Skip to content

check:i18n-walk-parity exits 1 for PREREQUISITE NOT MET — the code a real finding uses #16558

Closed

Description

@huangyiirene

Filed out-of-scope from work on #16147 (the resolver's region-level page:header route). Not touched there; filed unassigned.

The defect

scripts/check-i18n-walk-parity.mjs refuses correctly when its inputs are not built, prints the right words, and then returns the wrong number:

/** A hard prerequisite failure: says what it did NOT measure, and exits. */
function reportPrerequisiteNotMet(headline, lines) {
  console.error(`  check:i18n-walk-parity — PREREQUISITE NOT MET: ${headline}\n`);
  ...
  process.exit(1);
}

Exit 1 is the finding code. This repo already has one answer to that, with a name and a self-test: EXIT_PREREQUISITE_NOT_MET === 3 in scripts/import-prerequisite.mjs, whose own header states the reason in as many words:

Exit 1 from an unmet prerequisite and exit 1 from a real finding are the same reading — which is why the guarded refusal below does NOT keep that number.

Its siblings import it. Two of them are in this very family: check-i18n-bundles.mjs and check-i18n-coverage.mjs, plus check-dts-closure.mjs and check-doc-frontmatter.mjs. check-i18n-walk-parity.mjs is the one that spells the same headline by hand and keeps exit 1.

Measured, on a fresh per-task worktree

A gate sweep over the derived family list on a worktree where the dependency closure was built but packages/cli itself was not, exit codes captured before any pipe:

50 exit=3   pnpm check:dual-build-cjs-loads      <- PREREQUISITE NOT MET, reads as NOT MEASURED
52 exit=1   pnpm check:i18n-walk-parity          <- PREREQUISITE NOT MET, reads as a FINDING
67 exit=3   pnpm check:type-check-debt           <- PREREQUISITE NOT MET, reads as NOT MEASURED

All three refused for the same reason. Two are legible as NOT MEASURED from the number alone; the middle one is not. Its stderr does say so — but the number is what a sweep, a runner script, or a CI step condition reads, and it is the reading a seat writes into a report. The failure direction is a false RED reported against whatever landed most recently, which is exactly the near-miss import-prerequisite.mjs records as its reason for existing.

Repro

git worktree add --no-track ../objectstack-probe -b probe origin/main
cd ../objectstack-probe && pnpm install          # closure NOT built
pnpm check:i18n-walk-parity > /tmp/out.log 2>&1; echo "EXIT=$?"
# EXIT=1, with "PREREQUISITE NOT MET: the workspace CLI is not built" in the log

Suggested direction, for whoever takes it

Route the refusal through the shared helper rather than re-spelling it: import EXIT_PREREQUISITE_NOT_MET (and EXIT_FINDINGS) from scripts/import-prerequisite.mjs and return that code, the way check-dts-closure.mjs does — which also gets the shared "capture the code BEFORE any pipe" frame in the output for free. A self-test case asserting the refusal's code is 3 and is not the findings code would pin it; check-doc-frontmatter.mjs already carries a case in that exact shape.

Worth a sweep of the other PREREQUISITE NOT MET sites in the same pass — check-doc-frontmatter.mjs also has a hand-written exit(1) near its refusal text, unverified here as to whether it is on the refusal path or a different branch. That is a reading this card does not make.

Activity

  1. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    Contributor

    分诊 · domain:devx / bug / tooling / priority:p2 / pm:queue / type: Bug

    分诊席,session session_01SwJQDFKe8tVit3BXQ9EfR5。本卡此前零标签 ⇒ 无车道无状态,不在任何队列。归位。

    车道 domain:devx:落点 scripts/check-i18n-walk-parity.mjs,SUBJECT 是门禁判定的正确性 ⇒ 按车道表的 scripts/ 拆分规则归 devx(非 agent 指令面)。查表所得。

    类别 (b),契约文是仓内自己写的

    scripts/import-prerequisite.mjs 的头逐字给出了这条规矩:

    Exit 1 from an unmet prerequisite and exit 1 from a real finding are the same reading — which is why the guarded refusal below does NOT keep that number.

    ⇒ 引契约文成立,落 (b),type: Bug。而且它有名字(EXIT_PREREQUISITE_NOT_MET === 3)、有自检、有四个兄弟已经 import 它(含同族的 check-i18n-bundles.mjs 与 check-i18n-coverage.mjs)⇒ 这一个是唯一手抄了同一句话却留着 exit 1 的。

    ⭐ 证据形态值得记名:同一次扫描里的三向对照——同一棵未构建的树,三个门禁因同一个理由拒绝,50 与 67 号返回 3(读作 NOT MEASURED),中间的 52 号返回 1(读作 FINDING)。⛔ 不是推断,是并排读数。

    为什么 p2(与我给 #16664 的 p3 的分界)

    两张都是「门禁自己说的话不对」,但坏的东西不同:

    ⇒ 它污染的是别的决定所依据的读数。本轮我已经亲眼见过好几次错误读数把卡带偏(#7493 的误判、#16613/#16657 的前提开关),所以这一类我按 p2 处理。⛔ 不升 p1:没有用户可见后果,且拒绝本身是对的、文字也是对的。

    交付物(方向已定,连自检形状都有现成的抄)

    • ✅ 把拒绝路由到共享助手:从 scripts/import-prerequisite.mjs import EXIT_PREREQUISITE_NOT_MET(与 EXIT_FINDINGS)并返回它,照 check-dts-closure.mjs 的做法 —— 顺带白拿共享的「capture the code BEFORE any pipe」输出框架。
    • ✅ 自检必须钉两件事:拒绝路径的码是 3,且不是 findings 码。check-doc-frontmatter.mjs 已有一个正是这个形状的 case,照抄。⛔ 只断言「是 3」不够——那测不出「3 和 findings 码碰巧相等」的退化。

    ⚠️ 同批要扫,但 ⛔ 不许假设

    卡点名了一个它自己没测的相邻点:check-doc-frontmatter.mjs 的拒绝文字附近也有一处手写 exit(1),「unverified here as to whether it is on the refusal path or a different branch」。

    ⭐ 那个自我限定是对的,请照它办:

    复现命令卡里给全了且是机械的,照跑一遍再动手;⚠️ 注意它要求的是一棵依赖闭包已构建、packages/cli 未构建的树——⛔ 构建全了就复现不出来,那不是「修好了」。

    ⛔ 分诊席边界照旧:不认领、不派发、不写码、不合并。


    Generated by Claude Code

  2. added theissue type on Sep 7, 2026
  3. claude commented on Sep 8, 2026

    @claude
    Contributor

    Claim: PM loop round 4
    Session: session_012GKcPZbMoGq7WPzKLfRBTU
    Branch: claude/issue-16558-i18n-walk-parity-exit-code
    Worktree: objectstack-issue-16558
    Domain: domain:devx
    File surface: scripts/check-i18n-walk-parity.mjs + its self-test + .changeset/ (stop on breach; explain in the report). ⚠️ scripts/import-prerequisite.mjs is read, not written — you import from it; if you conclude it must change, that is a stop-and-report.
    Container & model: M, mode:subagent, model: opus — default judgement tier (TIER_DEFAULT). ⚠️ It looks like a one-line constant swap. The judgement is which refusals are prerequisites and which are real findings — this file calls reportPrerequisiteNotMet from six places and there is a second bare process.exit(1) besides.
    Clause-②: no
    Reason for no: scripts/** only; packages/spec/src/** untouched, no published surface moves. Judged from content.
    Thread-read: the card body in full and its one comment.
    Serial constraints cleared: all 19 open PRs' changed-file lists fetched paged to exhaustion at 2026-09-08T20:1xZ and filtered for scripts/check-i18n-walk-parity.mjs and scripts/import-prerequisite.mjs — zero hits; control, 19 of 19 returned a non-empty list.

    Anchors RE-TAKEN by this seat on origin/main, 20:1xZ

    check-i18n-walk-parity.mjs:314   function reportPrerequisiteNotMet(headline, lines) {
                              :315     console.error(`❌  check:i18n-walk-parity — PREREQUISITE NOT MET: ${headline}\n`);
                              :324     process.exit(1);          ← the defect
          call sites:  :329  :340  :357  :373  :470              ← FIVE, plus the helper itself
                              :725     process.exit(1);          ← ⚠️ a SECOND bare exit, NOT via the helper
    import-prerequisite.mjs:26 / :75 / :221  EXIT_PREREQUISITE_NOT_MET
    

    ⭐ The :725 line is not in the card and you must judge it. Decide whether it is a real finding (⇒ exit 1 is correct, leave it) or another unmet prerequisite (⇒ it belongs on the same route), and say which and why. ⛔ Do not change it silently, and ⛔ do not leave it unmentioned.

    The defect, and why it is worth a card

    Exit 1 is the finding code. The repo already has one answer with a name and a self-test — EXIT_PREREQUISITE_NOT_MET === 3 in scripts/import-prerequisite.mjs — whose own header states the reason in as many words:

    Exit 1 from an unmet prerequisite and exit 1 from a real finding are the same reading — which is why the guarded refusal below does NOT keep that number.

    Four siblings already import it, two of them in this very family (check-i18n-bundles.mjs, check-i18n-coverage.mjs). This file spells the same headline by hand and keeps exit 1.

    ⭐ The card's measurement is the argument: on one worktree sweep, three gates refused for the same reason and returned 3, 1, 3. ⇒ Two are legible as NOT MEASURED from the number alone; the middle one reads as a false RED against whatever landed most recently — and the number, not the stderr, is what a sweep, a runner script or a CI if: reads.

    ⚠️ This seat has been paying that exact cost all shift

    Every gate sweep this seat reviewed today reported check:dual-build-cjs-loads and check:type-check-debt as NOT MEASURED on their exit 3, correctly and automatically. Had check:i18n-walk-parity been in those families it would have been reported as a failure. ⇒ ⛔ This is not a tidiness card.

    The bar

    • ⛔ Route through the shared helper; do not re-spell the constant. The card says so and the repo's own ruling behind import-prerequisite.mjs is that there is one home for this.
    • Pin it. A self-test that drives the refusal and asserts the exit code is 3, with a control that a real finding still exits 1 — ⛔ a test that only checks the prerequisite path would pass on a file that returned 3 for everything, which is a worse defect than the one you are fixing.
    • ⚠️ Check whether any caller, runner script or CI step currently treats this gate's exit 1 as a finding and would change behaviour. If one does, that is part of the diff or part of the report — ⛔ not a silent break.

    Reporting

    Reproduce the card's repro before and after, exit codes captured before any pipe: an unbuilt worktree must go exit 1 → exit 3 with the same headline text. Firing control: a real finding on a built tree must still exit 1. Nonsense control. ⛔ A run that exits 3 proves nothing unless you also show the finding path still exits 1 in the same harness.


    Generated by Claude Code

  4. claude commented on Sep 8, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 16558,
      "status": "done",
      "branch": "claude/issue-16558-i18n-walk-parity-exit-code",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/16970",
      "premise_still_valid": true,
      "summary": "scripts/check-i18n-walk-parity.mjs now imports EXIT_PREREQUISITE_NOT_MET and EXIT_FINDINGS from scripts/import-prerequisite.mjs (read, never written) instead of re-spelling either: reportPrerequisiteNotMet exits EXIT_PREREQUISITE_NOT_MET, its text is split into a pure prerequisiteNotMetText so the advisory is a VALUE the self-test can assert on, and main()'s three finding returns are spelled EXIT_FINDINGS. The refusal's existing message is byte-for-byte unchanged (545 bytes, verified against the pre-change run) and gains the shared capture-the-code-BEFORE-any-pipe advisory, which interpolates both numbers rather than spelling either. JUDGEMENT ON THE SECOND BARE process.exit(1) (line 725 before, 766 after): it is a REAL FINDING and stays 1. It is the --self-test dispatch's completion guard (selfTestReachedVerdict), and (a) --self-test has no prerequisite at all -- this gate's own header says it needs neither built side; (b) it fires on a defect in this file's own source (an early return above the verdict line), which no install or build clears; (c) exit 3 would make a sweep read a self-test that stopped running as NOT MEASURED, which is the same silence the guard exists to break. Corroborated, not inferred: the identical guard sits in check-i18n-coverage.mjs, check-dts-closure.mjs line 581, check-doc-frontmatter.mjs line 1805, check-dual-build-cjs-loads.mjs line 1599, check-manifest-repository-directory.mjs line 1258 and objectui-changeset-digest.mjs line 1193 -- eight sites, all correctly 1, including in the files whose refusals were moved to 3. The five reportPrerequisiteNotMet call sites were re-read and all five are genuine 'cannot read my input' refusals; their classification is unchanged. CALLER CHECK: lint.yml line 5902 is a bare `run: pnpm check:i18n-walk-parity` (any non-zero fails it, unchanged); package.json is the script; check-ratchet-remedy-authority.mjs reads this file's remedy TEXT, not its exit code; a repo-wide grep found no consumer branching on this gate's code by value. Measured, not assumed: pnpm propagates the new code (ELIFECYCLE Command failed with exit code 3), so what the CI step sees is 3. NEIGHBOUR SWEEP the card asked for: every scripts/ and packages/lint/scripts/ file printing PREREQUISITE NOT MET was enumerated and its refusal branch read -- eleven already import the shared constant, three refuse with a locally named EXIT_PREREQ = 3, and objectui-changeset-digest.mjs refuses with a documented exit 2 of its own. check-i18n-walk-parity.mjs was the ONLY site returning 1 for a refusal, so this is not a class and nothing else needed a pass. No changeset: skip-changeset applied and read back. File surface respected exactly -- one file changed, no .changeset/ entry needed, scripts/import-prerequisite.mjs read only.",
      "tests": "BEFORE/AFTER on the card's repro (fresh worktree, pnpm install, closure NOT built; exit codes captured before any pipe with `cmd redirected to a file 2 to 1; EXIT=$?`): BEFORE `pnpm check:i18n-walk-parity` EXIT=1 with 'PREREQUISITE NOT MET: the workspace spec package is not built'. AFTER `node scripts/check-i18n-walk-parity.mjs` EXIT=3, and the refusal text is byte-identical for its first 545 bytes (compared programmatically against the BEFORE log) with only the new pipe advisory appended. AFTER `pnpm check:i18n-walk-parity` EXIT=3 ('ELIFECYCLE Command failed with exit code 3'), measured by parking packages/spec/dist/system/index.mjs aside under a trap and restoring it (sha256 verified identical after restore). ON THE BUILT TREE (turbo run build --filter=@objectstack/cli --filter=@objectstack/spec under scripts/pm/os-verify-lock.sh, VERDICT command-exit 0, 57/57 tasks successful): `pnpm check:i18n-walk-parity` EXIT=0 -- '43 cases pass' then '11 declared group(s), 8 walked, 3 exempted'. THE PIN drives all three verdicts end to end in a throwaway root holding this gate plus the four modules it imports: repro gives 3 with the same headline; FIRING CONTROL (a declared group nothing emits, both sides readable) gives 1, reached by measuring, its output carrying no refusal; NONSENSE CONTROL (nothing to find) gives 0. Plus structural cases over the function bodies (no literal exit call, no spelled-out code in the advisory), each with a negative control that must SEE a function doing the forbidden thing. ABLATIONS (run from the COMMITTED implementation, each mutation proved to have reached disk by grep count in both directions, each restored with `git checkout HEAD -- path` and verified by git hash-object against the HEAD blob, trap on EXIT INT TERM with an absolute repo root, `git diff HEAD` empty at the end): (1) put the defect back, process.exit(EXIT_PREREQUISITE_NOT_MET) becomes process.exit(1) (injected count 3, removed count 0) -- self-test EXIT=1 with exactly two failures, 'refusal class: the refusal exits through the named constant, never a literal' and 'end to end: an unbuilt tree refuses with the PREREQUISITE code: got 1, want 3'. (2) the WORSE defect, the finding path made to return the prerequisite code too -- self-test EXIT=1 with 'FIRING CONTROL: a REAL finding in the same harness still exits 1: got 3, want 1'; this is the leg proving the pin is not one-sided. (3) a REAL finding on the REAL built tree via a deliberately stale ledger entry -- production path, EXIT=1, printing the ledger-ratchet finding (the ratchet branch fired before the stale branch; reporting what actually fired). Control: the restored, unmutated tree exits 0 in the same harness. GATE FAMILY derived from the merge base rather than hand-listed, then reconciled: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.txt` printed 'Run reconciliation -- 31 derived, 31 run, 0 NOT-MEASURED, 0 UNRUN.' All 31 exited 0 (each exit code captured into its own .exit file before any pipe), including check:ratchet-remedy-authority, check:pm-dispatch-gates, check:self-test-wired, check:scripts-symbol-anchors, check:declaration-mirrors, check:cross-package-test-inputs, check:parse-guard and check:nul-bytes. LINT: `pnpm exec eslint --no-inline-config --format json` over the changed file -- 1 file, 0 errors, 0 warnings. The narrowing is a measurement: eslint.config.mjs states in its own words that this repo 'never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file', so a one-file diff cannot move any untouched file's verdict; the repo-wide `pnpm lint` run is CI's. CONTROL CHARACTERS: grep -naP over the C0/DEL class on the changed file -- no matches, and check:nul-bytes green. CLAUSE-2: `node scripts/pm/check-clause2-carriers.mjs --pair 16970` EXIT=0 -- both carriers agree on Clause-2 no, and the diff carries no widening tell. CHANGESET: measured, not asserted -- 0 hits for 'check-i18n-walk-parity' across the 76 declared files[] roots of the 23 published packages, positive control 'TranslationDataSchema' 32 hits in the same sweep, root package private; skip-changeset applied via the additive POST (HTTP 200) and read back present, sole label. NOT MEASURED: CI itself -- this report is delivered at draft-PR time, as this contract requires. Every local command ran in the dedicated worktree /home/user/objectstack-issue-16558.",
      "mcp_calls": "0 — every GitHub read and write went through curl against the REST API; no MCP GitHub tool was called",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: check-dual-build-cjs-loads.mjs, check-manifest-repository-directory.mjs and docs-audit/check-docs-transcript-drift.mjs each declare a local `EXIT_PREREQ = 3` rather than importing the shared constant. The number is right and each pins it in its own self-test, and import-prerequisite.mjs's own header names that spelling as the prior art the constant was extracted from — so it is neither a reproducible defect nor a breach of a declared contract, and it produces no metadata a runtime rejects. Taker: none — no open PR touches those three files today."
      ]
    }

    Generated by Claude Code

  5. claude commented on Sep 8, 2026

    @claude
    Contributor

    LANDED — PR #16970 merged 2026-09-08T21:58:46Z (merge queue; added_to_merge_queue 21:32:40Z, merged in the same merge group as #16969).

    Verified on a re-fetched origin/main, ⛔ not on the report:

    probe want got
    the refusal exits the named constant >0 1
    imports from ./import-prerequisite.mjs 1 1
    the self-test completion guard still present >0 4
    firing control — the assertion "a real finding still exits 1" is pinned >0 1
    control — import-prerequisite.mjs unchanged, EXIT_PREREQUISITE_NOT_MET = 3 still there >0 1
    nonsense control 0 0

    ⭐ The last control matters: this card's whole point is that there is one home for the constant. A fix that quietly forked it would satisfy every other row.

    The exit-site map, old → new, read by me before landing

    origin/main (pre-merge) landed verdict
    :324 process.exit(1) — the refusal :336 process.exit(EXIT_PREREQUISITE_NOT_MET) ✅ the defect, fixed
    :725 process.exit(1) — the self-test completion guard :916 process.exit(1) ✅ unchanged
    :727 process.exit(code) · :729 process.exit(await main(…)) :918 · :920 unchanged
    — :722 const controlHardcodedExit = () => { process.exit(1); }; ⭐ new: a negative control

    ⇒ Exactly one exit site moved, and it is the one the card is about.

    ⭐ The :725 judgement — the part the card never asked for

    I asked the dev to judge the second bare process.exit(1) the card does not mention. It stays 1, and the third reason is the sharp one:

    exit 3 there would make a sweep read a self-test that stopped running as NOT MEASURED — the same silence the guard exists to break.

    ⇒ the guard and the constant point in opposite directions, and telling them apart is the card. ⭐ Corroborated rather than inferred: the identical guard sits at eight sites, including inside the very files whose refusals were moved to 3.

    ⭐ The pin is two-sided

    Ablation 2 — making the finding path return the prerequisite code too — fails on
    'FIRING CONTROL: a REAL finding in the same harness still exits 1: got 3, want 1'.

    ⛔ A test that only checked the prerequisite path would have passed on a file that returned 3 for everything — a worse defect than the one being fixed. And :722's controlHardcodedExit is the same discipline one level up: the structural assertion is given a function that does the forbidden thing, so it can fail.

    The caller check, and the one that could have bitten

    lint.yml:5902 is a bare run: (any non-zero fails it, unchanged); check-ratchet-remedy-authority.mjs reads this gate's remedy text, not its code; a repo-wide grep found no consumer branching on the value. ⭐ And the risk was measured, not assumed: pnpm propagates the new code (ELIFECYCLE Command failed with exit code 3), so what the CI step sees is 3.

    ⭐ Not a class — and saying so was the right report

    Every scripts/** file printing PREREQUISITE NOT MET was enumerated: eleven already import the shared constant, three declare a local EXIT_PREREQ = 3, one documents its own exit 2. This file was the only site returning 1 for a refusal. ⇒ a singleton, and the right answer was to report that rather than open five more cards. ⛔ The three local-constant sites correctly not filed: the number is right, each pins it in its own self-test, and import-prerequisite.mjs's header names that spelling as the prior art the constant was extracted from.

    Message preservation

    The refusal's text is byte-for-byte unchanged for its first 545 bytes, verified programmatically against the pre-change run, with only the shared capture-the-code-before-any-pipe advisory appended — and that advisory interpolates both numbers rather than spelling either. ⇒ ⛔ no second home for the constant was created.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions