Skip to content

check:type-check-debt --re-measure trusts stale dist/: phantom upward drift for ledgered packages whose deps resolve to build artifacts, unless the caller builds the closure first #8271

Description

@hotlong

Split out of #8235 (closed: its "red on main" premise measured false) per the services seat's referral (comment 5274624869 on #6015). The measurement establishing the mechanism is the services seat's stale-premise check, comment 5274598357 on #8235 — read it before re-deriving anything here.

The defect

KNOWN_UNALIASED_TEST_IMPORTS packages (e.g. @objectstack/service-analytics, listed at scripts/check-test-source-alias.mjs:279) resolve workspace deps through exports → dist/ — no vitest.config.ts, no paths in tsconfig.json.

  • In CI (lint.yml), step 31 "Build the ledgered packages' dependencies" runs immediately before step 32's re-measure, so tsc always sees a dist/ matching current source.
  • Locally, pnpm check:type-check-debt --re-measure performs no closure build. Against a dist/ predating a sibling package's change, it reports upward ledger drift that does not exist in source.

Measured consequence: #8235 was filed in good faith as a priority:p0 "main-red stanch" off exactly this — four phantom errors (TS2614/TS2339/TS7006 against pre-#8198 @objectstack/driver-sql artifacts) while main CI's step 32 was green on two consecutive runs.

Why it matters

The gate lies in the direction that manufactures false alarms: a careful reporter following the gate's own output escalates a queue-jumping p0 against a green gate. The mirror direction also holds — a stale dist/ can hide real drift locally until CI catches it, which defeats the point of running the gate before pushing.

Suggested directions (not prescriptive)

  1. Make --re-measure build the ledgered packages' dep closure first (what CI step 31 does), or
  2. Staleness detection: warn/refuse loudly when a resolved dep's dist/ is older than its src/, naming the un-built packages, or
  3. Resolve through source — heavier; that is the Type-axis remediation worklist: 51 packages resolve a workspace dep's declarations through dist, per check:type-source-resolution's landing registry #8249 worklist's axis, not this card's.

Acceptance shape

A local --re-measure against a deliberately stale dist/ must either build the closure or name the staleness — it must not report phantom ledger drift. A regression fixture can stage this deterministically (touch a src/ file in a dep of any ledgered package, skip the build, run the gate).

Related (checked for duplicates before filing)

Activity

  1. added theissue type on Aug 13, 2026
  2. self-assigned this
    on Aug 13, 2026
  3. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 1
    Session: session_01KNfj35pXRxnF1D3gZN5zjc
    Branch: claude/issue-8271-remeasure-stale-dist
    Worktree: objectstack-issue-8271
    Domain: domain:devx
    File surface: scripts/check-type-check-coverage.mjs, .github/workflows/lint.yml (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus
    Serial constraints cleared: predecessor PR #8260 (#8218) on scripts/check-type-check-coverage.mjs merged — the temp tsconfig now goes through mkdtempSync, so any line reference predating that is stale. #8189 targets the same file and is deliberately held out of this batch (hard serial, next round at the earliest). No other in-flight claim declares this file.

    ⚠️ Landing-spot correction recorded at claim time. The card is titled against check:type-check-debt, but that npm script is implemented by scripts/check-type-check-coverage.mjs — package.json:88 wires check:type-check-debt to node scripts/check-type-check-coverage.mjs --self-test && … --re-measure. There is no check-type-check-debt.mjs; searching for that spelling returns zero and the zero means nothing. Anyone picking this card up should start from the coverage file.

    Verified at claim time on origin/main: .github/workflows/lint.yml:1329 still carries the "Build the ledgered packages' dependencies" step immediately before :1333's pnpm check:type-check-debt, which is the CI-side counterpart the card contrasts against the local path. The card's premise is intact.


    Generated by Claude Code

  4. added a commit that references this issue on Aug 13, 2026
    e7c35e7
  5. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor
    {
      "issue": 8271,
      "status": "done",
      "branch": "claude/issue-8271-remeasure-stale-dist",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8330",
      "premise_still_valid": true,
      "summary": "Premise verified and reproduced, then fixed. The gate already had a BUILT CLOSURE precondition (unbuiltClosure, from #6376), so the hole is narrower than the card states: `built` is a bare existsSync, so a stale-but-present dist/*.d.ts sails through while a missing one refuses. --re-measure now REFRESHES the closure (refreshBuiltClosure runs the same turbo command and filters lint.yml runs immediately before this gate) instead of merely asserting it; the nothing-is-built case keeps its existing refusal, and an mtime freshness read stays on as a backstop AFTER the build, naming anything those filters do not reach. I took direction 1 over the PM's lean toward direction 2, on measurement: a current-closure build is 9.5s (70/70 turbo cache hits) against a ~250s re-measure, while an mtime read flagged 3 packages in the first worktree it saw and all 3 were false alarms (2 dated by a *.test.ts no dist is generated from, 1 by a git checkout that rewrote a file to byte-identical content). An error rate fine for a backstop is disqualifying for a refusal, because refusing costs a 4-minute lap and the rebuild it would demand costs 9.5 seconds. The cold case stays a refusal because ONE ledgered package's 7-task closure built cold measured 3m2s. Only scripts/check-type-check-coverage.mjs changed; lint.yml untouched.",
      "tests": "REPRODUCTION (deterministic, per the card's recipe): dropped one export from driver-sql/src/index.ts, built that package alone, restored the source from HEAD -- git hash-object identical (99d71b72), git status clean -- leaving dist/ at the pre-change shape with current source, i.e. the #8235 world. UNFIXED gate against it: RED, '@objectstack/service-analytics: DEBT records 10 raw tsc error(s), `tsc --noEmit` now reports 11 (+1)' with the tree byte-identical to HEAD. FIXED gate, same fixture: 'OK -- 33 ledger entr(ies) re-measured in 244.0s, 1969 raw tsc error(s) total' + 'surplus: none'. CONTROL (clean built tree, before any change): 'OK -- 33 ledger entr(ies) re-measured in 256.9s, 1969 raw tsc error(s) total' + 'surplus: none'. Per #8218's lesson, equal item by item and not merely in total: 'surplus: none' means every one of the 33 entries sat exactly at its recorded number, in both runs. The gate was also observed to have repaired the staged artifact itself (dist regained the export; staleness probe went 3 -> 0). WIRED npm script end to end, exactly what CI step 32 runs: `pnpm check:type-check-debt` -> self-test + 'OK -- 33 ledger entr(ies) re-measured in 235.6s, 1969 raw tsc error(s)' (third consecutive identical reading). SELF-TEST: 28 built-closure cases, up from 15, paired the way the file's existing table is (same graph quiet, then one flag flipped). MUTATION TESTING (the self-test passed first try, so it was mutated rather than trusted): 7 mutations, first pass caught 6. The survivor was my own case labelled 'a package that declares no type entry point is never stale' -- it described the node as built:false, so the built guard alone carried it and the type-entry guard it named was never exercised; fixed, re-run, 7/7 caught. A second weakness found the same way: the source-file cases re-spelled the predicate inline instead of calling it, so they would have stayed green through a walk that had stopped excluding test files -- extracted as isBuildSource() and called. GATES: check:type-check-debt, check:type-check-coverage, check:type-source-resolution ('OK -- 76 packages scanned; 51 registered'), check:nul-bytes ('OK, scanned 7531 text file(s)') all green; `node scripts/pm/dispatch-gates.mjs scripts/check-type-check-coverage.mjs` returns exactly the three families the dispatch named -- no delta. PM ASSUMPTIONS: (1) that lint.yml's step 31 is exactly what the local path lacks -- VERIFIED, CI's green-ness does come from that step and not from something else about the environment; the gate now runs the same command itself. (2) that the two directions are genuine alternatives -- FALSIFIED as stated: they are better as a hybrid split by state (refuse when cold, refresh when warm), and the cost measurement points the opposite way from the PM's lean. (3) that a deterministic fixture is achievable -- CONFIRMED, and the gate's own temp-file handling does not interfere (#8260 moved it to mkdtempSync outside the repo, so nothing it writes lands in a package directory).",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #8331: lint.yml's 'Build the ledgered packages' dependencies' step duplicates the closure refresh once #8330 lands (observation-class, `finding` label, no pm:queue; Blocked-by #8330; measured cost of leaving it is 9.5s of cache hits, so the argument for acting is drift between two copies of one precondition, not the seconds)",
        "not filed, deliberately: #8189's stale worked examples in this same file were left untouched as instructed -- confirmed by diff-range check that no hunk of mine reaches old lines ~499-501 or ~1537"
      ]
    }

    Generated by Claude Code

  6. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    ACCEPT — PR #8330 (round 1, domain:devx seat)

    Reviewed against the tree and GitHub, not against the report's self-description.

    The two rulings I set, checked directly

    ruling verification
    ⛔ no ledger re-baselining — the fix must not make numbers agree by moving numbers The diff's only DEBT/TEST_DEBT line is a new error-message string. Spot-checked a known entry: errors: 348 appears once on origin/main and once on the branch, unchanged. No ledger number moved.
    ⛔ no ride-along on #8189 (same file, queued behind) Its two stale worked examples survive verbatim — shifted only by insertions above them, :501→:507 and :1537→:1705. A +/- scan for global-setup across the whole diff returns nothing. The serial constraint held.

    Scope is 1 file, scripts/check-type-check-coverage.mjs, matching the declared file surface; lint.yml untouched. New identifier refreshBuiltClosure really present (3 occurrences). CI: 23 check runs, zero failures, TypeScript Type Check and ESLint still in_progress at review time and confirmed before landing.

    ⚠️ The dev falsified a PM assumption, and it was right — recording that against my own dispatch

    My dispatch order stated as a mechanism assumption that "the two directions the card offers are genuinely alternatives", and leaned toward direction 2 (detect-and-refuse). Both halves were wrong, and the measurements say so rather than the argument:

    • They are not clean alternatives — the shipped fix is a hybrid split by state: refuse when the closure is cold, refresh when it is warm.
    • The cost reading points the opposite way from my lean: a current-closure build is 9.5 s (70/70 turbo cache hits) against a ~250 s re-measure — under 4% to remove the variable entirely.
    • Decisively, an mtime read — the instrument direction 2 would have to refuse on — flagged 3 packages in the first worktree it saw, and all 3 were false alarms: two dated by a *.test.ts that no dist/ is generated from, one by a git checkout that rewrote a file to byte-identical content. An error rate that is fine for a backstop is disqualifying for a refusal, because refusing costs a ~4-minute lap while the rebuild it demands costs 9.5 seconds.

    The cold case staying a refusal is measured too: one ledgered package's 7-task closure built cold is 3 m 2 s, and a gate that vanishes for minutes is a gate nobody runs before pushing.

    It also narrowed the card's own framing

    The card reads as though --re-measure had no closure precondition at all. It does — unbuiltClosure, from #6376. The real hole is narrower and sharper: built is a bare existsSync, so a stale-but-present dist/*.d.ts sails through while a missing one refuses. Presence is not currency. Worth carrying into #8249's worklist, which sits on the resolution side of the same axis.

    Evidence I weighted most

    • The defect was reproduced deterministically before being fixed — source restored to byte-identical (git hash-object match, clean git status) with dist/ left at the pre-change shape. Unfixed gate: red, +1 phantom error on a tree identical to HEAD. Fixed gate, same fixture: green.
    • Per check:type-check-debt writes tsconfig.debt-remeasure.json into tracked package dirs and it is not gitignored — a concurrent git add -A commits it #8218's lesson, equality is item-by-item, not just in total: surplus: none in both the fixture run and the clean control means every one of the 33 entries sat exactly at its recorded number — 1969 raw errors in both, and a third consecutive identical reading through the wired npm script.
    • Mutation testing found a real hole in the dev's own self-test. The survivor was its case labelled "a package that declares no type entry point is never stale" — the fixture described the node as built: false, so the built guard alone carried it and the type-entry guard the case was named after was never exercised. A second weakness surfaced the same way: source-file cases re-spelled the predicate inline instead of calling it, so they would have stayed green through a walk that had stopped excluding test files.

    ⚠️ That is the second empty assertion this round found only by mutation (the sibling gate card had one too). Two independent instances in one round is a strong argument for keeping mutation testing mandatory on this family — a self-test that passes first try is a signal to prove it is not empty, not good news.

    Follow-up filed

    #8331 — once this lands, lint.yml's "Build the ledgered packages' dependencies" step duplicates the refresh. Correctly filed observation-class with Blocked-by: #8330 and honest that the cost of leaving it is 9.5 s of cache hits; the real argument is that a precondition owned in two places drifts, which is the exact defect class this card was.

    Landing: path surface is a repo gate script with no ADR or skills prefix and no bearing on AI merge authority ⇒ normal path. Ready + auto-merge once the two remaining jobs report.


    Generated by Claude Code

  7. added a commit that references this issue on Aug 17, 2026
    fb3896c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions