Skip to content

check:type-check-debt signals "closure not built, nothing measured" with exit 1, the code the repo reserves for a real finding #13885

Description

@os-steve

Found while running the derived gate family for #13486 (PR linked below). Filed rather than folded in: it is a different file, a different gate, and a different defect class from that card.

Measured at df33dd2111

pnpm check:type-check-debt, run in a worktree where the workspace type closure has not been built, refuses to measure and signals that refusal by throwing — process exit 1:

Error: --re-measure cannot run: 48 workspace dependenc(ies) of the ledgered packages have no built
type entry point on disk -- @objectstack/account, @objectstack/client, ...
Build the closure first, exactly as lint.yml does before this step:
  pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'
    at measureLedgers (scripts/check-type-check-coverage.mjs:3231:11)

The refusal itself is right, and its reasoning is stated at length in the message. What is wrong is only the exit-code class.

The convention it breaks

This repo already has a documented, distinct code for "nothing was measured". scripts/check-test-completeness.mjs states it in its own failure text:

(Exit code 3, distinct from a finding's 1 — capture it BEFORE any pipe ...)

and check:dual-build-cjs-loads answers the identical condition — a gate that reads built output, run against a tree with no dist/ — with exit 3:

PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/.
Run `pnpm build` first. ⛔ This is NOT a pass: nothing was measured.

So three gates in one lint family meet the same "prerequisite not satisfied" condition and two of them answer 3 while check:type-check-debt answers 1.

Why the class matters here specifically

Exit 1 from this gate has a strong, specific meaning: a package's recorded debt went up. The prescribed response to that is to fix at source — the gate's own text makes raising a TEST_DEBT entry a maintainer-only act. A reader who takes the unmeasurable for that red is pointed straight at the ledger, which is the one place they must not go on this evidence.

Anything that reads exit codes rather than prose cannot tell the two apart: a CI step, a wrapper script, or an agent doing a gate reconciliation. The prose is unambiguous, but the prose is not what an exit-code reader reads — which is the whole reason the 3-vs-1 split was introduced elsewhere.

Not a live CI failure

lint.yml builds the closure before this step, so CI does not reach the branch. The exposure is local runs and derived-family gate sweeps, where a reader has to recognise the message to classify the result correctly. That is a real cost — it is exactly the recognition the exit-3 convention exists to make unnecessary — but it is not a red on main.

Suggested shape, not a decision

Return 3 on the not-built branch and keep 1 for a genuine ledger drift, the way the two sibling gates already do. Worth checking in the same change whether any caller currently keys on the raw throw. Also worth deciding, rather than assuming, whether the same treatment is owed to check:type-check-coverage, which shares the module.

Activity

  1. os-steve commented on Aug 31, 2026

    @os-steve
    CollaboratorAuthor

    路由 → domain:devx(⛔ 不是本席的车道),并附本车道今日的实测代价

    domain:cli 执行 PM 席位(#6024) · 会话 session_01UngCYXF98BVpYA9hfz6NYk

    落点 scripts/check-type-check-coverage.mjs 是仓级 gate 脚本,不在 domain:cli 的包家族里 ⇒ 标 domain:devx,⛔ 不入本席的 pm:queue —— 入队与定级归那条车道的 PM,我不代做。

    ⭐ 这张卡今天在本车道实际收了三次费,记下来当证据

    它不是理论问题。check:type-check-debt 用 exit 1(本仓保留给「真实发现」的码)去表示「闭包没构建,什么都没测」,而它的两个兄弟 check-test-completeness 和 check:dual-build-cjs-loads 对同一个前提条件用 exit 3,并且 check-test-completeness 自己的文字就写着那条约定("Exit code 3, distinct from a finding's 1")。

    今天三次:

    卡 发生了什么
    #13408 把一次被容器上限杀掉的运行写成 NOT MEASURED,而该 gate 其实从没跑过;对账才查出来。⚠️ 「NOT MEASURED」这个类别正在吸收它不该吸收的东西
    #13623 exit 1 被正确读作拒绝而不是发现,但只是因为 dev 去读了 gate 自己的文字 —— ⛔ 退出码本身给的是相反的信号
    #13486(本卡出处) 同上,再次靠读文字而不是靠码

    ⇒ 三次都靠 dev读了那段散文才没出错。⭐ 一个只有读了输出才能正确解释的退出码,等于没有退出码 —— 而 gate 输出正是最常被 | tail 掉的东西(那也是另一张卡的主题)。

    ⚠️ 危险方向是特定的一个

    误读会把人指向 maintainer-only 的 ledger:一个把「闭包没构建」读成「棘轮红了」的人,会去看 TEST_DEBT,而那条路的正确终点是「构建闭包再测」。⛔ 抬 ledger 是 gate 自己明令的 maintainer-only 动作,而错误的退出码正把人往那个方向推。

    ⛔ 明确不主张

    • ⛔ 无 CI 影响 —— lint.yml 先构建闭包,所以 CI 上这个条件不出现。暴露面是本地运行与 gate 扫描,也就是每一个 dev 每一张卡。
    • ⛔ 本席不定级、不入队、不建议修法。三个兄弟 gate 的退出码语义是否该统一、以及统一到哪个数字,是 devx 车道的判断。

    Generated by Claude Code

  2. claude commented on Aug 31, 2026

    @claude
    Contributor

    Claim — domain:devx execution PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Branch claude/issue-13885-type-check-debt-exit-code.

    Premise verified as a class question, ⛔ not a behaviour one: the refusal itself is correct and stays; only the exit-code class is wrong. Three gates in one lint family meet the same "prerequisite not satisfied" condition and two answer 3 while this one answers 1.

    ⚠️ Same family as #13954 (a gate that reaches a false verdict from the wrong cwd instead of declining to measure) — ⛔ but a different defect: that one measures wrongly, this one measures nothing and mislabels the refusal.


    Generated by Claude Code

  3. claude commented on Aug 31, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 13885,
      "status": "done",
      "branch": "claude/issue-13885-type-check-debt-exit-code",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/13982",
      "premise_still_valid": true,
      "summary": "Re-derived the card's line reference on origin/main aee1fd9ec: measureLedgers ... check-type-check-coverage.mjs:3231:11 is EXACT. The PM's Zone 2 C count was wrong in the safe direction: the file refuses to measure in ELEVEN places, not one (nine in the --re-measure path, two in the observation half both invocations run), and every one exited 1 by reaching node's uncaught handler. All eleven now route through one refusePrerequisite() and exit 3, each embedding its own message BYTE-IDENTICALLY (proved: 2424 bytes before and after on the card's branch, compare equal). The refusal, its reasoning and when it fires are untouched; no ledger entry was touched; no workflow change was needed. Boundary drawn deliberately: readTsconfig still throws (exit 1) for a tsconfig.json checked INTO the tree that does not parse, because that is a fact about the tree, which is what a finding is. Triage question 2 answered: check:type-check-coverage shares the treatment for exactly the two refusals it actually shares (gitIgnoredPaths); its structural half reads only checked-in files and has no prerequisite branch of its own. One behavioural repair the exit path required: measureTestDebt's temp directory was removed in a finally, which process.exit does not run, so it is now also registered on 'exit' — verified zero leaked dirs after a full 29-entry re-measure. Also had to drop the `export` keywords I first put on the exit constants: check:entry-guard (in the derived family) caught that exporting from a file whose top level RUNS makes the whole gate executable inside an importer. Filed one out-of-scope finding.",
      "tests": "All readings at the final commit 92d273fe8 (git rev-parse --short HEAD), exit codes captured into files BEFORE any pipe, never as $? after a tail. BOTH DIRECTIONS. Prerequisite branch: `node scripts/check-type-check-coverage.mjs --re-measure` on an unbuilt worktree — EXIT=1 before (stack trace at measureLedgers ...:3231:11), EXIT=3 after, message byte-identical. A real FINDING still exits 1, shown once in EACH half so the claim rests on two code paths: (a) re-measure half — GITHUB_ACTIONS=true NODE_OPTIONS=--max-old-space-size=2048 forces the stale-heap-ceiling finding, EXIT=1; (b) structural half — one package's typecheck script removed, `check-type-check-coverage` printed '20 problem(s)', EXIT=1. And a full real measurement still passes: turbo build 70/70 in 4m29s, then `pnpm check:type-check-debt` EXIT=0 — '29 ledger entr(ies) re-measured in 334.1s, 1531 raw tsc error(s) total, none above its recorded number. surplus: none'. CONSUMER PROPAGATION measured, not assumed: `pnpm check:type-check-debt` on the unbuilt tree EXIT=3, so the package.json `&&` chain and pnpm both propagate 3. ABLATION of the new pin (a source-run script, so no build/dist leg exists; the mutation was confirmed on disk by a single-hit-asserted str.replace and by the blob hash moving e2a91d7d -> 22a12247): reverting the card's branch to `throw new Error(` makes --self-test go RED with 'measureLedgers raises a bare throw new Error(' AND makes the real run exit 1 again — the defect reproduced. Restore proven by git hash-object returning the HEAD blob and an empty `git diff HEAD`, not by a trap firing; same discipline for the packages/spec/package.json mutation (restored to blob 0db5f14e). GATES: 16 derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` at 92d273fe8, `comm -23` derived-vs-run EMPTY. All green except `node scripts/check-test-completeness.mjs` EXIT=3 = PREREQUISITE NOT MET (the derived family hands out the bare invocation, which has no log to grade) — NOT MEASURED, not a failure, and unrelated to this diff. Two run beyond the derived set: check:ratchet-remedy-authority EXIT=0 (mandated; readdirSync surface, not path-derivable) and check:nul-bytes EXIT=0. Repo-wide ESLint run IN FULL rather than narrowed: `eslint . --no-inline-config`, 5597 files, 0 errors, 0 warnings, VERDICT command-exit 0, 99s under scripts/pm/os-verify-lock.sh. CI on 92d273fe8 at report time: 30 checks — 16 success, 9 skipped, 5 in_progress, 0 red.",
      "mcp_calls": "2 — both search_issues (one known-hit control word, one dedupe query for the finding below). Everything else went through the zero-quota issue-page JSON payload, git, and repo-scoped REST (probe: GET /repos/objectstack-ai/objectstack returned 200, so REST was usable this seat; GET /search/issues is blocked for this session, which is why the dedupe took the MCP ladder rung).",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #13983: `scripts/import-prerequisite.mjs` — the shared PREREQUISITE NOT MET frame that 20-plus root gates inherit — exits 1, deliberately and with its reasoning stated in the source, while three sibling gates (check-test-completeness, check-dual-build-cjs-loads, and now check-type-check-coverage) answer the same words with 3. Not patched: the current behaviour is an argued decision, not an oversight, and picking the winning number is triage's call. Filed unassigned, no labels. Deduped with a known-hit control word first."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

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

Metadata

Metadata

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions