Skip to content

fix(tooling): make check:type-source-resolution per-program, not per-package - #11922

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-11490-type-source-resolution-sibling-configs
Aug 25, 2026
Merged

os-steve merged 1 commit into
mainfrom
claude/issue-11490-type-source-resolution-sibling-configs

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #11490

scripts/check-type-source-resolution.mjs built every package's tsc program from join(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 in check-type-check-coverage.mjs's TESTS_COVERED docs — is precisely 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.

The population is now every tsconfig*.json a package's typecheck script NAMES.

The card's own measurement, re-derived on current main before the change

Both legs on 59972075e, on packages/triggers/trigger-record-change — 7 test files carrying the same four dist-resolved type imports:

the same files, put through pre-change gate
the build config (drop "**/*.test.ts" from its exclude) exit 1 — ✗ @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.
the prescribed sibling config (the tree as it stands) exit 0 — check-type-source-resolution OK — 77 packages with a tsconfig.json scanned; 51 registered

The asymmetry had not closed. The mutation was proven on disk in both directions (exclusion-anchor occurrence count 1 → 0 → restored, git diff --stat non-empty, git status clean afterwards) and restored under trap 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:

  • (17) packages/sibling-config — build config excludes the test layer, sibling tsconfig.test.json named by typecheck, exposure reachable only through the sibling. Must be reported.
  • (18) packages/build-config-twin — byte-identical files and import, reached through the build config. Must be reported.
  • the assertion is not "both are reported" but that measured.get('@fx/sibling-config') equals measured.get('@fx/build-config-twin'), and that both equal exactly ['@fx/spec'].
  • (19) 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 in packages/triggers/trigger-record-change/src/record-change-trigger.test.ts — a file the build config excludes and the sibling includes:

PRE-CHANGE gate on the MUTATED tree
  PRE_EXIT=0
  check-type-source-resolution OK — 77 packages with a tsconfig.json scanned; 51 registered ...

WIDENED gate on the SAME MUTATED tree
  POST_EXIT=1
  check-type-source-resolution FAILED
  ✗ @objectstack/trigger-record-change: NEW dist-resolved type import(s) since this entry
    was measured: @objectstack/metadata-core (via tsconfig.test.json).

Mutation proven on disk both directions (marker count 0 → 1 → 0, git status --porcelain empty after restore), restored under a trap, and both gates re-run green on the restored tree.

Why NAMED, not every tsconfig*.json on disk

The criterion is "the typecheck script 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

⚠️ This is a population widening = gate strengthening. Nothing about the repo changed and no new exposure was admitted — the same imports were always there, in tsc programs CI has been running on every PR, and the instrument could not see them. Both figures from --list on the same checkout:

programs packages registry entries package-dep pairs clean
before 77 77 51 221 26
after 93 77 54 233 23

so +16 programs, +3 entries, +12 pairs. What the widening turned up, all of it previously invisible:

  • 3 new entries, each reached only through tsconfig.scripts.json: @objectstack/plugin-webhooks, @objectstack/service-messaging, @objectstack/service-realtime — all ['@objectstack/spec'].
  • @objectstack/client +4 via tsconfig.test.json: driver-sqlite-wasm, objectql, plugin-hono-server, runtime.
  • @objectstack/trigger-record-change +4 via tsconfig.test.json: driver-sql, formula, objectql, service-automation — the card's own four.
  • @objectstack/plugin-auth +1 via tsconfig.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

configsNamedByTypecheck was local to check-type-check-coverage.mjs. It is now scripts/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:

  • both extracted function bodies are byte-identical to the originals (diff of the awk-extracted spans against 59972075e: 6 lines and 13 lines, no output);
  • check-type-check-coverage.mjs's structural pass prints byte-identical output at 59972075e and 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, dead paths targets) are reported as packages/x/tsconfig.test.json: ... rather than against the package.

Census guard

The widening can go quiet — configsNamedByTypecheck returning 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 with node 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.

