Skip to content

fix(devx): check-regen-pending refuses on an unmet prerequisite instead of reading a crash as a stale artifact (#15722) - #15785

Merged
baozhoutao merged 5 commits into
mainfrom
claude/issue-15722-regen-pending-prereq-and-ff
Sep 5, 2026
Merged

fix(devx): check-regen-pending refuses on an unmet prerequisite instead of reading a crash as a stale artifact (#15722)#15785
baozhoutao merged 5 commits into
mainfrom
claude/issue-15722-regen-pending-prereq-and-ff

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #15722

Two halves were filed. The narrowing measurement was taken first, and it withdrew half 2. Half 1 is fixed; half 2 is pinned as a fixture so the reading that withdrew it cannot be re-derived.

1. The narrowing measurement — half 2 does NOT reproduce, with deps or without

The card read the refusal as a predicate over "every merge=os-regen path that changed between the previous and the new HEAD". It is not a predicate over paths at all. The hook is three lines:

if [ -z "$OS_SKIP_REGEN_CHECK" ]; then
  cat >/dev/null
  node "$(git rev-parse --show-toplevel)/scripts/check-regen-pending.mjs" --pre-push || exit 1
fi

and the pending set is read from a FILE:

const marker = join(gitDir, PENDING_MARKER);          // $GIT_DIR/os-regen-pending
const { paths: pending, deferral } = readMarker(marker);
if (!pending.length) {  return 0; }

The only writer of a path line is markPending() in scripts/git-merge-regen.mjs — the merge driver — which git invokes only when it actually attempts a text merge on a path. A fast-forward invokes no driver.

Reproduced exactly as the card prescribes: branch at e13ede817^ (036b4cac2), git pull --ff-only origin main across e13ede817 (#15689, which changed content/docs/permissions/system-context.mdx), then the check run the way the hook runs it.

$ git reflog -1
b25a5fc32 HEAD@{0}: pull --ff-only origin main: Fast-forward
$ git diff --stat HEAD origin/main | wc -l
0
$ git diff --name-only 036b4cac2 HEAD -- content/docs/permissions/system-context.mdx
content/docs/permissions/system-context.mdx
$ printf '' | node scripts/check-regen-pending.mjs --pre-push > /tmp/ff.log 2>&1; echo EXIT=$?
EXIT=0          # deps installed  — /tmp/ff.log is 0 bytes
EXIT=0          # no node_modules — /tmp/ff.log is 0 bytes

Half 2 is WITHDRAWN. What the reporting seat hit was a marker left by an EARLIER real merge in that worktree, which half 1 made impossible to clear: the marker is removed only when every gate passes, the gate can never pass without node_modules, so the marker was stuck and every later push in that worktree met it — fast-forward or not. The two halves are one causal chain, and fixing half 1 unsticks it.

The four readings that separate the marker from the fast-forward (same commit, same tree):

marker node_modules verdict exit
absent (after the ff) installed no output 0
absent (after the ff) absent no output 0
present absent stale + ERR_MODULE_NOT_FOUND stack 1
present installed current, marker cleared 0

2. Half 1 — the population is 13 gates, not one

The card names one artifact. Measured on an uninstalled tree with all 14 registered paths in the marker, 12 of the 14 were reported stale over three distinct failures, none of which is a verdict about a file:

what actually failed count today
the RUNNER is missing (sh: 1: tsx: not found) 10 stale
node could not LINK the gate (ERR_MODULE_NOT_FOUND) 1 stale
the gate ALREADY refused PREREQUISITE NOT MET, exit 3 1 stale
this file's own readsDist / readsSchemaTree refusals 2 already honest — not changed here

That third row is the landed #11557 fix undone one process boundary out: check:platform-object-tenancy-census says PREREQUISITE NOT MET in so many words and its caller relabelled it stale.

Before → after, on the card's own artifact

BEFORE (exit 1)
  ✗ content/docs/permissions/system-context.mdx — stale
        node:internal/modules/package_json_reader:314
          throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base), null);
      pnpm gen:system-context-census

AFTER (exit 3)
  ⚠ content/docs/permissions/system-context.mdx — NOT MEASURED (`check:system-context-census` could not run)
        node:internal/modules/package_json_reader:314
        …
      Fix:  pnpm install

check-regen-pending: PREREQUISITE NOT MET — `check:system-context-census` could not run: the dependency `typescript` is not installed
  …
  Nothing was measured: this gate exited before running a single check, so this
  result says NOTHING about whether content/docs/permissions/system-context.mdx is stale.
  (Exit code 3, distinct from a finding's 1 …)

Across the 14 paths: — stale lines 12 → 0, NOT MEASURED lines 0 → 12.

No new spelling. The frame, the wording and the exit code are #11824's, imported: reportPrerequisiteNotMet, EXIT_PREREQUISITE_NOT_MET, INSTALL_FIX, and classifyImportFailure for the four-way diagnosis (uninstalled / unbuilt workspace package / broken install / a dependency of a whole package). Only the detail is written here, which is the half #11824's header reserves for the gate.

Nothing is loosened.

3. Self-test: 23 → 36 cases, exit 0 both

node scripts/check-regen-pending.mjs --self-test; echo EXIT=$?before 23 cases, EXIT=0 (run at b25a5fc32); after 36 cases, EXIT=0. Thirteen new, in fixtureSelfTest (the battery roster and its floor of 3 are unchanged — the unit is the callee, and no callee was added):

  • 5 — a gate that cannot LOAD: refusal + exit 3, never stale, "Nothing was measured", the package named, the marker still holding the debt.
  • 2 — a gate whose RUNNER is missing, named in the diagnosis.
  • 2 — a gate that already refused, propagated rather than relabelled.
  • 1 — the control: a gate that RAN and exited 1 keeps today's verdict.
  • 3 — the half-2 pin: a --ff-only across a changed merge=os-regen artifact is a fast-forward, writes no marker, and the push is accepted.

The two prerequisite stubs are REAL failures, not printed imitations: node --input-type=module -e "import 'os-regen-fixture-absent-pkg';" makes node fail to link, and os-regen-fixture-absent-runner --check hands the shell a command that is not on PATH. A fixture that echoed the text would pass against an implementation that matched on text alone.

4. Ablation — per half, trap-guarded, blob-hash restore

Both legs run against the COMMITTED implementation. No build or dist/ is involved: the fixture spawns fileURLToPath(import.meta.url) — the very file being mutated — so ablation-dist-preflight has no artifact to assert about and the on-disk proof is the blob hash plus an anchored grep -c on both the deleted and the injected text.

A1 — remove the grading (if (unmeasured.length)if (false), and gateCouldNotRun's result discarded):

PRE  blob=93af08f21a321eb990327bfc81c4fd2b32089f28 (== HEAD:scripts/check-regen-pending.mjs)
  deleted  'reportPrerequisiteNotMet(' occurrences: 1     injected 'OS_ABLATION_A1': 2
POST blob=04418e5e1d65fd967e65fe4782570dc075c89c4e (differs from HEAD — the mutation is on disk)
ABLATED_EXIT=1   → 8 of the new cases RED
RESTORED blob=93af08f21a321eb990327bfc81c4fd2b32089f28  git-diff-HEAD=''   RESTORE VERIFIED

The stale control and the three fast-forward cases stay GREEN under A1 — they must, since A1 is exactly the pre-fix behaviour.

⚠️ A1 was run twice on purpose. The first run left two sub-assertions GREEN: they matched a string the raw child output carries either way. That is a fixture passing against the pre-fix code, so they were rewritten to assert the DIAGNOSIS (the gate's runner ... is not installed, refused to measure — ...), and the second A1 run reds them. The first reading is reported here rather than replaced.

A2 — re-derive the pending set the way the withdrawn half 2 proposed (pending filled from git diff --name-only HEAD@{1} HEAD filtered through entryForPath when the marker is empty):

  injected 'OS_ABLATION_A2': 1
POST blob=da6ba9478ff92a4d9acfd7b56327f1c2971d9f8d (differs from HEAD)
ABLATED_EXIT=1   → exactly one case RED:
  ✗   …so the push is ACCEPTED — a fast-forward is not a merge without a text merge
RESTORED blob=93af08f21a321eb990327bfc81c4fd2b32089f28  git-diff-HEAD=''   RESTORE VERIFIED

So the half-2 pin is non-vacuous against the exact "fix" the card proposed.

5. Verification

  • node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstack; echo EXIT=$?EXIT=0, 31 commands. The first derivation warned STALE TREE (2 family-defining files moved on origin/main), so it was re-derived from a tree AT origin/main (2dec9576d) with the same one path: command list identical.
  • 31/31 green. Two needed a prerequisite cleared first and were re-run, not excused: check:docs (no packages/spec/json-schema) and check:generated (check:api-surface, no dist) both exit 1 on an unbuilt tree; after pnpm --filter @objectstack/spec build through scripts/pm/os-verify-lock.sh (VERDICT command-exit 0 · held the lock 180s · waited 81s) both exit 0, with the working tree still clean afterwards.
  • pnpm check:nul-bytes — green (in the 31). Plus a direct scan of the diff: grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' on both changed files, no match.
  • pnpm check:merge-driver — green; node scripts/git-merge-regen.mjs --self-test run explicitly too: green, "merge driver wiring is consistent (24 path(s) deliberately excluded)".
  • node scripts/pm/check-governed-merges.mjs --test scripts/check-regen-pending.mjs .githooks/pre-pushNOT governed (0 of 2 paths hit the register), re-run on this final file list.
  • pnpm lint (repo-wide, eslint . --no-inline-config) — green in full, 108s. No narrowing declared.
  • TURBO_SCM_BASE=b25a5fc32 pnpm exec turbo ls --affected0 packages; the diff is root scripts/ only. The two tests that import this module were still run: pnpm --filter @objectstack/spec exec vitest run scripts/dist-freshness.test.ts scripts/schema-tree-freshness.test.ts — 2 files, 21 tests, green. check:scripts-typecheck green.
  • node scripts/check-declaration-mirrors.mjs and its --self-test — green. The partial mirror in scripts/check-regen-pending.d.mts is deliberate and unchanged; only the export count in that gate's prose (seven → eight) follows the module.

NOT MEASURED, by name. dispatch-gates prints three families whose argv takes a value only a CI run supplies, and they are not runnable here:

  • scripts/check-cross-package-test-inputs.mjs --union-into "$RUNNER_TEMP/turbo-ls.json" --changed "$RUNNER_TEMP/changed-files.txt"
  • scripts/check-shard-attestation.mjs --emit --job test --shard (matrix.shard) --total 6 --out "$RUNNER_TEMP/shard-attestation"
  • scripts/check-test-completeness.mjs "$RUNNER_TEMP/test-core.log" --scheduled "$RUNNER_TEMP/shard-packages.txt" --package-list "$RUNNER_TEMP/turbo-ls.json"

Also not a clearance, quoted from the tool: 10 families declare a population too wide to place, and 38 artifact rosters score silent for every card in the tree.

6. Changeset

skip-changeset. AGENTS.md: "that label is for a diff that publishes nothing from any released package." Both changed files are root scripts/ tooling; no package publishes them.

Out of scope, filed

#15783check:system-context-census itself still dies with a raw ERR_MODULE_NOT_FOUND stack when run directly (a bare top-level import ts from 'typescript' in scripts/isystem-census.mjs, a site PR #11824 did not convert). This PR grades that failure at the CALLER; the gate's own reading is the other half and is not touched here.

Also deliberately NOT changed: this file's readsDist / readsSchemaTree branches keep their exit code, and the summary line still counts them among "the N stale artifact(s)". They already print what is unmet and what clears it, and the card does not name them.

🤖 Generated with Claude Code

https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk


Generated by Claude Code

…ad of calling the artifact stale

`check-regen-pending` does not regenerate and does not compare bytes: it spawns
each deferred artifact's `check:` gate and reads the exit status. Every reason a
gate has for exiting non-zero therefore arrived wearing the same clothes, and all
of them were printed as one word — `stale` — over three lines of whatever the
child said. On a checkout with no `node_modules` that is a verdict about a file
nothing read.

Grade the failure instead. A gate that could not LOAD is reported in #11557's
landed frame — `reportPrerequisiteNotMet`, `EXIT_PREREQUISITE_NOT_MET`, both
imported from `scripts/import-prerequisite.mjs`, never restated — and the run
exits 3. Three shapes, all measured live on an uninstalled tree across the 14
registered paths: the runner missing (`sh: 1: tsx: not found`, 10), a raw
`ERR_MODULE_NOT_FOUND` link failure (1), and a child that already refused with
`PREREQUISITE NOT MET` and exited 3 (1) — the landed fix undone one process
boundary out.

Nothing is loosened. The refusal is still a refusal, the marker still keeps every
deferred path, and a gate that RAN and failed still reads `stale` and exits 1.
The prerequisite branch sits BELOW `defer`, so a merge commit that cannot run its
gates still defers rather than being refused — putting that back would restore
the #8047 defect whose learned workaround was skipping the whole hook.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
…em dash

The shared frame already spells `PREREQUISITE NOT MET — <headline>`, so a second
em dash inside the headline read as one sentence broken twice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
…ng them

The shared frame inlines the `measures` clause into a sentence it does not wrap,
so a fourteen-path list there buries the clause that carries the reading.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
Two of the new fixture assertions matched a string the raw child output carries
either way, so they passed against the pre-fix code — measured under ablation A1,
where their parent cases went red and they stayed green. They now assert the text
only the refusal produces.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
…escribes

check-regen-pending.mjs now exports eight functions, not seven; the count is
prose in check-declaration-mirrors.mjs, not a pin, and the gate stays green
either way — but a stale number there is a lie this change introduced.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 5, 2026
@github-actions github-actions Bot added the size/m label Sep 5, 2026
@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — PR #15785 (head 6280e0680, scripts/check-regen-pending.mjs +344/−5 plus a one-word export count in check-declaration-mirrors.mjs) reviewed against the GitHub three-dot diff, not the report.

The narrowing measurement came first and half 2 is WITHDRAWN on it: the pending set is not a diff-derived predicate at all — it is read from $GIT_DIR/os-regen-pending, whose only writer is markPending() in git-merge-regen.mjs, called only when git runs the merge driver; a fast-forward runs none. Reproduced as prescribed (branch at e13ede817^, git pull --ff-only origin main across #15689) → exit 0, empty, with and without deps. The seat's refusal was a marker left by an EARLIER real merge that half 1 made impossible to clear (the marker clears only when every gate passes, and no gate could pass without node_modules). Half 2 is pinned as three fixtures so the diff-derived predicate the card proposed cannot be re-derived (ablation A2: re-deriving pending from git diff HEAD@{1} HEAD reds exactly the ff-acceptance case).

Half 1 as ruled: a spawned check: gate that cannot LOAD is graded in #11824's landed frame (reportPrerequisiteNotMet / EXIT_PREREQUISITE_NOT_MET / INSTALL_FIX / classifyImportFailure imported, no new spelling); exit 3 is non-zero so the pre-push hook still refuses and the marker is kept; a gate that RAN and failed still reads stale / exit 1; the merge-commit defer path (#8047) unchanged. Measured on an uninstalled tree: — stale 12 → 0, NOT MEASURED 0 → 12 across 14 registered paths; green control with deps — current, marker cleared.

Measured by this seat: branch blob at scripts/--self-test EXIT=0 ✓ check-regen-pending self-test passed. (main EXIT=0, same line; the script prints no case count — the dev's 23 → 36 is from the battery roster); check-declaration-mirrors.mjs reads exit 0 on main and the branch's one-word change (seven → eight exports) is the mirror gate's own count; git merge-tree --write-tree origin/main <branch> → EXIT=0; governed test per the report NOT governed. NOT MEASURED here: the end-to-end uninstalled-tree runs and the spec build the dev cleared through the lock (VERDICT command-exit 0, held 180s) — the report is the record; pnpm lint repo-wide was green in the dev's box.

Dev-filed #15783 (isystem-census.mjs bare top-level import ts from 'typescript') is triage's; the untouched readsDist / readsSchemaTree exit-1 branches are declared in the PR and not owed by the card.

Flip + arm when both required jobs report success on 6280e0680. On landing #15722 closes (narrowed to half 1, as the card itself allowed).


Generated by Claude Code

@baozhoutao
baozhoutao marked this pull request as ready for review September 5, 2026 08:15
@baozhoutao
baozhoutao enabled auto-merge September 5, 2026 08:15
@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Flipped ready + auto-merge enabled (08:15Z, method: MERGE). Required jobs on 6280e0680: Lint & Repo Gates = success, TypeScript Type Check = success; git merge-tree --write-tree origin/main <branch> → EXIT=0 against the current main. Watched; on landing #15722 closes (narrowed to half 1).


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants