Repository navigation
fix(tooling): make check:type-source-resolution per-program, not per-package - #11922
Conversation
…package
The gate built each package's program from `join(pkg.dir, 'tsconfig.json')`
and nothing else, so every other tsc program in the package was outside its
declared population. That is not an exotic case here: the repair this repo
PRESCRIBES for a hidden test layer -- AGENTS.md and check-type-check-coverage's
TESTS_COVERED docs -- is a SIBLING `tsconfig.test.json` named in the `typecheck`
script, and CI runs it on every PR. Following the house pattern was therefore
what made an exposure invisible.
Measured on packages/triggers/trigger-record-change before the change, the
same 7 test files with the same four dist-resolved type imports:
through the BUILD config exit 1, "NEW dist-resolved type import(s):
driver-sql, formula, objectql, service-automation"
through the SIBLING config exit 0, count unchanged
The population is now every `tsconfig*.json` a package's `typecheck` script
NAMES -- not every one on disk. `packages/cli/tsconfig.build.json` is present
and named by nothing, and stays out: a gate that measures a program nobody
runs looks stronger while reading as covered.
The predicate is `configsNamedByTypecheck`, extracted from
check-type-check-coverage.mjs into scripts/typecheck-configs.mjs so both gates
share one answer; its cases moved with it and both gates fold them in.
Registry re-baseline, both figures from `--list` on the same checkout:
before 77 programs / 77 packages, 51 entries, 221 package-dep pairs
after 93 programs / 77 packages, 54 entries, 233 package-dep pairs
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
|
ACCEPT. Flipping out of draft ( The Zone 2 trap I flagged is the one you actually avoidedMy dispatch warned: "Do not assume every Verified on
Verified against the branch
Putting the re-baseline in the registry's own docblock as well as the PR body is better than what I asked for: a PR body scrolls out of reach, a docblock is where the next person re-deriving the number will actually be standing. Extracting The acceptance test was two-sided, as the card requiredThe card's own test is that the sibling-config spelling must report identically to the build-config spelling. You ran it that way: same mutated tree, pre-change gate exit 0 ( Diagnostics now naming the program — ⭐ The new census leg that fails when NO package runs more than one program, with its own positive control. That is the vacuity guard this widening needed: without it, a future change that silently collapsed the config discovery back to one-per-package would leave the gate green over the old population and nothing would say so. You built the thing that notices your own work being undone. The 12 newly visible pairs itemised rather than absorbed, and Generated by Claude Code |
Fixes #11490
scripts/check-type-source-resolution.mjsbuilt every package's tsc program fromjoin(pkg.dir, 'tsconfig.json')and nothing else, so every other tsc program in the package sat outside its declared population. That is not an exotic case here: the repair this repo prescribes for a hidden test layer — stated in AGENTS.md and incheck-type-check-coverage.mjs's TESTS_COVERED docs — is precisely a siblingtsconfig.test.jsonnamed in thetypecheckscript, and CI runs it on every PR. Following the house pattern was therefore what made an exposure invisible.The population is now every
tsconfig*.jsona package'stypecheckscript NAMES.The card's own measurement, re-derived on current
mainbefore the changeBoth legs on
59972075e, onpackages/triggers/trigger-record-change— 7 test files carrying the same four dist-resolved type imports:"**/*.test.ts"from itsexclude)✗ @objectstack/trigger-record-change: NEW dist-resolved type import(s) since this entry was measured: @objectstack/driver-sql, @objectstack/formula, @objectstack/objectql, @objectstack/service-automation.check-type-source-resolution OK — 77 packages with a tsconfig.json scanned; 51 registeredThe asymmetry had not closed. The mutation was proven on disk in both directions (exclusion-anchor occurrence count 1 → 0 → restored,
git diff --statnon-empty,git statusclean afterwards) and restored undertrap restore EXIT INT TERM.The acceptance test, two-sided
After the change the sibling-config spelling reports identically to the build-config spelling. Both sides are pinned as self-test fixtures rather than left to a one-off run:
packages/sibling-config— build config excludes the test layer, siblingtsconfig.test.jsonnamed bytypecheck, exposure reachable only through the sibling. Must be reported.packages/build-config-twin— byte-identical files and import, reached through the build config. Must be reported.measured.get('@fx/sibling-config')equalsmeasured.get('@fx/build-config-twin'), and that both equal exactly['@fx/spec'].packages/decorative-config— identical to (17) except that no script names the sibling. Must not be reported.Non-vacuity: an ablation on the real tree, both gates on one mutated tree
A dist-resolved import (
@objectstack/metadata-core) planted inpackages/triggers/trigger-record-change/src/record-change-trigger.test.ts— a file the build config excludes and the sibling includes:Mutation proven on disk both directions (marker count 0 → 1 → 0,
git status --porcelainempty after restore), restored under atrap, and both gates re-run green on the restored tree.Why NAMED, not every
tsconfig*.jsonon diskThe criterion is "the
typecheckscript names it", never "the glob matches it". A config no script invokes is decorative: putting it in the population reports imports no tsc program ever resolves, and a gate that looks stronger while measuring something nobody runs is worse than the state it replaced, because it reads as covered.Measured across the workspace at the widening: 16 configs across 15 packages join the population, and
packages/cli/tsconfig.build.json— present on disk, named by no script — correctly stays out. 0 package manifests in this workspace reference a tsconfig with a directory prefix, so the basename property of the predicate (documented in the new module's header) has no instance today; names are resolved against the package's own directory regardless.The registry re-baseline, with its numbers
--liston the same checkout:so +16 programs, +3 entries, +12 pairs. What the widening turned up, all of it previously invisible:
tsconfig.scripts.json:@objectstack/plugin-webhooks,@objectstack/service-messaging,@objectstack/service-realtime— all['@objectstack/spec'].@objectstack/client+4 viatsconfig.test.json:driver-sqlite-wasm,objectql,plugin-hono-server,runtime.@objectstack/trigger-record-change+4 viatsconfig.test.json:driver-sql,formula,objectql,service-automation— the card's own four.@objectstack/plugin-auth+1 viatsconfig.examples.json:plugin-hono-server.Nothing was baselined away and no gate was weakened. The registry's docblock now carries the before/after and an explicit refusal: the precedent is available only when the SET OF PROGRAMS changed, which is a change to the gate file, never to a package.
The shared predicate
configsNamedByTypecheckwas local tocheck-type-check-coverage.mjs. It is nowscripts/typecheck-configs.mjs, imported by both gates — a second copy of that regex is how the two answers drift, and the symptom of drift is a green gate on either side. Its cases moved with it and both gates fold them in.The extraction is provably behaviour-preserving, not merely believed to be:
diffof theawk-extracted spans against59972075e: 6 lines and 13 lines, no output);check-type-check-coverage.mjs's structural pass prints byte-identical output at59972075eand at this branch's HEAD on the same tree.Diagnostics
A widened diagnostic that only names the package is one an author cannot act on — they open
tsconfig.json, find no such import, and conclude the gate is wrong. Every finding now names the program:@objectstack/spec (via tsconfig.scripts.json), and config-level defects (unreadable configs,@pkg*star traps, deadpathstargets) are reported aspackages/x/tsconfig.test.json: ...rather than against the package.Census guard
The widening can go quiet —
configsNamedByTypecheckreturning an empty set for every package reverts this gate to exactly its old per-package behaviour, every remaining program still scans, and the summary reads like a clean repo. A new census leg fails when no package runs more than one tsc program, and it carries its own positive control: a purpose-built single-program tree is asserted to trip it, and the real fixture tree is asserted not to.Verification
All at HEAD
e3abf9a1d. Gate families derived live withnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(never a hand-written list); each verdict is the gate's own printed line, exit codes captured before any pipe.check:type-source-resolutioncheck-type-source-resolution --self-test OK/OK — 93 tsc program(s) across 77 packages scanned ...; 54 registeredcheck:type-check-coverageOK — 65/78 workspace packages type-checked (plus the root), 13 in the DEBT ledger ..., 1 exemptcheck:entry-guard155 scripts/ file(s) — every entry guard goes through invoked-as.mjscheck:parse-guard154 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjscheck:agent-test-spelling0 violations — 365 file(s) ...check:pnpm-filter-targets135/168 --filter occurrence(s) across 26 file(s) resolve against 78 workspace package(s)check:cross-package-test-inputsOK: 16 package(s) read outside themselves, all declaredcheck-ci-filter-parity.mjsOK: all 96 declared cross-package glob(s) (81 unique) are covered ...check-cross-package-test-inputs.mjsOK: 16 package(s) read outside themselves, all declaredcheck:nul-bytesOK (scanned 6639 text file(s) ...; no raw ASCII control bytes)typecheck-configs.mjs --self-testtypecheck-configs --self-test OK -- 11 cases hold.Declared narrowing, one item.
check:type-check-debtis--self-test && --re-measure. Its--self-testleg is the same one run green above undercheck:type-check-coverage. Its--re-measureleg refused to run and measured nothing —Error: --re-measure cannot run: 56 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk— it needs a fullturbo run buildof the workspace first, which is CI's run (lint.ymlbuilds the closure before this step). Left to CI, and named here rather than passed over. The by-construction argument that it cannot move: the predicate bodies are byte-identical and the structural pass output is byte-identical.Lint, narrowed, and the narrowing measured.
eslint --no-inline-configover the 3 changed files: 3 files, 0 errors, 0 warnings (counts read from--format json, not from a summary line). Three pieces of evidence that this is a measurement rather than a silence: (1) the population is read from eslint's own config —ESLint#isPathIgnoredisfalsefor all three andcalculateConfigForFilereports the 2 rules that governscripts/*.mjs; (2) the file count comes from the JSON payload; (3)eslint.config.mjsnever enables type-aware linting for any file (its own header states and measures this), so no untouched file's verdict can move on account of this diff. Positive control: a plantedcomment-swallow/no-code-inside-block-commentviolation in the new module drove the same command to exit 1 naming that rule,node --checkconfirmed the mutant still parsed (an assertion-red, not a crash-red), and the restored file returned to exit 0.No changeset
Dev tooling only — three files under
scripts/, nothing published and nothing user-visible.skip-changesetapplied.Generated by Claude Code
Generated by Claude Code