gate exit its own verdict line
check:type-source-resolution 0 check-type-source-resolution --self-test OK / OK — 93 tsc program(s) across 77 packages scanned ...; 54 registered
check:type-check-coverage 0 OK — 65/78 workspace packages type-checked (plus the root), 13 in the DEBT ledger ..., 1 exempt
check:entry-guard 0 155 scripts/ file(s) — every entry guard goes through invoked-as.mjs
check:parse-guard 0 154 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs
check:agent-test-spelling 0 0 violations — 365 file(s) ...
check:pnpm-filter-targets 0 135/168 --filter occurrence(s) across 26 file(s) resolve against 78 workspace package(s)
check:cross-package-test-inputs 0 OK: 16 package(s) read outside themselves, all declared
check-ci-filter-parity.mjs 0 OK: all 96 declared cross-package glob(s) (81 unique) are covered ...
check-cross-package-test-inputs.mjs 0 OK: 16 package(s) read outside themselves, all declared
check:nul-bytes 0 OK (scanned 6639 text file(s) ...; no raw ASCII control bytes)
typecheck-configs.mjs --self-test 0 typecheck-configs --self-test OK -- 11 cases hold.

Declared narrowing, one item. check:type-check-debt is --self-test && --re-measure. Its --self-test leg is the same one run green above under check:type-check-coverage. Its --re-measure leg 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 full turbo run build of the workspace first, which is CI's run (lint.yml builds 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-config over 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#isPathIgnored is false for all three and calculateConfigForFile reports the 2 rules that govern scripts/*.mjs; (2) the file count comes from the JSON payload; (3) eslint.config.mjs never 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 planted comment-swallow/no-code-inside-block-comment violation in the new module drove the same command to exit 1 naming that rule, node --check confirmed 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-changeset applied.

Generated by Claude Code


Generated by Claude Code

…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
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

ACCEPT. Flipping out of draft (scripts/** is not governed); arming once green.

The Zone 2 trap I flagged is the one you actually avoided

My dispatch warned: "Do not assume every tsconfig*.json in a package belongs in the program set. The criterion is 'named by the typecheck script', not 'matches the glob' — getting this wrong produces a gate that looks stronger and measures less."

Verified on origin/main, and this is the decisive check:

packages/cli/tsconfig.build.json   ← on disk
packages/cli/tsconfig.json
packages/cli  "typecheck": "tsc --noEmit"   ← names NO config file

tsconfig.build.json exists and a glob would have swept it in. Your criterion leaves it out because nothing runs it. A gate that had swallowed it would have reported a larger population and measured a program no one type-checks — stronger-looking, weaker.

Verified against the branch

claim check result
re-baseline stated visibly the registry docblock ✅ :192 — "after 93 programs / 77 packages, 54 entries, 233 package-dep pairs"
scope git diff --stat ✅ 3 files; the coverage gate shrinks ~92 lines as the predicate moves out
shared extraction new scripts/typecheck-configs.mjs ✅ 198 lines, with its own --self-test

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 configsNamedByTypecheck into a shared module rather than copying it is the right call, and proving the extraction inert by showing the coverage gate's structural output byte-identical at 59972075e and at HEAD is the correct instrument — a refactor that changes a second gate's verdict would be a silent regression in a file this PR only meant to read from.

The acceptance test was two-sided, as the card required

The 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 (77 packages … 51 registered) and widened gate exit 1 naming @objectstack/metadata-core (via tsconfig.test.json). Planting the import in a file the build config excludes and the sibling includes is what makes it a discriminating test rather than a repetition.

Diagnostics now naming the program — @objectstack/spec (via tsconfig.scripts.json) — matters more than it looks: with 93 programs across 77 packages, a finding that names only the package leaves the reader to guess which of its programs saw it.

⭐ 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 check:type-check-debt's --re-measure declared-not-run (it refuses without the built closure) rather than quietly skipped — both correct.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review August 25, 2026 01:13
@os-steve
os-steve added this pull request to the merge queue Aug 25, 2026
Merged via the queue into main with commit c97edee Aug 25, 2026
32 checks passed
@os-steve
os-steve deleted the claude/issue-11490-type-source-resolution-sibling-configs branch August 25, 2026 01:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants