From b233064dda78b584d45f8b4ee334cdd14842ed2c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 06:36:52 +0000 Subject: [PATCH 1/2] wip: retire check-type-source-resolution (script, script entry, lint step, ratchet-remedy coupling) Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- .github/workflows/lint.yml | 26 - package.json | 1 - scripts/check-ratchet-remedy-authority.mjs | 35 +- scripts/check-type-source-resolution.mjs | 2671 -------------------- 4 files changed, 17 insertions(+), 2716 deletions(-) delete mode 100644 scripts/check-type-source-resolution.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ee6ca90e2a2..5baaf290ea8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4160,32 +4160,6 @@ jobs: - name: Ref'd-timer probe containment run: pnpm check:refd-timer-probe - # The TYPE axis of the same invariant (#8180). The gate above reads - # `vitest.config.*` and nothing else, so the identical exposure on the - # type axis was unguarded repo-wide — and its symptom is likewise a GREEN - # `typecheck`. Measured on `packages/qa/downstream-contract` (#8021): - # identical checkout, identical stale `dist`, a narrowing injected into - # `packages/spec/src` in SOURCE only — without a `paths` block - # `tsc --noEmit` exited 0 and clean; with one it reported the `TS2322` the - # frozen fixture should always have produced. The comment beside the - # type-check-debt step below documents the same mechanism as expected - # state ("tsc resolves workspace imports through each dependency's built - # `dist/*.d.ts`"), and turbo's `typecheck` dependsOn `^build` does not - # reach it for the same reason it does not reach the runtime axis. - # NOT a port of the gate above: tsc's `paths` algorithm is exact-key-wins - # then longest-prefix (Vite's is first-match-wins), type-only imports - # COUNT here, and `tsconfig.json` is JSONC that inherits through - # `extends`. The trap is also different and worse — `@objectstack/spec*`, - # star not after a separator, folds every namespace onto one target and - # type-checks GREEN against the wrong module instead of crashing. - # The registry is measured (51 of 76 packages on the day it landed), - # shrink-only and audited in BOTH directions. It finds no new offender - # today: it is a ratchet, so the remediation list is finite and cannot - # grow behind anyone's back. Static scan of sources and tsconfigs; no - # build needed (~1.2s), so it belongs in this job. - - name: Type-source resolution gate - run: pnpm check:type-source-resolution - # #4248 packaging-hygiene guard. Without a `files` whitelist npm packs the # whole package directory, and 20 of the 49 publishable packages declared # none — so consumers installed TypeScript sources, unit tests and build diff --git a/package.json b/package.json index a50b92c644d..1e2955dc715 100644 --- a/package.json +++ b/package.json @@ -169,7 +169,6 @@ "check:rest-log-declared": "node scripts/check-rest-log-declared.mjs --self-test && node scripts/check-rest-log-declared.mjs", "check:rest-log-spy-declared": "node scripts/check-rest-log-spy-declared.mjs --self-test && node scripts/check-rest-log-spy-declared.mjs", "check:refd-timer-probe": "node scripts/check-refd-timer-probe.mjs --self-test && node scripts/check-refd-timer-probe.mjs", - "check:type-source-resolution": "node scripts/check-type-source-resolution.mjs --self-test && node scripts/check-type-source-resolution.mjs", "check:undeclared-dep-imports": "node scripts/check-undeclared-dep-imports.mjs --self-test && node scripts/check-undeclared-dep-imports.mjs", "check:keyed-text-bounds": "node scripts/check-keyed-text-bounds.mjs --self-test && node scripts/check-keyed-text-bounds.mjs", "check:published-files": "node scripts/check-published-files.mjs --self-test && node scripts/check-published-files.mjs", diff --git a/scripts/check-ratchet-remedy-authority.mjs b/scripts/check-ratchet-remedy-authority.mjs index b4b92a41048..3c9e282661b 100644 --- a/scripts/check-ratchet-remedy-authority.mjs +++ b/scripts/check-ratchet-remedy-authority.mjs @@ -13,8 +13,8 @@ // This gate enforces that DISJUNCTION. (b) is not a lesser form of (a): a gate // that refuses satisfies the convention MORE strongly than one that marks, and // this detector must never push a refusing gate onto the marking shape. The -// precedents are check-type-source-resolution.mjs, check-test-source-alias.mjs, -// check-adr-links.mjs and check-driver-memory-census.mjs. +// precedents are check-test-source-alias.mjs, check-adr-links.mjs and +// check-driver-memory-census.mjs. // // ── The ruled EXCEPTION: an authorised cross-file move (2026-09-03) ───────── // @@ -205,7 +205,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ '(9) Stage 2 reaches a real ratchet, by each limb, so (8) is not vacuous.': 2, '(10) NON-CIRCULARITY. The authority token must never be its own anchor: a': 1, '(11) Refusal, BOUND shape — check-adr-links.mjs / check-driver-memory-census.mjs.': 1, - '(12) Refusal, PREDICATION shape — check-type-source-resolution.mjs / check-test-source-alias.mjs.': 1, + '(12) Refusal, PREDICATION shape — check-test-source-alias.mjs.': 1, '(13) Refusal DISCRIMINATES. A marking gate\'s closing discouragement is not a': 1, '(14) End-to-end: an anchored, unrefused, unmarked offer is a VIOLATION. This': 1, '(15) …and the same text carrying the token classifies as MARKED. Paired with': 1, @@ -897,25 +897,24 @@ const CONTROL = { expect: 'refused', why: 'Refuses by binding a negation to the verb, over a shrink-only registry.', }, - // The two gates below refuse by PREDICATION (the act named as subject and - // denied) — self-test (12) pins that predicate on their exact sentence. + // The gate below refuses by PREDICATION (the act named as subject and + // denied) — self-test (12) pins that predicate on its exact sentence. // - // Until #8576 both were recorded here as `excluded`, and that was the honest - // reading: stage 2 declined them FIRST, because each named its target in a + // Until #8576 it was recorded here as `excluded`, and that was the honest + // reading: stage 2 declined it FIRST, because it named its target in a // message carrying no testimony about the registry's nature — the testimony // sat in a comment, where no author and no detector reads it. #8576 mirrored - // one clause of each gate's own shrink-only comment into that same message, so + // one clause of the gate's own shrink-only comment into that same message, so // the target is now established as a ratchet and the refusal limb is reached. - // Growing the refusal limb's sample from one gate to three was the point: a - // regression in that limb used to be measured against a sample of one. + // That card grew the refusal limb's sample from one gate to three, so a + // regression in that limb is no longer measured against a sample of one. It + // had a second PREDICATION entry, check-type-source-resolution.mjs, until that + // gate was retired under the maintainer ruling of 2026-09-18 on #18373; the + // entry went with the file, which is the remedy the STALE branch below names. 'check-test-source-alias.mjs': { expect: 'refused', why: 'Refuses by predication. Its registry states its own nature in the same message since #8576, so stage 2 reaches it and the refusal limb is consulted.', }, - 'check-type-source-resolution.mjs': { - expect: 'refused', - why: 'The other refusal precedent, refusing by the same predication shape. Its registry states its own nature in the same message since #8576, so stage 2 reaches it rather than declining on a path target.', - }, // The fourth refusal instance, and the second gate to join the convention as a // NEW gate rather than by retrofit (#10619). Refusal — not marking — is the // honest shape here because there is no legitimate act for anyone to own: its @@ -1218,7 +1217,7 @@ function main() { + ` remedy: …${r.live[0].window.slice(0, 140).trim()}…\n` + ` registry: ${r.live[0].target.name} (testimony: ${r.anchors[0]})\n` + ' Fix: say in the same message that this path belongs to a maintainer, or turn it down\n' - + ' outright the way check-type-source-resolution.mjs does. Turning it down is the stronger\n' + + ' outright the way check-test-source-alias.mjs does. Turning it down is the stronger\n' + ' shape and this gate treats it as fully compliant — it is not a lesser option.', ); } @@ -1427,10 +1426,10 @@ function selfTest() { + 'check-adr-links.mjs and check-driver-memory-census.mjs use', offerIsRefused({ context: 'fix the link; do not add it to KNOWN_DEAD_TARGETS to make this green.' })); - // (12) Refusal, PREDICATION shape — check-type-source-resolution.mjs / check-test-source-alias.mjs. - battery('(12) Refusal, PREDICATION shape — check-type-source-resolution.mjs / check-test-source-alias.mjs.'); + // (12) Refusal, PREDICATION shape — check-test-source-alias.mjs. + battery('(12) Refusal, PREDICATION shape — check-test-source-alias.mjs.'); expect('refusal — an act named as subject and denied is a refusal ("widening the registry entry ' - + 'is not the fix"), the shape the two registry gates use', + + 'is not the fix"), the shape check-test-source-alias.mjs uses', offerIsRefused({ context: 'Add the rules to its tsconfig.json — widening the registry entry is not the fix.' })); // (13) Refusal DISCRIMINATES. A marking gate's closing discouragement is not a diff --git a/scripts/check-type-source-resolution.mjs b/scripts/check-type-source-resolution.mjs deleted file mode 100644 index 8c44df5ae08..00000000000 --- a/scripts/check-type-source-resolution.mjs +++ /dev/null @@ -1,2671 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// check-type-source-resolution — a package's TYPES must be a verdict about the -// source in the checkout, never about a sibling package's build artifact. -// -// ── The asymmetry this closes (#7849, #8021, #8180) ───────────────────────── -// -// `check:test-source-alias` made the source-resolution invariant mechanical on -// the RUNTIME axis: it reads every package's `vitest.config.*`, simulates -// Vite's resolution, and demands that anything still landing on `dist/` be -// registered. Its header states why a sweep is the wrong terminal state — "it -// leaves the NEXT package unguarded, and the symptom of the omission is a -// green test, so nothing would report the gap". -// -// That reasoning is axis-independent. The gate was not: it reads -// `vitest.config.*` and nothing else, so the identical exposure on the TYPE -// axis was unguarded repo-wide, and its symptom is likewise a green -// `typecheck`. `.github/workflows/lint.yml` even documents the exposure as the -// expected state, beside the type-check-debt re-measure step — "tsc resolves -// workspace imports through each dependency's built `dist/*.d.ts`". -// -// Measured end to end on `packages/qa/downstream-contract` (#8021). Identical -// checkout, identical stale `dist`, a narrowing injected into -// `packages/spec/src/integration/connector.zod.ts` in SOURCE ONLY, no rebuild: -// -// without a `paths` block `tsc --noEmit` -> exit 0, CLEAN -// with a `paths` block src/additional-domains.fixtures.ts(35,3): error -// TS2322: Type 'string' is not assignable to type -// 'number'. -// -// That package is the repo's backward-compatibility gate, whose README -// promises "a removed or NARROWED export fails here" — so it was the loudest -// instance, not the only one. Ordering does not reach any of them: `turbo.json` -// orders `typecheck` after `^build`, so `turbo run typecheck` and CI were never -// the failing paths. What breaks is every path turbo does not mediate — -// `pnpm --filter PKG typecheck` inside a package, an editor's TS server, an -// agent in a tree built at an older commit. Those are exactly the paths a type -// contract is re-read on WHILE someone is changing the dependency. -// -// ── Why this is NOT a port of the vitest gate ────────────────────────────── -// -// Four things differ, and each one is a way a copied gate would be wrong: -// -// 1. THE RESOLUTION ALGORITHM. Vite's is first-match-wins with prefix -// matching. tsc's is: an EXACT (star-free) key wins outright regardless of -// declaration order; otherwise the pattern key with the LONGEST MATCHING -// PREFIX wins, and the captured text substitutes into the target's star. -// #8020 and #8108 are both defects in how the vitest gate read a CORRECT -// config — a resolution simulator that is wrong reads compliant packages -// as broken, so the algorithm is written here from tsc's rules and pinned -// by `--self-test`, never inherited. -// -// 2. THE TRAP HAS A DIFFERENT SPELLING AND IS WORSE. A `paths` key without a -// `*` is an EXACT match, so a bare `@objectstack/spec` entry cannot -// swallow `@objectstack/spec/ui` the way a Vite object-form alias does -// (that one is `ENOTDIR`, loud, at run time). The tsconfig-shaped mistake -// is `@objectstack/spec*` — star NOT preceded by a slash — which matches -// every namespace AND the bare name and folds them all onto whatever the -// target names. It does not crash: `spec/src/index.ts` re-exports most of -// the namespace surface, so it type-checks against the WRONG MODULE and -// stays green. A gate that does not flag that spelling certifies the exact -// defect it exists to find; `--self-test` fixture (5) is that case. -// -// 3. `tsconfig.json` IS JSONC AND IT INHERITS. 55 of this repo's 76 package -// tsconfigs carry comments and 66 use `extends` — both are the norm here, -// not an edge case, and `vitest.config.*` has neither property. Comments -// are stripped before `JSON.parse` (the same whole-line rule -// `scripts/check-type-check-coverage.mjs` uses), and the `extends` chain -// is resolved before `paths` is read. `compilerOptions` merge SHALLOWLY, -// so a child's `paths` REPLACES the parent's rather than adding to it, and -// relative targets resolve against `baseUrl` if declared and otherwise -// against the directory of the config that ORIGINATED them — which is not -// necessarily the package's own tsconfig. -// -// 4. TYPE-ONLY IMPORTS COUNT HERE. They are the exact inversion of the -// vitest gate's rule 1: `import type { X } from 'y'` is erased before -// anything resolves at run time, so that gate does not count it — and on -// this axis it is precisely what tsc resolves, to `y`'s `.d.ts`. A port -// that kept the type-only filter would go blind on the majority of the -// imports it exists to judge. -// -// ── What this checks ─────────────────────────────────────────────────────── -// -// For every tsc PROGRAM a workspace package runs — its `tsconfig.json` plus -// every `tsconfig*.json` its `typecheck` script NAMES (#11490; see -// `programConfigsFor` for why named and not globbed): -// -// 1. Determine the files tsc puts in the program (`files` / `include` / -// `exclude`, with TS's defaults), and collect the workspace packages they -// import — ALL imports, type-only included (see 4 above). -// 2. Keep only the deps whose TYPES can go stale: the dep's declaration entry -// resolves under `dist/`. A dep whose types already point at source is not -// an artifact and needs no `paths` rule — counting it would be a false -// positive the registry then has to carry forever. -// 3. Resolve each specifier through the effective `paths` using TSC'S -// algorithm (1 above). A specifier whose winning target lands under `src/` -// — and exists on disk — is safe. A target that does NOT exist is not -// safe: tsc falls back to node resolution, i.e. to `dist`, silently. -// 4. Anything left resolves through a build artifact, and the package must be -// registered in `KNOWN_DIST_RESOLVED_TYPE_IMPORTS` below with EXACTLY that -// set — the UNION over the package's programs, since the registry is keyed -// by package and an exposure is an exposure whichever program reaches it. -// Unregistered ⇒ red. -// -// Rules are judged INDIVIDUALLY, never "does a `paths` block exist". Measured -// on #8021: with the subpath rule kept and only the bare-entry rule deleted, -// tsc stayed clean while `src/stack.ts`'s `defineStack` types came from `dist`. -// A `paths` block that covers eight of nine specifiers is eight-ninths of a -// verdict, and the ninth is silent. -// -// ── The registry, and what its SIZE means ────────────────────────────────── -// -// `KNOWN_DIST_RESOLVED_TYPE_IMPORTS` is the measured state of the repo on the -// day this gate landed, and it is LARGE: exactly one package (`downstream- -// contract`, fixed by #8021) declares a `paths` rule for a workspace dep at -// all. So this gate finds no new offender today — it is a RATCHET, and that is -// the whole of its value: the list of remediation cards is now finite, audited -// in both directions, and unable to grow behind anyone's back. That is the same -// trade `check:test-source-alias` made and its header argues for; it is stated -// plainly here so a large registry is not read as a large finding. -// -// ⛔ SHRINK-ONLY, audited in BOTH directions, like `UNRESOLVED_ADR_CITATIONS` -// in `check-adr-anchors.mjs`: an entry that is no longer needed FAILS and names -// itself for deletion, so the registry cannot rot into a grandfather clause. -// Each entry carries the exact set of dist-resolved deps and the audit demands -// set EQUALITY — a bare list of package names would license a listed package to -// acquire ten NEW artifact imports with nothing going red, which is the silent -// regression headroom the type-check DEBT ledger paid 273 raw errors for. -// -// ⛔ Two things this gate deliberately does NOT do: -// - It does not add or edit any package's `tsconfig.json`. Remediation is -// per-package and lands as its own card, because the switch is not free: -// putting a dependency's SOURCE into a consumer's program makes tsc check -// that source under the CONSUMER's `compilerOptions`, which on #8021 -// surfaced 2 `TS2591` (spec source needs `types: ["node"]`) and 247 -// `TS6059` from a `rootDir` that emits nothing under `noEmit` and is still -// enforced. The gate's job is to make the list of cards finite. -// - It does not fail a package for having no `paths` block. A package that -// imports no stale-able workspace dep needs none, and demanding one would -// be cargo cult. The predicate is the import, not the file. -// -// Usage: -// node scripts/check-type-source-resolution.mjs -// node scripts/check-type-source-resolution.mjs --list # registry-shaped -// node scripts/check-type-source-resolution.mjs --self-test - -import { readFileSync, readdirSync, statSync, existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; -import { join, resolve, relative, dirname, sep } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - isExclusionGlob, - readWorkspaceGlobs, - selfTest as workspaceEnumeratorSelfTest, - workspaceEnumeratorFloorFailures, -} from './workspace-enumerator.mjs'; -// The `typecheck`-script -> tsconfig program set, shared with -// `check-type-check-coverage.mjs` (#11490). Imported rather than re-derived: -// two copies of that predicate drift, and the symptom of drift is a green gate -// on either side. -import { configsNamedByTypecheck, selfTest as typecheckConfigsSelfTest } from './typecheck-configs.mjs'; -import { tmpdir } from 'node:os'; -import process from 'node:process'; - -// ── The self-test's own battery roster and floor (#13489) ────────────────── -// -// `failures.length === 0` used to be this self-test's ONLY success condition, so -// "every case held" and "the cases never ran" printed the same line. Closed the -// way PR #13487 validated on check-doc-authoring: what is pinned is the -// registered NAMES, not a number. Every section opens with `battery('')`, -// every assertion is attributed to the battery most recently opened, and the -// floor requires the OPENED set to equal the DECLARED set with each battery at -// or above its own count. -// -// ⛔ A pinned TOTAL is not the repair: a battery dropping from 9 cases to 3 -// keeps a total "right" the moment a sibling grows. -// -// The counts are a FLOOR, not an equality — adding cases is ordinary work and -// must not red. A battery BELOW its floor means cases stopped running; the -// remedy is to find what stopped registering. -const SELF_TEST_BATTERIES = Object.freeze({ - 'the defect is caught': 4, - 'THE TRAP: `@fx/spec*`, star not after a separator': 2, - 'a rule that matches nothing is not coverage': 2, - 'the CORRECT spellings must stay quiet': 6, - 'false positives': 3, - 'fail-closed': 2, - '#11490: the population is per PROGRAM': 6, - 'the registry, audited in BOTH directions': 12, - 'census guard: sibling-config discovery going quiet is INVISIBLE': 14, - 'the import clause is bounded to ONE statement (#12555)': 8, - 'the declaration must still BE the workspace (#11510)': 22, -}); - -// DELETING an entry silences that battery's floor exactly as effectively as -// zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 11; - -// The key an assertion is filed under when no battery is open. It is not a -// declared battery, so it reds by the same set difference rather than silently -// inflating whichever battery happened to run last. -const UNATTRIBUTED_BATTERY = '(no battery open)'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = resolve(HERE, '..'); - -/** - * Packages whose tsc program imports a workspace package whose declarations - * resolve to `dist/`, with no `paths` rule redirecting it to source — i.e. - * packages whose type verdicts are currently a function of build state. - * - * MEASURED, not curated: this is what `--list` printed on the day the gate - * landed. Each value is the exact set of dist-resolved type imports for that - * package. - * - * ⛔ SHRINK-ONLY. Adding an entry, or widening one, is not how a red build gets - * fixed — add the rule to that package's `tsconfig.json` instead (see the - * header for the two rules `downstream-contract` uses and the `@pkg*` spelling - * that must never be used). Entries are audited in both directions, so one that - * is no longer needed fails the gate and names itself for deletion. - * - * ## The one thing that is NOT a violation of that: a re-baseline (#11490) - * - * This registry grew when the gate's POPULATION widened from "each package's - * `tsconfig.json`" to "every `tsconfig*.json` its `typecheck` script names". - * 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. The measured before/after, both 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 - * - * so +16 programs, +3 entries (`plugin-webhooks`, `service-messaging`, - * `service-realtime`, all reached only through `tsconfig.scripts.json`) and - * +12 pairs. The ratchet is shrink-only from the NEW number; the widening is - * the only thing that may ever move it up, and a widening states its numbers - * here or it is indistinguishable from a ratchet quietly reset. - * - * ⛔ Do NOT reach for this precedent to admit an exposure your change created. - * The test is whether the SET OF PROGRAMS changed. - * - * ## Who may move the program set (this paragraph was falsified by #11490) - * - * ⚠️ That test used to end "…and that is a change to this file, not to a - * package". It was true only while the population was each package's single - * `tsconfig.json`: no package COULD move the set. #11490 made the population - * every `tsconfig*.json` a `typecheck` script names — which handed the move to - * the packages. A package whose `typecheck` script begins naming a - * `tsconfig.test.json` has moved the set BY DEFINITION, because that is what - * onboarding a test layer IS. The sentence outlived its invariant, and while it - * stood it forbade the only correct action for that case: #11491 measured 14 of - * the 18 remaining `TEST_DEBT` entries as arriving exactly here the moment - * their tests enter a program. - * - * So the re-baseline limb is open to a package too — on the terms this registry - * already imposes on itself, and no others: - * - * 1. Every dep admitted is reached ONLY through the program the change - * ONBOARDED. A dep newly reached through a program that was ALREADY - * counted is the exposure this ratchet exists to catch, and onboarding - * something else in the same PR does not launder it. The provenance - * annotation (`via `) in `--list` and in the failure text is what - * tells the two apart; if you cannot point at the program, you do not have - * this case. - * 2. The numbers are stated in place — `--list` before and after — the way - * the #11490 re-baseline above states its own. A widening that does not is - * indistinguishable from a ratchet quietly reset. - * 3. It is a RE-BASELINE reviewed as one, not an escape from a red build. The - * failure text goes on refusing the widening deliberately: an author who is - * merely red cannot tell these two cases apart from inside the failure, and - * this doc-block plus a reviewer is where they are told apart. - * - * Landed instances, each stating its program provenance in place: - * `@objectstack/client` and `@objectstack/trigger-record-change` (the #11490 - * re-baseline), and `@objectstack/rest` (#12542 / PR #12570 at `3f41a215`, the - * first package to take the #5286 sibling route for this reason). - * - * ## The #12511 re-baseline — six packages onboarding one program each - * - * Seven packages took the #5286 sibling route in one change; six of them move - * this registry. Every admitted dep is annotated `via tsconfig.test.json` by - * this gate's own provenance output, i.e. reached ONLY through the program the - * change onboarded — condition 1 above, read off the instrument rather than - * asserted. `@objectstack/formula` onboarded a program and admitted NOTHING, - * which is the control that says the other six are reporting a real widening - * rather than an artifact of the population growing. - * - * before 125 programs / 78 packages, 61 entries, 310 package-dep pairs - * after 132 programs / 78 packages, 61 entries, 319 package-dep pairs - * - * so +7 programs, +0 entries (all six already had one) and +9 pairs: `mcp` +2 - * (`lint`, `metadata-core`), `platform-objects` +3 (`core`, `formula`, `lint`), - * `connector-mcp` / `connector-openapi` / `connector-rest` +1 each - * (`service-automation`), `service-sms` +1 (`service-settings`). The ratchet is - * shrink-only from the new number. - * - * ⚠️ TWO OF THE NINE ARE A DISAGREEMENT WORTH NAMING, because a later reader - * will otherwise find it and think it was missed. `packages/mcp` and - * `packages/platform-objects` both alias `@objectstack/lint` (and mcp also - * `@objectstack/metadata-core`) to that package's SOURCE in their vitest - * configs, precisely so the suite runs against the checkout rather than a build - * artifact — while the test program admitted above still resolves those same - * specifiers' TYPES through `dist/`. So for those specifiers the RUN and the - * TYPE VERDICT read different artifacts. `paths` is the obvious repair and is - * measured to be the wrong one here: the refusal directly above is explicit - * that for the ONBOARDING case it billed other packages' source diagnostics - * into the onboarding package's ledger (PR #12570, 37 -> 42). Declared here - * instead, where the shrink-only ratchet keeps it visible. - * - * ⛔ Still NOT open: `paths` remains the fix for a dep exposed through an - * EXISTING program, and no widening may silence one. For the onboarding case - * `paths` is additionally the WRONG tool, measured on PR #12570 rather than - * argued: redirecting those six deps to source took that package's test layer - * from 37 errors to 42, the +5 being `TS6133` in other packages' source billed - * to a ledger those packages cannot see — and a ledger holding another - * package's diagnostics is a ledger nobody can pay down. - * - * ## The #12511 re-baseline, second half — the eighth package, one program - * - * `@objectstack/http-conformance` was the one package of the eight that could - * not take the #5286 sibling route when the other seven did: its - * `tsconfig.json` extended nothing and declared no `skipLibCheck`, so the - * question was a config-policy one and was left to a ruling. The ruling of - * 2026-09-07 (decision batch #66, maintainer verbatim: 「同意」) aligned that - * build config to the repo root, after which the sibling goes in unchanged — so - * this is the same onboarding case as the six above, arriving late rather than - * a new shape. - * - * Condition 1 is read off the instrument, not asserted: all six deps admitted - * here are annotated `via tsconfig.test.json` in this gate's own provenance - * output, i.e. reached ONLY through the program this change onboarded. - * - * before 135 programs / 80 packages, 61 entries, 321 package-dep pairs - * after 136 programs / 80 packages, 61 entries, 327 package-dep pairs - * - * so +1 program, +0 entries (this package was already listed for - * `@objectstack/core`) and +6 pairs: `driver-sqlite-wasm`, `hono`, `objectql`, - * `plugin-hono-server`, `runtime`, `spec`. Both readings were taken on one - * checkout at d1bacbd2e, the BEFORE one under a trap-restored mutation that - * un-NAMES the sibling config in the `typecheck` script and nothing else — the - * gate exits 0 in that state, which is what makes the pair a measurement of the - * program set rather than of two different trees. The ratchet is shrink-only - * from the new number. - * - * ⚠️ ONE OF THE SIX IS THE SAME DISAGREEMENT `mcp` and `platform-objects` - * declare above, and it is named here for the same reason — a later reader will - * otherwise find it and think it was missed. `packages/qa/http-conformance`'s - * `vitest.config.ts` aliases `@objectstack/hono` to that package's SOURCE, on - * purpose and with its own reason written in place (the adapter's rendering IS - * the subject of one of these suites, so a stale `dist/` would leave it green - * against the very rendering it pins), while the test program admitted here - * still resolves that specifier's TYPES through `dist/`. `paths` is the obvious - * repair and is the one the paragraph directly above measures to be wrong for - * an onboarding program. Declared here instead, where the shrink-only ratchet - * keeps it visible. - */ -const KNOWN_DIST_RESOLVED_TYPE_IMPORTS = { - '@objectstack/account': ['@objectstack/platform-objects'], - // #11490 re-baseline. Everything after `@objectstack/core` here arrives from - // `tsconfig.test.json`, a program CI has run on every PR since PR #5546 and - // which this gate could not see until its population became per-PROGRAM. - '@objectstack/client': [ - '@objectstack/core', '@objectstack/driver-sqlite-wasm', '@objectstack/objectql', - '@objectstack/plugin-hono-server', '@objectstack/runtime', '@objectstack/spec', - ], - '@objectstack/client-react': ['@objectstack/client', '@objectstack/spec'], - '@objectstack/connector-mcp': ['@objectstack/core', '@objectstack/service-automation', '@objectstack/spec'], - '@objectstack/connector-openapi': ['@objectstack/core', '@objectstack/service-automation', '@objectstack/spec'], - '@objectstack/connector-rest': ['@objectstack/core', '@objectstack/service-automation', '@objectstack/spec'], - '@objectstack/connector-slack': [ - '@objectstack/core', '@objectstack/service-automation', '@objectstack/spec', - ], - '@objectstack/core': ['@objectstack/metadata-core', '@objectstack/spec'], - '@objectstack/dogfood': [ - '@objectstack/connector-mcp', '@objectstack/connector-openapi', - '@objectstack/connector-rest', '@objectstack/core', '@objectstack/driver-sql', - '@objectstack/driver-sqlite-wasm', '@objectstack/mcp', '@objectstack/metadata', - '@objectstack/metadata-core', '@objectstack/objectql', '@objectstack/platform-objects', - '@objectstack/plugin-audit', '@objectstack/plugin-auth', '@objectstack/plugin-email', - '@objectstack/plugin-security', '@objectstack/plugin-webhooks', '@objectstack/service-analytics', - '@objectstack/service-messaging', '@objectstack/service-storage', '@objectstack/spec', - '@objectstack/types', '@objectstack/verify', - ], - '@objectstack/driver-memory': ['@objectstack/core', '@objectstack/spec', '@objectstack/types'], - // #14917: `packages/drivers/driver-mongodb` had NO tsc program compiling any - // of its 30 test files (the build config's `exclude` named `**/*.test.ts`), - // and its new `tsconfig.test.json` (the #5286 sibling route) is the first one - // that does — the same shape `@objectstack/rest` took below. Exactly ONE dep - // arrives from that program: `@objectstack/objectql`, a devDependency no - // non-test file in `src/` imports (measured: 0 non-test importers, and the - // gate's own provenance annotation reads `via tsconfig.test.json`). - // - // ⚠️ This is a program-set widening and its numbers are stated, per this - // registry's own rule: before, at 061d62e50 with the sibling config removed - // and unnamed, `--list` reported 61 of 78 packages / 123 tsc programs / 309 - // pairs / 17 clean; after, 61 of 78 packages / 124 programs / 310 pairs / 17 - // clean. +1 program, +0 entries (this package was already listed), +1 pair — - // that one pair, in this one package, reached only through the onboarded - // program. - // - // Why the entry and not `paths` rules, which is what this gate's failure text - // asks for: this is the onboarding case the doc-block above rules on, where - // `paths` is measured to be the WRONG tool (PR #12570). Redirecting objectql - // to source here would put its `src` tree in this package's test program and - // bill objectql's own diagnostics to a driver package that cannot pay them - // down. The test layer measures 0 errors as it stands. - '@objectstack/driver-mongodb': [ - '@objectstack/core', '@objectstack/objectql', '@objectstack/spec', '@objectstack/types', - ], - '@objectstack/driver-sql': [ - '@objectstack/core', '@objectstack/formula', '@objectstack/observability', '@objectstack/spec', - '@objectstack/types', - ], - '@objectstack/driver-sqlite-wasm': [ - '@objectstack/core', '@objectstack/driver-sql', '@objectstack/formula', '@objectstack/spec', - ], - // [#13513] `@objectstack/verify` shrank out of this entry when - // `src/date-bucket-parity.test.ts` moved to `packages/qa/dogfood`. That test - // was the driver's only importer of it, and its devDependency was the one - // edge that made this workspace's manifest graph cyclic - // (runtime -peer(optional)-> driver-turso -dev-> verify -dep-> runtime), which - // left every `pnpm --filter 'PKG^...' build` with no topological order. - '@objectstack/driver-turso': [ - '@objectstack/core', '@objectstack/driver-sql', '@objectstack/spec', - ], - '@objectstack/embedder-openai': ['@objectstack/spec'], - '@objectstack/example-crm': ['@objectstack/driver-sql', '@objectstack/objectql', '@objectstack/spec'], - '@objectstack/example-embed-objectql': [ - '@objectstack/driver-memory', '@objectstack/objectql', '@objectstack/spec', - ], - // #8990 / PR #9280 — `@objectstack/formula` came OUT of this entry (a shrink) when - // the app's tsconfig gained a `paths` rule pointing at formula's SOURCE. The test - // added there typechecks against the CEL engine's contract, and a stale `dist/*.d.ts` - // would typecheck GREEN over a contract that has since moved — the dangerous - // direction this file's header names. Same pair examples/app-crm moved through on - // PR #9166. - '@objectstack/example-showcase': [ - '@objectstack/cloud-connection', '@objectstack/connector-mcp', '@objectstack/connector-openapi', - '@objectstack/connector-rest', '@objectstack/connector-slack', '@objectstack/core', - '@objectstack/driver-sql', '@objectstack/objectql', - '@objectstack/plugin-approvals', '@objectstack/runtime', '@objectstack/service-automation', - '@objectstack/service-datasource', '@objectstack/service-messaging', '@objectstack/spec', - ], - '@objectstack/example-todo': [ - '@objectstack/core', '@objectstack/driver-sqlite-wasm', '@objectstack/mcp', '@objectstack/objectql', - '@objectstack/runtime', '@objectstack/service-automation', '@objectstack/spec', - '@objectstack/trigger-record-change', - ], - '@objectstack/formula': ['@objectstack/spec'], - '@objectstack/hono': ['@objectstack/plugin-hono-server', '@objectstack/runtime', '@objectstack/types'], - // #12511, the eighth package's re-baseline — the doc-block above carries the - // before/after `--list` numbers and the provenance condition. Everything - // after `@objectstack/core` here arrives from `tsconfig.test.json`, the - // program this card onboarded. - '@objectstack/http-conformance': [ - '@objectstack/core', '@objectstack/driver-sqlite-wasm', '@objectstack/hono', - '@objectstack/objectql', '@objectstack/plugin-hono-server', '@objectstack/runtime', - '@objectstack/spec', - ], - '@objectstack/knowledge-memory': ['@objectstack/core', '@objectstack/spec'], - '@objectstack/knowledge-ragflow': ['@objectstack/core', '@objectstack/spec'], - '@objectstack/lint': ['@objectstack/formula', '@objectstack/sdui-parser', '@objectstack/spec'], - '@objectstack/mcp': [ - '@objectstack/core', '@objectstack/formula', '@objectstack/lint', - '@objectstack/metadata-core', '@objectstack/spec', '@objectstack/types', - ], - '@objectstack/metadata': [ - '@objectstack/core', '@objectstack/driver-sqlite-wasm', '@objectstack/metadata-core', - '@objectstack/metadata-fs', '@objectstack/spec', '@objectstack/types', - ], - '@objectstack/metadata-core': ['@objectstack/spec'], - '@objectstack/metadata-fs': ['@objectstack/metadata-core'], - '@objectstack/metadata-protocol': [ - '@objectstack/core', '@objectstack/formula', '@objectstack/lint', '@objectstack/metadata', - '@objectstack/metadata-core', '@objectstack/spec', '@objectstack/types', - ], - '@objectstack/objectql': [ - '@objectstack/core', '@objectstack/formula', '@objectstack/metadata', '@objectstack/metadata-core', - '@objectstack/metadata-protocol', '@objectstack/spec', '@objectstack/types', - ], - '@objectstack/platform-objects': [ - '@objectstack/core', '@objectstack/formula', '@objectstack/lint', - '@objectstack/metadata-core', '@objectstack/spec', - ], - // ── #14062 re-baseline, on the onboarding limb above ───────────────────── - // - // The director ruling of 2026-09-01 on #14062 (maintainer verbatim: 「同意」) - // onboarded all fourteen `packages/plugins/**` packages into the - // `check:test-typecheck` instrument, so thirteen packages' `typecheck` - // scripts began naming a `tsconfig.test.json` they had never named before - // (`plugin-security` already did, from #13176). That MOVES THE PROGRAM SET, - // which #11490 made a package's prerogative and which this doc-block's - // onboarding limb is exactly about. - // - // Condition 1 — every dep admitted below is annotated `via tsconfig.test.json` - // by this gate's own failure text, i.e. reached ONLY through the program this - // change onboarded. Not one of them is newly reached through a program that - // was already counted, and nothing here launders such a case. - // - // Condition 2 — the numbers, `--list` on the same checkout with the workspace - // closure built, taken at this branch's merge base (`79b6a22a5`) and on the - // branch: - // - // before 98 programs / 77 packages, 54 entries, 242 package-dep pairs - // after 111 programs / 77 packages, 55 entries, 269 package-dep pairs - // - // so +13 programs (the thirteen new `tsconfig.test.json`), +1 entry - // (`plugin-dev`, which had none) and +27 pairs — the ones marked below and - // nothing else. The other nine onboarded packages add ZERO pairs: their test - // files import no workspace dep their `tsconfig.json` did not already reach. - // - // ⚠️ The program total moves under this branch for reasons that are NOT this - // change: measured against the earlier base `f7d92d364` the same pair read - // 96 -> 109, because `#13284` landed two `tsconfig.typecheck.json` on `main` - // in between. The DELTA is the invariant to read here (+13 programs, +1 - // entry, +27 pairs) — an absolute taken at one moment is a number about that - // moment, and re-deriving it after any merge is what keeps it honest. - // - // Condition 3 — reviewed as a re-baseline. `paths` is not the tool here, by - // the doc-block's own measurement on PR #12570 rather than by preference. - // [#14062] The five deps after `@objectstack/core` arrive from - // `tsconfig.test.json`: 35 test files that NO tsc program compiled (the build - // config's `exclude` named `**/*.test.ts`), carrying 324 measured errors. - '@objectstack/plugin-approvals': [ - '@objectstack/core', '@objectstack/formula', '@objectstack/spec', '@objectstack/types', - '@objectstack/driver-sql', '@objectstack/metadata-protocol', '@objectstack/objectql', - '@objectstack/service-automation', '@objectstack/trigger-record-change', - ], - '@objectstack/plugin-audit': ['@objectstack/core', '@objectstack/objectql', '@objectstack/spec'], - // #11490 re-baseline: `@objectstack/plugin-hono-server` arrives from - // `tsconfig.examples.json`. - // [#14062] The three deps after `@objectstack/types` arrive from - // `tsconfig.test.json`: 87 test files that NO tsc program compiled (the build - // config's `exclude` named `**/*.test.ts`), carrying 94 measured errors. - '@objectstack/plugin-auth': [ - '@objectstack/core', '@objectstack/platform-objects', '@objectstack/plugin-hono-server', - '@objectstack/rest', '@objectstack/spec', '@objectstack/types', - '@objectstack/driver-sql', '@objectstack/objectql', '@objectstack/plugin-security', - ], - // [#14062] A NEW entry, and the only one this re-baseline adds. Every dep - // arrives from `tsconfig.test.json`; this package's `tsconfig.json` reaches no - // workspace dependency through `dist/` at all, which is why it had no entry. - // Its 6 test files measure ZERO errors, so it carries no debt ledger — this - // registry records reachability, never debt, and the two are independent. - '@objectstack/plugin-dev': [ - '@objectstack/core', '@objectstack/driver-memory', '@objectstack/objectql', - '@objectstack/plugin-auth', '@objectstack/plugin-hono-server', '@objectstack/plugin-security', - '@objectstack/rest', '@objectstack/runtime', '@objectstack/service-i18n', - '@objectstack/service-realtime', '@objectstack/service-storage', '@objectstack/spec', - '@objectstack/types', - ], - '@objectstack/plugin-email': [ - '@objectstack/core', '@objectstack/formula', '@objectstack/objectql', '@objectstack/platform-objects', - '@objectstack/service-queue', '@objectstack/service-settings', '@objectstack/spec', - ], - '@objectstack/plugin-hono-server': [ - '@objectstack/core', '@objectstack/observability', '@objectstack/spec', '@objectstack/types', - ], - '@objectstack/plugin-pinyin-search': ['@objectstack/core', '@objectstack/objectql', '@objectstack/types'], - '@objectstack/plugin-reports': [ - '@objectstack/core', '@objectstack/driver-sql', '@objectstack/objectql', - '@objectstack/platform-objects', '@objectstack/spec', - ], - // #13176 re-baseline, on the onboarding limb above. The five deps after - // `@objectstack/metadata-core` here arrive from `tsconfig.test.json` — a - // program this repo had no equivalent of in this package until that card, and - // whose 89 test files were read by NO tsc program at all (measured per program - // with `--listFiles`: 0 of them in `tsconfig.json`, 0 in - // `tsconfig.scripts.json`). Every one of the five is annotated `via - // tsconfig.test.json` by this gate's own failure text, which is condition 1: - // none is newly reached through a program that was already counted. - // `paths` is not the tool here, by the doc-block's own measurement on - // PR #12570 rather than by preference — and `vitest.config.ts` aliases four of - // these to source already, so the RUNTIME axis is covered where it matters. - // The numbers, as condition 2 requires them stated in place (`--list`, - // workspace closure built): - // - // before 94 programs / 77 packages, 54 entries, 238 package-dep pairs - // after 95 programs / 77 packages, 54 entries, 243 package-dep pairs - // - // so +1 program, +0 entries (this package already had one) and +5 pairs — the - // five below and nothing else. - '@objectstack/plugin-security': [ - '@objectstack/core', '@objectstack/driver-sql', '@objectstack/formula', - '@objectstack/metadata-core', '@objectstack/metadata-protocol', '@objectstack/objectql', - '@objectstack/platform-objects', '@objectstack/plugin-sharing', '@objectstack/service-i18n', - '@objectstack/spec', - ], - // #11490 re-baseline: a NEW entry — reached only through `tsconfig.scripts.json`. - // [#14062] The five after `@objectstack/spec` arrive from `tsconfig.test.json`. - // ⚠️ Note the shape here, which differs from approvals/auth/sharing: this - // package's build config never excluded its tests, so `tsc --noEmit` already - // compiled all 11 of them (measured: 0 errors). What was missing was not a - // compile but this gate's SIGHT of it — the program set moved, and these five - // deps were always reached, by a program nobody had named. - '@objectstack/plugin-webhooks': [ - '@objectstack/spec', - '@objectstack/core', '@objectstack/metadata-core', '@objectstack/objectql', - '@objectstack/platform-objects', '@objectstack/service-messaging', - ], - // [#14062] `@objectstack/driver-sql` arrives from `tsconfig.test.json`: 30 test - // files that NO tsc program compiled (the build config's `exclude` named - // `**/*.test.ts`), carrying 3 measured errors. - '@objectstack/plugin-sharing': [ - '@objectstack/core', '@objectstack/formula', '@objectstack/metadata-core', '@objectstack/objectql', - '@objectstack/platform-objects', '@objectstack/spec', '@objectstack/types', - '@objectstack/driver-sql', - ], - // #12542: `packages/rest` had NO tsc program compiling any of its 149 test - // files, and its new `tsconfig.test.json` (the #5286 sibling route) is the - // first one that does. The six deps after `@objectstack/core` here arrive - // from that program, exactly as `@objectstack/client`'s and - // `@objectstack/trigger-record-change`'s test-program deps do above. - // - // ⚠️ This is a program-set widening and its numbers are stated, per this - // registry's own rule: before, at 5fbd58e0d, `--list` reported 93 programs / - // 77 packages, 54 entries, 233 pairs; after, 94 programs / 77 packages, 54 - // entries, 239 pairs. +1 program, +0 entries, +6 pairs, all six in this one - // package and all six reached only through `tsconfig.test.json`. - // - // Why the entry and not `paths` rules, which is what this gate's failure text - // asks for: MEASURED both ways on the same checkout. `paths` redirecting - // these six to source puts their `src` trees in the program and takes the - // test layer from 37 errors to 42 — the +5 being TS6133 in - // `../plugins/plugin-hono-server/src/{hono-plugin,current-user-endpoints}.ts` - // and `../drivers/driver-sql/src/sql-driver.ts`, i.e. OTHER packages' source - // billed to `packages/rest/test-typecheck-debt.json`, where they would then - // red on those packages' PRs. It would also make the type program diverge - // from the runtime one: `packages/rest/vitest.config.ts` aliases exactly two - // of the six (`plugin-hono-server`, `service-datasource`) to source and - // resolves the other four through `dist/`, so blanket `paths` here is not - // fidelity to vitest either. - '@objectstack/rest': [ - '@objectstack/core', '@objectstack/driver-sql', '@objectstack/metadata', - '@objectstack/metadata-core', '@objectstack/objectql', '@objectstack/observability', - '@objectstack/platform-objects', '@objectstack/plugin-hono-server', '@objectstack/plugin-security', - '@objectstack/service-analytics', '@objectstack/service-datasource', '@objectstack/service-package', - '@objectstack/spec', '@objectstack/types', - ], - // ── #14504 re-baseline, on the onboarding limb above ───────────────────── - // - // This package's `typecheck` script began naming `tsconfig.test.json`, which - // is what onboarding a test layer IS, so the PROGRAM SET moved and this entry - // is re-measured rather than widened. The nine deps after `@objectstack/core` - // that are new here -- `driver-memory`, `driver-sqlite-wasm`, - // `platform-objects`, `plugin-hono-server`, `plugin-sharing`, - // `service-analytics`, `service-job`, `service-messaging`, `service-package` - // -- satisfy CONDITION 1 by measurement and not by assertion: the gate's own - // failure text annotates every one of them `(via tsconfig.test.json)`, and a - // `--list` taken with the wiring reverted (the ONLY difference between the - // two trees) reports this entry WITHOUT any of the nine. They are reached - // only through the program this change onboarded. Six of the nine are this - // package's devDependencies, which its `src/**` cannot import at all and its - // test layer does; the other three (`driver-memory`, `driver-sqlite-wasm`, - // `plugin-hono-server`) are runtime deps whose bare specifier only the test - // layer names. - // - // CONDITION 2 -- the numbers, both from `--list` on the same checkout, the - // wiring in `packages/runtime/package.json` the only difference: - // - // before 114 programs / 78 packages, 56 non-clean, 270 package-dep pairs - // after 115 programs / 78 packages, 56 non-clean, 279 package-dep pairs - // - // so +1 program, +9 pairs, +0 entries and +0 non-clean packages: this entry - // already existed, and nothing that was clean stopped being clean. The - // ratchet is shrink-only from 279. - // - // CONDITION 3 -- reviewed as a re-baseline. `paths` is deliberately NOT the - // tool here, on the onboarding limb's own measured grounds (PR #12570): - // redirecting these specifiers to source would bill other packages' source - // diagnostics into `packages/runtime/test-typecheck-debt.json`, a ledger - // those packages cannot see and nobody can pay down -- and that ledger is - // this change's whole deliverable. - '@objectstack/runtime': [ - '@objectstack/core', '@objectstack/driver-memory', '@objectstack/driver-sql', - '@objectstack/driver-sqlite-wasm', '@objectstack/metadata', '@objectstack/metadata-core', - '@objectstack/metadata-protocol', '@objectstack/objectql', '@objectstack/observability', - '@objectstack/platform-objects', '@objectstack/plugin-auth', '@objectstack/plugin-hono-server', - '@objectstack/plugin-security', '@objectstack/plugin-sharing', '@objectstack/rest', - '@objectstack/service-analytics', '@objectstack/service-cluster', '@objectstack/service-datasource', - '@objectstack/service-job', '@objectstack/service-messaging', '@objectstack/service-package', - '@objectstack/spec', '@objectstack/types', - ], - // ── #14710 re-baseline, on the onboarding limb above ───────────────────── - // - // A NEW entry, and this package was CLEAN before it -- unlike #14504's - // `runtime`, which already had one to re-measure. Read that first, because a - // package leaving the clean list normally means something regressed and here - // it does not: nothing about the code or the build state changed. This - // package's `typecheck` script began naming `tsconfig.test.json`, which is - // what onboarding a test layer IS, so a program that was always there became - // one this gate can SEE. The `packages/cli/test/` tree reached these nine - // deps on every vitest run already; no tsc program had ever been pointed at - // it, so no `--list` ever counted the pairs. - // - // CONDITION 1 by measurement, not assertion: the gate's own failure text - // annotates all nine `(via tsconfig.test.json)`, and a `--list` taken with - // the `package.json` wiring reverted -- the ONLY difference between the two - // trees, restored and verified by blob-hash equality -- reports NO - // `@objectstack/cli` entry at all. Every one of the nine is reached only - // through the program this change onboarded. - // - // CONDITION 2 -- the numbers, both from `--list` on the same checkout with - // the closure built: - // - // before 115 programs / 78 packages, 56 non-clean, 279 package-dep pairs - // after 116 programs / 78 packages, 57 non-clean, 288 package-dep pairs - // - // so +1 program, +9 pairs, +1 ENTRY and +1 non-clean package. The +1s are - // the honest cost of making an unseen program visible, and the ratchet is - // shrink-only from 288. - // - // CONDITION 3 -- reviewed, and `paths` is deliberately NOT the tool here, on - // the onboarding limb's own measured grounds (PR #12570, restated for - // `packages/rest` above): redirecting these specifiers to source would bill - // OTHER packages' source diagnostics into `packages/cli/test-typecheck-debt.json`, - // a ledger those packages cannot see and nobody can pay down -- and that - // ledger is this change's whole deliverable. - // - // ⚠️ One asymmetry recorded rather than repaired, because it is a property of - // this family and not of this card: `packages/cli/vitest.config.ts` aliases - // exactly four specifiers to source (`service-cache`, `plugin-auth`, - // `metadata-core`, and `create-objectstack/created-summary`), and - // `@objectstack/plugin-auth` is among the nine below -- so vitest reads it - // from `src` while this program reads its `dist`. That is the same trade - // `packages/rest`'s block above names (vitest aliases 2 of its 6), and it is - // why blanket `paths` would not be fidelity to vitest either. The - // source-vs-dist ledger for aliases is `scripts/check-test-source-alias.mjs`, - // which owns this question; this registry only records that the pair exists. - '@objectstack/cli': [ - '@objectstack/cloud-connection', '@objectstack/core', '@objectstack/driver-sql', - '@objectstack/lint', '@objectstack/mcp', '@objectstack/platform-objects', - '@objectstack/plugin-auth', '@objectstack/spec', '@objectstack/types', - ], - // #15048 re-baseline (the onboarding limb above): a NEW entry, reached ONLY - // through `tsconfig.test.json` -- a program this card ADDED. Same shape as - // the `service-cluster` re-baseline below (#14181): `service-automation` had - // NO `typecheck` script AT ALL before (its scripts were `build` and `test`), - // and its build `tsconfig.json` -- which is ALWAYS a counted program per this - // gate's own design (see `programConfigsFor`'s doc-block) -- measured clean - // on its own, so there is no pre-existing program a dep could be laundered - // through. All 9 deps here are annotated `via tsconfig.test.json` by this - // gate's own failure text. - // - // Provenance measured four ways on one checkout, by varying only what the - // `typecheck` script NAMES (`--list`, totals as printed). RE-MEASURED on the - // merge of `origin/main` @ 919beca43b, which had landed the `service-knowledge` - // onboarding below (#15049) since this card's first reading: that merge moved - // every ABSOLUTE here (+1 program, +3 pairs, +1 package before this entry - // exists) and moved none of the DELTAS, which are what this block claims. - // - // no `typecheck` script (origin/main) absent 120 programs / 293 pairs - // names `tsconfig.json` only absent 120 programs / 293 pairs - // names `tsconfig.test.json` only PRESENT 121 programs / 302 pairs - // names both (this card) PRESENT 121 programs / 302 pairs - // - // Row 2 is the load-bearing one: the BUILD program (which already includes - // every test file -- `tsconfig.json`'s `include` has never excluded them) - // carries no dist-resolved workspace type import at all, so the exposure is - // not merely first SEEN through the onboarded program, it is only REACHABLE - // through it. Numbers, before/after on the same checkout: - // - // before 59 of 78 packages, 120 programs, 293 pairs, 19 clean - // after 60 of 78 packages, 121 programs, 302 pairs, 18 clean - // - // so +1 package, +1 program, +9 pairs (one per dep below) -- this entry and - // nothing else. - // - // Why the entry and not `paths`: MEASURED, not argued. Redirecting these 9 - // deps to source takes this package's test layer from 0 errors to 648 (647 - // TS6059 `not under rootDir` + 1 TS6133), ALL 647 of the TS6059 in ANOTHER - // package's source -- zero name a file under this package's own `src/`: - // `packages/spec/src/**` 379, `packages/core/src/**` 62, - // `packages/plugins/plugin-security/src/**` 60, `packages/objectql/src/**` - // 49, `packages/services/service-messaging/src/**` 41, `packages/ - // metadata-core/src/**` 29, `packages/formula/src/**` 15, `packages/ - // services/service-job/src/**` 6, `packages/drivers/driver-sql/src/**` 6 -- - // billed to packages that cannot pay them down. Same finding as the - // `service-cluster` re-baseline below (#14181: 0 -> 435) and PR #12570's - // before it, reproduced again at a larger scale because this package pulls - // more workspace deps. The #5286 route this entry backs makes this - // package's OWN test files compile clean; `paths` would immediately re-bury - // that result under other packages' diagnostics. - // - // #17396 / PR #18198 -- `@objectstack/types` is ADMITTED here by maintainer - // ruling 「18198 确认 + A」 (relayed by the director seat at PR comment - // 5692650519, 2026-09-16), which places the deployment-switch resolver in - // `packages/types` (ruling G item 1) and the gate plus its reason in this - // package and `trigger-schedule` (items 2-3). - // - // ⚠️ This is NOT the re-baseline limb and a later reader must not take it - // for one. Condition 1 above requires every admitted dep be reached ONLY - // through a program the change ONBOARDED; this change onboarded NOTHING. - // `tsconfig.test.json` and this package's `typecheck` script are both - // byte-identical to the merge-base (d4554d4f5), so the dep arrives through a - // program that was ALREADY counted -- which is precisely the exposure this - // ratchet exists to catch. It is admitted under the OTHER limb, the one this - // gate's own failure text names in its last two lines: widening this ledger - // is MAINTAINER-ONLY, and the maintainer ruled. No author may reach for this - // entry as precedent; ⛔ it is not a door, it is a signature. - // - // Provenance read off the instrument rather than asserted: the failure - // annotated this dep `via tsconfig.test.json`, i.e. the build program does - // not reach it -- only the test program does. - // - // `paths` (option B) was ruled out on this package's OWN measurement, stated - // directly above: 647 TS6059, not one of them naming a file under this - // package's `src/`. ⛔ Not C (no source-reachable shared home; spec may not - // read env) and ⛔ not D (a second and third implementation of one - // deployment policy). - // - // Numbers, per this registry's rule that a widening states them or is - // indistinguishable from a ratchet quietly reset. ⚠️ `--list` reports the - // MEASURED repo, not this object, so on this widening it does NOT move: - // 135 programs / 80 packages / 321 pairs / 19 clean, before AND after. That - // is the honest reading and not a missing measurement -- this change admits - // no program, and the exposure was already inside that 321 (it is what went - // red). What moves is the REGISTRY: 61 entries / 319 pairs -> 61 entries / - // 321 pairs, so +0 entries and +2 pairs, this one and `trigger-schedule`. - // Shrink-only from the new number. - '@objectstack/service-automation': [ - '@objectstack/core', '@objectstack/driver-sql', '@objectstack/formula', - '@objectstack/metadata-core', '@objectstack/objectql', '@objectstack/plugin-security', - '@objectstack/service-job', '@objectstack/service-messaging', '@objectstack/spec', - '@objectstack/types', - ], - // #14181 re-baseline (the onboarding limb above): a NEW entry, reached ONLY - // through `tsconfig.test.json` -- a program this card ADDED. This is the - // limb's cleanest case rather than a borderline one: `service-cluster` had NO - // `typecheck` script AT ALL before (its scripts were `build` and `test`), so - // it ran ZERO counted programs and there is no pre-existing program for a dep - // to be laundered through. Both deps here are annotated `via - // tsconfig.test.json` by this gate's own failure text. - // - // Provenance measured four ways on one checkout, by varying only what the - // `typecheck` script NAMES (`--list`, totals as printed): - // - // no `typecheck` script (origin/main) absent 118 programs / 288 pairs - // names `tsconfig.json` only absent 118 programs / 288 pairs - // names `tsconfig.test.json` only PRESENT 119 programs / 290 pairs - // names both (this card) PRESENT 119 programs / 290 pairs - // - // Row 2 is the load-bearing one: the BUILD program carries no dist-resolved - // workspace type import at all, so the exposure is not merely first SEEN - // through the onboarded program, it is only REACHABLE through it. (The two - // programs put the same files in -- this package's `tsconfig.json` has never - // excluded tests -- so module semantics, NodeNext vs bundler, is the only - // axis that differs.) - // - // Numbers, `--list` before/after on the same checkout (before at 44ffa2103, - // after with this card applied): - // - // before 57 of 78 packages, 118 programs, 288 pairs, 21 clean - // after 58 of 78 packages, 119 programs, 290 pairs, 20 clean - // - // so +1 package, +1 program, +2 pairs -- this entry and nothing else. - // - // Why the entry and not `paths`, which is what this gate's failure text asks - // for: MEASURED both ways on the same checkout, and `paths` is decisively the - // wrong tool here. Redirecting these two deps to source takes this package's - // test layer from 0 errors to 435, ALL of them TS6059 (`not under rootDir`) - // and every one of them in ANOTHER package's source -- `packages/spec/src/**` - // and `packages/core/src/**` -- billed to a package that cannot pay them down. - // That is the PR #12570 finding (+5 TS6133 for `rest`) and the #8021 one (247 - // TS6059) reproduced at a much larger scale, on a package whose entire point - // in this card was to reach ZERO test-layer errors. Note the direction: the - // #5286 route it took makes its OWN test files compile clean, and `paths` - // would immediately re-bury that result under other packages' diagnostics. - '@objectstack/service-cluster': ['@objectstack/core', '@objectstack/spec'], - // #14386 re-baseline (the onboarding limb above): a NEW entry, reached ONLY - // through `tsconfig.typecheck.json` -- a program that card ADDED (this - // package's `typecheck` was a bare `tsc --noEmit` before it, with no sibling - // to move the set through). The bare `@objectstack/spec` specifier - // `objectstack.config.ts`'s `import { defineStack } from '@objectstack/spec'` - // resolves is not one `src/**/*` reached before: `src/` imports only - // SUBPATHS (`@objectstack/spec/contracts`, `@objectstack/spec/system`), - // never the bare package, so this program is genuinely the first to reach - // it. `paths` is deliberately NOT the tool, on the onboarding limb's own - // measured grounds (PR #12570): this program checks exactly one file whose - // manifest is inline literals, and pulling `@objectstack/spec/src` into it - // via `paths` would put that package's own source (and everything IT - // imports) inside a program whose `rootDir` is `.` (= this package's own - // directory) -- the same TS6059 storm `driver-memory/tsconfig.typecheck.json` - // (#13284) neutralises `rootDir` to avoid for its OWN single file, not for a - // transitively-pulled dependency tree. - // - // Numbers, `--list` before/after on the same checkout (measured at - // `bd8795ea1`, this entry excluded from the "after" run to isolate exactly - // what onboarding the program added): - // - // before 55 of 77 packages, 111 programs, 269 pairs, 22 clean - // after 56 of 77 packages, 112 programs, 270 pairs, 21 clean - // - // so +1 program, +1 pair, +1 package -- this entry and nothing else. - '@objectstack/service-i18n': ['@objectstack/spec'], - // #15049 re-baseline (the onboarding limb above): a NEW entry, reached ONLY - // through `tsconfig.test.json` -- a program this card ADDED, exactly the - // #14181 shape one package over (`service-cluster`, directly above): this - // package's `typecheck` script was ABSENT before this card, its build config - // (`tsconfig.json`) does NOT exclude tests and never did, so it ran ONE - // counted program (the build config) with ZERO dist-resolved deps, and there - // is no pre-existing program a dep could be laundered through. All three - // deps here are annotated `via tsconfig.test.json` by this gate's own - // failure text. - // - // Provenance measured by varying only what the `typecheck` script NAMES, - // same checkout (`--list`, totals as printed): - // - // no `typecheck` script (origin/main) absent 119 programs / 290 pairs - // names `tsconfig.json` only absent 119 programs / 290 pairs - // names `tsconfig.test.json` only PRESENT 120 programs / 293 pairs - // names both (this card) PRESENT 120 programs / 293 pairs - // - // Row 2 is the load-bearing one, same as `service-cluster`'s: the BUILD - // program carries no dist-resolved workspace type import at all, so the - // exposure is not merely first SEEN through the onboarded program, it is - // only REACHABLE through it. - // - // Numbers, `--list` before/after on the same checkout (before at the - // `service-cluster` merge, 2cc4610304; after with this card applied): - // - // before 58 of 78 packages, 119 programs, 290 pairs, 20 clean - // after 59 of 78 packages, 120 programs, 293 pairs, 19 clean - // - // so +1 package, +1 program, +3 pairs -- this entry and nothing else. - // - // Why the entry and not `paths`: MEASURED, not argued -- redirecting these - // three deps to source takes this package's test layer from 0 errors to - // 487, ALL of them TS6059 (`not under rootDir`) and every one of them in - // ANOTHER package's source (`packages/spec/src/**`, `packages/core/src/**`, - // `packages/objectql/src/**`) -- billed to a package that cannot pay them - // down. Same shape as `service-cluster`'s own 0 -> 435 (below) and #12570's - // +5 for `rest`, at a larger scale because this package's test layer pulls - // three workspace deps rather than two. The #5286 route this card took - // makes its OWN test files compile clean; `paths` would immediately re-bury - // that result under other packages' diagnostics. - '@objectstack/service-knowledge': [ - '@objectstack/core', '@objectstack/objectql', '@objectstack/spec', - ], - // #11490 re-baseline: NEW entries — reached only through `tsconfig.scripts.json`. - '@objectstack/service-messaging': ['@objectstack/spec'], - '@objectstack/service-realtime': ['@objectstack/spec'], - '@objectstack/service-sms': [ - '@objectstack/core', '@objectstack/plugin-auth', '@objectstack/service-settings', - '@objectstack/spec', - ], - // #15050 re-baseline (the onboarding limb above): a NEW entry, reached ONLY - // through `tsconfig.test.json` (all 7 deps) and `tsconfig.scripts.json` - // (`@objectstack/spec` again, no new pairs). Same shape as `service-cluster` - // (#14181, above): this package had NO `typecheck` script AT ALL before (its - // scripts were `build` and `test`), so it ran ZERO counted programs and - // there is no pre-existing program a dep could be laundered through. - // - // Provenance measured four ways on one checkout, by varying only what the - // `typecheck` script NAMES (`--list`, totals as printed). RE-MEASURED on the - // merge of `origin/main` @ 460134af8, which had landed BOTH sibling - // onboardings of this family since this card's first reading - // (`service-knowledge` #15049 and `service-automation` #15048, above): that - // merge moved every ABSOLUTE here (+2 programs, +12 pairs, +2 packages - // before this entry exists) and moved none of the DELTAS, which are what - // this block claims. - // - // no `typecheck` script (origin/main) absent 121 programs / 302 pairs - // names `tsconfig.json` only absent 121 programs / 302 pairs - // names `tsconfig.test.json` only PRESENT 122 programs / 309 pairs - // names all three (this card) PRESENT 123 programs / 309 pairs - // - // Row 2 is the load-bearing one, exactly as it was for `service-cluster`: - // the BUILD program carries no dist-resolved workspace type import at all, - // so the exposure is only REACHABLE through the onboarded programs, not - // merely first seen there. (`tsconfig.json` has never excluded tests, so - // module semantics — NodeNext vs bundler — is the axis that differs for the - // test program; `tsconfig.scripts.json` reads a directory BUILD's `include` - // never reached at all.) - // - // Numbers: +1 package (60 -> 61 of 78), +2 programs (121 -> 123, one per - // onboarded config), +7 pairs (302 -> 309) -- this entry and nothing else. - // - // Why the entry and not `paths`, which is what this gate's failure text - // asks for: MEASURED on this checkout (temporary `paths` added to - // `tsconfig.test.json`, `tsc --noEmit -p` run, then removed — never - // committed), and `paths` is decisively the wrong tool here, same as - // `service-cluster` found. Redirecting all 7 deps to source takes this - // package's test layer from 0 errors to 306 (305 x TS6059 "not under - // rootDir" + 1 x TS6133), every TS6059 in ANOTHER package's source - // (`packages/types/src/**`, `packages/spec/src/**`, `packages/objectql/ - // src/**`, `packages/observability/src/**`, `packages/drivers/ - // driver-sql/src/**`) -- billed to a package that cannot pay them down. The - // #5286 route this card took makes its OWN test files compile clean (0/0, - // both readings agree), and `paths` would immediately re-bury that result - // under other packages' diagnostics. - '@objectstack/service-storage': [ - '@objectstack/core', '@objectstack/driver-sql', '@objectstack/objectql', - '@objectstack/observability', '@objectstack/platform-objects', '@objectstack/spec', - '@objectstack/types', - ], - '@objectstack/setup': ['@objectstack/platform-objects', '@objectstack/spec'], - '@objectstack/studio': ['@objectstack/platform-objects', '@objectstack/spec'], - '@objectstack/trigger-api': ['@objectstack/core', '@objectstack/spec'], - // #11490 re-baseline, and the card's own measurement: these four arrive from - // `tsconfig.test.json`. They were REPORTED when the identical 7 test files - // were put through the build config and SILENT through the prescribed - // sibling — the asymmetry this widening closes. - '@objectstack/trigger-record-change': [ - '@objectstack/core', '@objectstack/driver-sql', '@objectstack/formula', '@objectstack/objectql', - '@objectstack/service-automation', '@objectstack/spec', - ], - // #17396 / PR #18198 -- `@objectstack/types` admitted by the same maintainer - // ruling 「18198 确认 + A」 that moves the `service-automation` entry above; - // the full argument, the limb it is admitted under and the before/after - // numbers are stated there once rather than twice here. - // - // ⚠️ This half is the WEAKER provenance of the two and says so in place. - // The failure text annotated the `service-automation` admission - // `via tsconfig.test.json`; this one carries NO `(via ...)` at all, which by - // `withProvenance`'s rule means the dep is reached through this package's - // MAIN `tsconfig.json` -- the build program, counted since long before this - // card. This package runs exactly one program (`typecheck` is `tsc --noEmit`, - // byte-identical to the merge-base) and has no sibling config to onboard, so - // the re-baseline limb is not merely unmet here, it is unreachable. Admitted - // by maintainer ruling alone. - '@objectstack/trigger-schedule': [ - '@objectstack/core', '@objectstack/service-automation', '@objectstack/spec', - '@objectstack/types', - ], - '@objectstack/types': ['@objectstack/spec'], - '@objectstack/verify': [ - '@objectstack/objectql', '@objectstack/platform-objects', '@objectstack/plugin-auth', - '@objectstack/plugin-hono-server', '@objectstack/plugin-security', '@objectstack/plugin-sharing', - '@objectstack/rest', '@objectstack/runtime', '@objectstack/service-analytics', - '@objectstack/service-automation', '@objectstack/service-datasource', '@objectstack/service-settings', - '@objectstack/spec', '@objectstack/types', - ], -}; - -// ── workspace enumeration ─────────────────────────────────────────────────── - -/** - * The workspace globs from pnpm-workspace.yaml, spelled AS GLOBS. - * - * ## Why the `/*` is written out rather than left to the comment (#9955) - * - * This array IS this gate's declared population: every package it walks lives - * directly under one of these parents. `scripts/pm/dispatch-gates.mjs` derives - * the gate list a dispatch brief pastes by scanning each gate's module body for - * the path literals it operates on — so this array is the only thing that tells - * that tool which cards should be sent here. - * - * Its covering rule refuses a literal with NO path separator (`packages`, - * `apps`, `examples`) as too generic, deliberately and measured: admitting bare - * top-level words takes that tool from 19k watch-hint pairs to 158k, because - * `packages` is a path COMPONENT in dozens of gates that never read the root. - * The sanctioned escape is for a gate to declare its own subtree in a spelling - * with a separator in it, which is what these entries now do. - * - * Written as bare directory names, 8 of the 11 entries carried a separator and - * 3 did not, so the derivation's answer for this gate was decided by WHERE a - * package happens to sit: measured on this tree, 1832 of the 4844 tracked files - * under packages/ derived this gate, and the ones that did not were exactly the - * flat `packages/` layouts plus all of apps/ and examples/. A new test in - * a nested package named this gate; the identical test in a flat one did not, - * and nothing in the output said so. That is worse than an honest blind spot — - * it works for a third of the tree, so it reads as working. - * - * The dropped `/*` is re-derived below, so the walk is unchanged and there is - * no second list to keep in sync. Keep the separator in every entry: a tidy-up - * back to bare directory names re-opens the blind spot silently, and the - * self-test case at the bottom of this file is what makes that loud instead. - */ -const WORKSPACE_PARENT_GLOBS = [ - 'packages/*', - 'packages/apps/*', - 'packages/adapters/*', - 'packages/connectors/*', - 'packages/drivers/*', - 'packages/plugins/*', - 'packages/qa/*', - 'packages/services/*', - 'packages/triggers/*', - 'apps/*', - 'examples/*', -]; - -/** The parent directories those globs enumerate — each glob minus its leaf. */ -const WORKSPACE_PARENT_DIRS = WORKSPACE_PARENT_GLOBS.map((glob) => glob.replace(/\/\*$/, '')); - -const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage', '.turbo', '.next', '.cache']); -const SOURCE_FILE = /\.[cm]?[jt]sx?$/; - -class UnreadableConfig extends Error {} - -function listWorkspacePackages(root) { - const out = []; - for (const parent of WORKSPACE_PARENT_DIRS) { - const abs = join(root, parent); - if (!existsSync(abs)) continue; - for (const name of readdirSync(abs)) { - const dir = join(abs, name); - const manifest = join(dir, 'package.json'); - if (!existsSync(manifest)) continue; - let json; - try { - json = JSON.parse(readFileSync(manifest, 'utf8')); - } catch { - continue; - } - if (!json.name) continue; - out.push({ name: json.name, dir, rel: relative(root, dir), json }); - } - } - return out; -} - -/** - * Every declaration entry point this package publishes. Explicit `types` wins - * where it exists — `types`, `typings`, and any `types` condition anywhere in - * `exports`. Only when a package declares none of those does tsc fall back to - * the JS entry's sibling `.d.ts`, so only then do the JS entries answer. - */ -function declarationTargets(json) { - const explicit = []; - if (typeof json.types === 'string') explicit.push(json.types); - if (typeof json.typings === 'string') explicit.push(json.typings); - - const walk = (node, underTypes) => { - if (typeof node === 'string') { - if (underTypes) explicit.push(node); - return; - } - if (node == null || typeof node !== 'object') return; - for (const [key, value] of Object.entries(node)) { - walk(value, underTypes || key === 'types'); - } - }; - walk(json.exports, false); - if (explicit.length > 0) return explicit; - - const fallback = []; - const walkAll = (node) => { - if (typeof node === 'string') { - fallback.push(node); - return; - } - if (node == null || typeof node !== 'object') return; - for (const value of Object.values(node)) walkAll(value); - }; - walkAll(json.exports); - if (typeof json.main === 'string') fallback.push(json.main); - if (typeof json.module === 'string') fallback.push(json.module); - return fallback; -} - -/** - * Does importing this package's TYPES land on a build artifact? Any declaration - * entry under `dist/` is enough: a stale build can then decide a consumer's - * verdict through that entry, and which entry a given specifier reaches is the - * consumer's business, not this predicate's. - */ -function typesResolveToArtifact(json) { - return declarationTargets(json).some((target) => /(^|[^a-z])dist\//.test(target.replace(/\\/g, '/'))); -} - -// ── tsconfig reading: JSONC, `extends`, and where relative targets resolve ── - -/** - * `tsconfig.json` is JSONC. Whole-line `//` comments are stripped before - * `JSON.parse`, exactly as `scripts/check-type-check-coverage.mjs` does — and - * this is not a nicety here: 55 of 76 package tsconfigs in this repo carry - * comments, several of them long rationale blocks that NAME the very specifiers - * being matched. A gate that cannot parse them reads every one of those - * packages as unreadable. - */ -function parseJsonc(raw, file) { - const stripped = raw.replace(/^\s*\/\/.*$/gm, ''); - try { - return JSON.parse(stripped); - } catch (error) { - throw new UnreadableConfig(`${file} is not parseable as JSONC: ${error.message}`); - } -} - -function resolveExtendsTarget(spec, fromDir) { - if (spec.startsWith('.')) { - const base = resolve(fromDir, spec); - for (const candidate of [base, base + '.json', join(base, 'tsconfig.json')]) { - if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; - } - throw new UnreadableConfig(`\`extends\` target does not exist: ${spec}`); - } - // Bare specifier — resolved out of node_modules, walking up. - let dir = fromDir; - for (;;) { - for (const candidate of [ - join(dir, 'node_modules', spec), - join(dir, 'node_modules', spec + '.json'), - join(dir, 'node_modules', spec, 'tsconfig.json'), - ]) { - if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; - } - const parent = dirname(dir); - if (parent === dir) break; - dir = parent; - } - throw new UnreadableConfig(`\`extends\` target cannot be resolved without node_modules: ${spec}`); -} - -/** - * The effective config for one tsconfig file, with its `extends` chain applied. - * - * `compilerOptions` merge SHALLOWLY — a child's `paths` REPLACES the parent's - * whole map rather than merging into it — and `files` / `include` / `exclude` - * are replaced outright by any config that declares them. Each option remembers - * the config file it ORIGINATED in, because relative `paths` targets and - * `baseUrl` resolve against that file's directory, which is not necessarily the - * package's own tsconfig. - */ -function loadTsconfig(file, seen = new Set()) { - const abs = resolve(file); - if (seen.has(abs)) throw new UnreadableConfig(`\`extends\` chain is cyclic at ${abs}`); - seen.add(abs); - - const json = parseJsonc(readFileSync(abs, 'utf8'), abs); - const dir = dirname(abs); - - let compilerOptions = {}; - let originOf = {}; - let fileSet = {}; - - const bases = json.extends == null ? [] : Array.isArray(json.extends) ? json.extends : [json.extends]; - for (const base of bases) { - const parent = loadTsconfig(resolveExtendsTarget(base, dir), new Set(seen)); - compilerOptions = { ...compilerOptions, ...parent.compilerOptions }; - originOf = { ...originOf, ...parent.originOf }; - fileSet = { ...fileSet, ...parent.fileSet }; - } - - for (const [key, value] of Object.entries(json.compilerOptions ?? {})) { - compilerOptions[key] = value; - originOf[key] = dir; - } - for (const key of ['files', 'include', 'exclude']) { - if (json[key] !== undefined) fileSet[key] = { value: json[key], dir }; - } - - return { file: abs, dir, compilerOptions, originOf, fileSet }; -} - -/** - * Where a relative `paths` target is resolved from: `baseUrl` when declared - * (itself relative to the config that declared IT), otherwise the directory of - * the config that declared `paths`. - */ -function pathsBaseDir(config) { - const pathsOrigin = config.originOf.paths ?? config.dir; - const baseUrl = config.compilerOptions.baseUrl; - if (typeof baseUrl === 'string') return resolve(config.originOf.baseUrl ?? config.dir, baseUrl); - return pathsOrigin; -} - -// ── the file set tsc actually puts in the program ─────────────────────────── - -/** A tsconfig glob as a RegExp over POSIX-separated paths relative to its dir. */ -function globToRegExp(glob) { - const g = glob.replace(/\\/g, '/').replace(/^\.\//, ''); - let re = ''; - for (let i = 0; i < g.length; i++) { - const c = g[i]; - if (c === '*') { - if (g[i + 1] === '*') { - i++; - if (g[i + 1] === '/') { - i++; - re += '(?:[^/]*/)*'; - } else { - re += '.*'; - } - } else { - re += '[^/]*'; - } - } else if (c === '?') { - re += '[^/]'; - } else { - re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&'); - } - } - return new RegExp('^' + re + '$'); -} - -const DEFAULT_EXCLUDES = ['node_modules', 'bower_components', 'jspm_packages']; - -function walkFiles(dir, acc = []) { - let entries; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - return acc; - } - for (const entry of entries) { - if (entry.name.startsWith('.')) continue; - const path = join(dir, entry.name); - if (entry.isDirectory()) { - if (SKIP_DIRS.has(entry.name)) continue; - walkFiles(path, acc); - } else if (entry.isFile()) { - acc.push(path); - } - } - return acc; -} - -/** - * The source files tsc puts in the program for this config: `files` plus - * everything matching `include` (default `**​/*`), minus `exclude` (default - * node_modules and friends, plus `outDir`). Directory-shaped excludes exclude - * everything beneath them, as tsc's do. - * - * This is the type axis's ENTRY SET, and it is deliberately wider than the - * vitest gate's: that one starts at test files and follows relative imports, - * because a specifier only decides a runtime verdict if a test can reach it. - * Here every file in the program is checked, so every import in it decides a - * verdict — there is nothing to walk from. - */ -function programFiles(pkgDir, config) { - const rel = (abs) => relative(pkgDir, abs).split(sep).join('/'); - - const includeSpec = config.fileSet.include; - const excludeSpec = config.fileSet.exclude; - const filesSpec = config.fileSet.files; - - const excludes = (excludeSpec?.value ?? DEFAULT_EXCLUDES).map((g) => ({ glob: g, re: globToRegExp(g) })); - const outDir = config.compilerOptions.outDir; - if (typeof outDir === 'string') excludes.push({ glob: outDir, re: globToRegExp(outDir) }); - - const isExcluded = (path) => { - for (const { glob, re } of excludes) { - if (re.test(path)) return true; - const bare = glob.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, ''); - if (!/[*?]/.test(bare) && (path === bare || path.startsWith(bare + '/'))) return true; - } - return false; - }; - - const out = new Set(); - - for (const entry of filesSpec?.value ?? []) { - const abs = resolve(filesSpec.dir, entry); - if (existsSync(abs)) out.add(abs); - } - - // `files` alone, with no `include`, means exactly those files. - if (filesSpec?.value?.length > 0 && includeSpec === undefined) return [...out]; - - const includes = (includeSpec?.value ?? ['**/*']).map((g) => globToRegExp(g)); - const includeDir = includeSpec?.dir ?? pkgDir; - for (const abs of walkFiles(includeDir)) { - if (!SOURCE_FILE.test(abs)) continue; - const path = relative(includeDir, abs).split(sep).join('/'); - if (isExcluded(rel(abs))) continue; - if (includes.some((re) => re.test(path))) out.add(abs); - } - return [...out]; -} - -// ── import extraction ─────────────────────────────────────────────────────── - -/** - * The clause capture is bounded to ONE statement by `[^;'"]` (#12555). This gate - * carried the byte-identical first alternative to - * `scripts/check-test-source-alias.mjs`, whose header block states the defect and - * why excluding `;` alone is not enough (ASI leaves nothing for a `;`-class to - * stop on; excluding the quotes is what closes it, because every intervening - * specifier is quoted). The rest of THIS regex is deliberately not identical — - * the dynamic-import alternative admits `:` and `<` as leading delimiters so that - * `import('y').X` in a type annotation and inside a generic argument are seen — - * and that half is untouched here. - * - * The consequence differs from the runtime gate's even though the regex bug is - * the same. `extractTypeImports` reads only the SPECIFIER captures and never - * `match[1]`, so an over-greedy clause could not corrupt a verdict here; it could - * only make one disappear. A side-effect `import 'x';` followed by any - * `import … from …` was swallowed whole, so a specifier this package really makes - * tsc resolve went uncounted — a false GREEN, in the fail-closed direction this - * gate is otherwise careful about. Bounding the clause can therefore only add - * specifiers to the scan, never remove one. - */ -const IMPORT_PATTERNS = - /(?:^|[\s;})])(?:import|export)\s+([^;'"]*?)\s*from\s*['"]([^'"]+)['"]|(?:^|[\s;{(=,:<])import\s*\(\s*['"]([^'"]+)['"]\s*\)|(?:^|[\s;{(=,])require\s*\(\s*['"]([^'"]+)['"]\s*\)|(?:^|[\s;}])import\s+['"]([^'"]+)['"]/g; - -/** - * Every module specifier this file makes tsc resolve — INCLUDING type-only - * imports, which is the inversion of the runtime gate's rule (see header note - * 4). `import type { X } from 'y'` never resolves at run time and always - * resolves at type time; filtering it here would go blind on the majority of - * this axis's imports. `import('y').X` in type position is caught by the - * dynamic-import branch. - */ -function extractTypeImports(text) { - const specs = []; - IMPORT_PATTERNS.lastIndex = 0; - let match; - while ((match = IMPORT_PATTERNS.exec(text))) { - const spec = match[2] ?? match[3] ?? match[4] ?? match[5]; - if (spec) specs.push(spec); - } - return specs; -} - -// ── tsc's `paths` resolution ──────────────────────────────────────────────── - -/** - * Resolve `specifier` through `paths` the way TSC does — deliberately NOT the - * way Vite does: - * - * - an EXACT (star-free) key that equals the specifier wins outright, - * whatever the declaration order; - * - otherwise, among pattern keys whose prefix and suffix both match, the one - * with the LONGEST MATCHING PREFIX wins — again independent of order; - * - the captured text substitutes for the star in the target. - * - * Vite's algorithm is first-match-wins with prefix matching, and a gate that - * used it would read correct configs as wrong (the #8020 / #8108 failure). - * - * Returns `null` when nothing matches, and null is a REAL ANSWER: it means tsc - * falls through to node resolution, i.e. to `dist`. - * - * Only the FIRST target of a key is followed. tsc tries the list in order and - * takes the first that exists, so a fallback list can only ever land somewhere - * the first entry did not — reported below as "does not exist", never as safe. - */ -function resolveThroughPaths(specifier, paths, baseDir) { - const exact = paths[specifier]; - if (exact && !specifier.includes('*') && exact.length > 0) { - return { key: specifier, target: resolve(baseDir, exact[0]) }; - } - - let best = null; - for (const [key, targets] of Object.entries(paths)) { - const star = key.indexOf('*'); - if (star === -1 || !targets || targets.length === 0) continue; - const prefix = key.slice(0, star); - const suffix = key.slice(star + 1); - if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue; - if (specifier.length < prefix.length + suffix.length) continue; - if (best && best.prefixLength >= prefix.length) continue; - const captured = specifier.slice(prefix.length, specifier.length - suffix.length); - best = { prefixLength: prefix.length, key, target: resolve(baseDir, targets[0].replace('*', captured)) }; - } - return best ? { key: best.key, target: best.target } : null; -} - -const MODULE_SUFFIXES = ['', '.ts', '.tsx', '.d.ts', '.mts', '.cts', '.d.mts', '.d.cts', '.js', '.jsx', '.mjs', '.cjs']; - -/** - * Does this `paths` target name something tsc can actually load? A rule whose - * target does not exist is not a rule: tsc falls back to node resolution — to - * `dist` — and says nothing. That silent fallthrough is the same failure class - * as having no rule at all, so it is reported as one. - */ -function existsAsModule(target) { - for (const suffix of MODULE_SUFFIXES) { - const candidate = target + suffix; - try { - if (statSync(candidate).isFile()) return true; - } catch { - /* next candidate */ - } - } - for (const suffix of MODULE_SUFFIXES) { - if (suffix === '') continue; - try { - if (statSync(join(target, 'index' + suffix)).isFile()) return true; - } catch { - /* next candidate */ - } - } - return false; -} - -function pointsAtSource(path) { - const posix = path.split(sep).join('/'); - return /(^|\/)src(\/|$)/.test(posix) && !/(^|\/)dist(\/|$)/.test(posix); -} - -/** - * The `@objectstack/spec*` trap: a pattern key whose star is NOT preceded by a - * separator, matching a workspace package NAME. Such a key folds the bare - * entry, every subpath and every sibling package sharing the prefix onto one - * target. Unlike the Vite object-form trap it does not crash — the target - * re-exports most of the surface, so it type-checks against the wrong module - * and stays GREEN. This is the one spelling a type-axis gate must refuse - * outright, whatever the rest of the config does. - */ -function starTraps(paths, workspaceNames) { - const traps = []; - for (const key of Object.keys(paths)) { - const star = key.indexOf('*'); - if (star <= 0) continue; - const prefix = key.slice(0, star); - const suffix = key.slice(star + 1); - if (prefix.endsWith('/')) continue; - const swallowed = [...workspaceNames].filter( - (name) => name.startsWith(prefix) && name.endsWith(suffix) && name.length >= prefix.length + suffix.length, - ); - if (swallowed.length > 0) traps.push({ key, swallowed: swallowed.sort() }); - } - return traps; -} - -// ── the scan ──────────────────────────────────────────────────────────────── - -/** - * Every tsc program a package runs, as config basenames — `tsconfig.json` - * first, the rest alphabetical. - * - * ## Why this is not just `tsconfig.json` (#11490) - * - * This gate read `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 — 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. - * - * Measured on `packages/triggers/trigger-record-change` before this widening, - * both spellings of the same 7 test files with the same four dist-resolved - * type imports: - * - * through the BUILD config ✗ NEW dist-resolved type import(s): driver-sql, - * formula, objectql, service-automation - * through the SIBLING config exit 0, count unchanged - * - * So following the house pattern was what made the exposure invisible, and an - * author with a genuine choice between the two spellings got a green gate for - * picking the one that hides more. Same files, same exposure, two answers — a - * gate that is per-package where it needs to be per-PROGRAM. - * - * ## 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 - * would report 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 on this tree 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. - * - * `configsNamedByTypecheck` answers in BASENAMES (see that module's header), so - * every name is resolved against this package's own directory and dropped when - * no file is there. `tsconfig.json` is always a candidate even when no - * `typecheck` script exists at all: this population may only grow here, and a - * package whose primary config stopped being read because its manifest lost a - * script would be a SILENT narrowing. - */ -function programConfigsFor(pkg) { - const named = configsNamedByTypecheck(pkg.json?.scripts ?? {}); - const present = [...new Set(['tsconfig.json', ...named])].filter((file) => existsSync(join(pkg.dir, file))); - return present.sort((a, b) => (a === 'tsconfig.json' ? -1 : b === 'tsconfig.json' ? 1 : a.localeCompare(b))); -} - -/** - * One tsc program's verdict: which workspace deps its file set imports whose - * types resolve through a build artifact, plus the config defects found on the - * way. Resolution is per PROGRAM and cannot be hoisted to the package: `paths`, - * `baseUrl` and the `extends` chain are properties of the config that was - * loaded, and a sibling config that re-declares `compilerOptions` replaces its - * parent's `paths` map outright rather than merging into it. - */ -function scanProgram(root, pkg, file, names, artifactPackages) { - const empty = { file, fileCount: 0, distResolved: [], traps: [], missingTargets: [] }; - - let config; - try { - config = loadTsconfig(join(pkg.dir, file)); - } catch (error) { - if (!(error instanceof UnreadableConfig)) throw error; - return { ...empty, unreadable: error.message }; - } - - const paths = config.compilerOptions.paths ?? {}; - const baseDir = pathsBaseDir(config); - const files = programFiles(pkg.dir, config); - - /** bare workspace name -> the specifiers actually written */ - const imports = new Map(); - for (const source of files) { - let text; - try { - text = readFileSync(source, 'utf8'); - } catch { - continue; - } - for (const spec of extractTypeImports(text)) { - if (spec.startsWith('.') || spec.startsWith('/')) continue; - const scoped = spec.match(/^(@[^/]+\/[^/]+)(?:\/.*)?$/); - const bare = scoped ? scoped[1] : spec.split('/')[0]; - if (bare === pkg.name || !names.has(bare)) continue; - if (!imports.has(bare)) imports.set(bare, new Set()); - imports.get(bare).add(spec); - } - } - - const distResolved = []; - const missingTargets = []; - for (const [dep, specs] of [...imports].sort(([a], [b]) => a.localeCompare(b))) { - if (!artifactPackages.has(dep)) continue; // types already point at source - let anyDistResolved = false; - for (const spec of [...specs].sort()) { - const resolved = resolveThroughPaths(spec, paths, baseDir); - if (!resolved) { - anyDistResolved = true; - continue; - } - if (!existsAsModule(resolved.target)) { - missingTargets.push({ spec, key: resolved.key, target: relative(root, resolved.target) }); - anyDistResolved = true; - continue; - } - if (!pointsAtSource(resolved.target)) anyDistResolved = true; - } - if (anyDistResolved) distResolved.push(dep); - } - - return { - file, - fileCount: files.length, - unreadable: null, - distResolved, - traps: starTraps(paths, names), - missingTargets, - }; -} - -function scan(root) { - const workspace = listWorkspacePackages(root); - const names = new Set(workspace.map((p) => p.name)); - const artifactPackages = new Set(workspace.filter((p) => typesResolveToArtifact(p.json)).map((p) => p.name)); - - const packages = []; - for (const pkg of workspace) { - const configFiles = programConfigsFor(pkg); - if (configFiles.length === 0) continue; - - const programs = configFiles.map((file) => scanProgram(root, pkg, file, names, artifactPackages)); - - // dep -> the config(s) whose program reaches it, so a diagnostic can say - // WHICH program to look in. Without it an author reads the build config, - // finds no such import, and concludes the gate is wrong. - const distResolvedBy = new Map(); - for (const program of programs) { - for (const dep of program.distResolved) { - if (!distResolvedBy.has(dep)) distResolvedBy.set(dep, []); - distResolvedBy.get(dep).push(program.file); - } - } - - packages.push({ - name: pkg.name, - rel: pkg.rel, - programs, - fileCount: programs.reduce((n, program) => n + program.fileCount, 0), - unreadable: programs.find((program) => program.unreadable)?.unreadable ?? null, - distResolved: [...distResolvedBy.keys()].sort((a, b) => a.localeCompare(b)), - distResolvedBy, - }); - } - - return { - packages, - artifactPackages, - totalPackages: workspace.length, - totalPrograms: packages.reduce((n, p) => n + p.programs.length, 0), - }; -} - -// ── the gate ──────────────────────────────────────────────────────────────── - -function check(root, registry) { - const failures = []; - const { packages, artifactPackages, totalPackages, totalPrograms } = scan(root); - - // Census guard. Every reading below is a scan result, and a scan that has - // quietly stopped matching reports a spotless repo. Zero is never the good - // news it looks like. - if (totalPackages === 0) - failures.push('scanner found NO workspace packages at all — the scan is broken, not the repo'); - if (artifactPackages.size === 0) - failures.push('scanner found NO package whose types resolve to `dist/` — entry detection is broken, not the repo'); - if (packages.length === 0) - failures.push('scanner found NO package with a tsc program — config discovery is broken, not the repo'); - if (packages.length > 0 && packages.every((p) => p.fileCount === 0 && !p.unreadable)) - failures.push('scanner put NO file in ANY program — include/exclude handling is broken, not the repo'); - // The census leg the #11490 widening needs. Sibling-config discovery failing - // is INVISIBLE without it: this gate reverts to exactly its old per-package - // behaviour, every remaining program still scans, and the summary reads like - // a clean repo. `configsNamedByTypecheck` returning an empty set for every - // package — a regex that stopped matching, a manifest reader that stopped - // seeing `scripts` — is that failure, and it is silent by construction. - if (packages.length > 0 && packages.every((p) => p.programs.length <= 1)) - failures.push( - 'scanner found NO package running more than one tsc program — sibling-config discovery is broken, not\n' + - ' the repo. The prescribed `tsconfig.test.json` route is the one this gate exists to be able to see.', - ); - - const measured = new Map(packages.filter((p) => p.distResolved.length > 0).map((p) => [p.name, p.distResolved])); - - for (const pkg of packages) { - for (const program of pkg.programs) { - if (program.unreadable) { - failures.push( - `${pkg.rel}/${program.file} cannot be read (${program.unreadable}).\n` + - ' This gate must be able to resolve every `extends` chain and read every `paths` block.', - ); - } - for (const trap of program.traps) { - failures.push( - `${pkg.rel}/${program.file}: \`paths\` key \`${trap.key}\` puts its star where a separator belongs, so it ` + - `swallows ${trap.swallowed.join(', ')}\n` + - ' and every subpath of them, folding all of it onto one target. This does NOT crash — the target\n' + - ' re-exports most of the surface, so the program type-checks against the WRONG MODULE and stays\n' + - ` green. Split it into an exact key and a \`/*\` key:\n` + - ` "${trap.swallowed[0]}": ["/src/index.ts"],\n` + - ` "${trap.swallowed[0]}/*": ["/src/*/index.ts"]`, - ); - } - for (const missing of program.missingTargets) { - failures.push( - `${pkg.rel}/${program.file}: \`paths\` key \`${missing.key}\` maps \`${missing.spec}\` to ` + - `\`${missing.target}\`, which does not exist.\n` + - ' A rule whose target is missing is not a rule: tsc falls back to node resolution — i.e. to `dist`\n' + - ' — and reports nothing. Fix the target or delete the rule; a rule that matches nothing is worse\n' + - ' than no rule, because it reads as coverage.', - ); - } - } - } - - /** - * A dep list annotated with the program that reaches it, when that is not the - * build config (#11490). Without it the widening produces a diagnostic an - * author cannot act on: they open `tsconfig.json`, find no such import, and - * conclude the gate is wrong. Plain names are kept in the copy-paste snippets - * below — those are `paths` keys and a parenthetical would be pasted in. - */ - const withProvenance = (pkg, deps) => - deps.map((dep) => { - const via = pkg.distResolvedBy.get(dep) ?? []; - const siblings = via.filter((file) => file !== 'tsconfig.json'); - return siblings.length > 0 && !via.includes('tsconfig.json') ? `${dep} (via ${siblings.join(', ')})` : dep; - }); - - for (const [name, deps] of measured) { - const registered = registry[name]; - if (!registered) { - const pkg = packages.find((p) => p.name === name); - failures.push( - `${pkg.rel} (${name}): its tsc program imports ${deps.length} workspace package(s) whose declarations\n` + - ` resolve to \`dist/\` with no \`paths\` rule pointing at source:\n` + - ` ${withProvenance(pkg, deps).join(', ')}\n` + - ' Every type verdict in this package — including whatever `typecheck` reports — is currently a\n' + - ' function of build state, and the dangerous case is SILENT (a dist merely BEHIND the source\n' + - ' type-checks GREEN against old declarations). Add the rules to its tsconfig.json:\n' + - ` "paths": { "${deps[0]}": ["/src/index.ts"], "${deps[0]}/*": ["/src/*/index.ts"] }\n` + - ' ⚠️ Never spell that key `*`: the star must follow a separator. See this file\'s header.', - ); - continue; - } - const added = deps.filter((d) => !registered.includes(d)); - const gone = registered.filter((d) => !deps.includes(d)); - if (added.length > 0) - failures.push( - `${name}: NEW dist-resolved type import(s) since this entry was measured: ` + - `${withProvenance(packages.find((p) => p.name === name), added).join(', ')}.\n` + - " Add the `paths` rules to the package's tsconfig.json — widening the registry entry is not the fix.\n" + - // The REASON for that refusal, in the text the author actually reads (#8576). - // Mirrors `KNOWN_DIST_RESOLVED_TYPE_IMPORTS`'s own words verbatim rather than - // restating them: one rule in two voices becomes two rules by the next reading. - ' That registry is ⛔ SHRINK-ONLY: entries are audited in both directions, so one that is no\n' + - ' longer needed fails the gate and names itself for deletion.\n' + - // #12572: that refusal is right for the case it was written for and WRONG as the last - // word, because since #11490 a package can move the program set itself. The exception is - // NAMED here and deliberately not OFFERED: the route stays a reviewed re-baseline spelled - // out over `KNOWN_DIST_RESOLVED_TYPE_IMPORTS`, not a door this message opens for an author - // who is merely red. Keeping the refusal above intact is also what keeps this gate the - // `refused` precedent `check-ratchet-remedy-authority` pins it as (#8435). - ' ⚠️ One case is not covered by that refusal: if a `(via …)` above names a program this\n' + - ' change ONBOARDED — a `tsconfig*.json` the `typecheck` script here did not name before —\n' + - ' then the program set itself moved, which since #11490 a package can do, and `paths` is\n' + - ' measured to be the wrong tool for it: on PR #12570 it billed source diagnostics from\n' + - ' other packages into this ledger. That case is a re-baseline, and it is\n' + - ' settled by the doc-block over the registry, not by this message. Read it first.\n' + - // #16000: the `paths` limb is the FIRST remedy this message names, and for a consuming - // program whose `rootDir` excludes the dependency's source it is closed too — so an author - // reading this straight through walks into a SECOND wall nothing here mentioned. The - // knowledge was in this file the whole time (the header's with-`paths` red, and the PR - // #12570 reading over the registry) and nowhere in the text the author reads; the #15978 - // round paid a full cycle for that gap, which is what #16000 records. - // - // Re-measured for this card rather than taken from the filing, on the same pair and - // through the same program: `@objectstack/runtime` -> `@objectstack/service-realtime` - // via `tsconfig.test.json`, with this message's own remedy applied to - // `packages/runtime/tsconfig.json`. That program goes from 191 errors to 204 — +13, - // ALL of them TS6059 naming `packages/services/service-realtime/src/**`, and not one - // new code error. Every other diagnostic code's count is unchanged. - // - // ⚠️ Written as a CONDITION and never as a blanket closure. `paths` remains the correct - // remedy wherever the consuming program's `rootDir` DOES contain the dependency's source, - // and a message overstating the closure would turn an author away from a route that is - // open — worse than today's silence. The condition is what was measured, not the verdict. - ' ⚠️ And `paths` is not always available — check its precondition before you take that\n' + - ' route. It puts the dependency\'s SOURCE into this program, so every file it pulls in has\n' + - ' to sit under the consuming program\'s `rootDir`. Where that `rootDir` excludes the\n' + - ' dependency (a package-local `./src`, whose `tsconfig*.json` header may say in so many\n' + - ' words that it will not widen), tsc admits those files and then reports TS6059 "is not\n' + - ' under rootDir" for that package\'s whole file graph — billed to THIS package\'s\n' + - ' test-typecheck ledger, which the package that owns the source cannot see. That is the\n' + - ' PR #12570 shape again, reached from the `paths` limb instead of the re-baseline one.\n' + - ' ⛔ In that case no self-serve remedy is left, and the honest move is to NOT take the\n' + - ' dependency: reach the subject through in-package source, or escalate. ⛔ Never widen\n' + - ' that ledger and ⛔ never widen a `rootDir` to make room — both are maintainer-only.', - ); - if (gone.length > 0) - failures.push( - `${name}: registry entry is STALE — no longer dist-resolved: ${gone.join(', ')}.\n` + - ` Narrow the entry to exactly: ${JSON.stringify(deps)}`, - ); - } - - for (const name of Object.keys(registry)) { - if (measured.has(name)) continue; - const known = packages.some((p) => p.name === name); - failures.push( - known - ? `${name}: registry entry is no longer needed — every workspace type import resolves to source now. Delete the entry.` - : `${name}: registry entry names a package with no tsconfig.json (or no such package). Delete the entry.`, - ); - } - - return { failures, packages, measured, programs: totalPrograms }; -} - -// ── reporting ─────────────────────────────────────────────────────────────── - -function printList(root) { - const { packages, totalPrograms: programs } = scan(root); - const offenders = packages.filter((p) => p.distResolved.length > 0).sort((a, b) => a.name.localeCompare(b.name)); - console.log('const KNOWN_DIST_RESOLVED_TYPE_IMPORTS = {'); - for (const pkg of offenders) { - console.log(` '${pkg.name}': [${pkg.distResolved.map((d) => `'${d}'`).join(', ')}],`); - } - console.log('};'); - console.error( - `\n${offenders.length} of ${packages.length} packages (${programs} tsc program(s)) have >=1 workspace type import ` + - `resolving through \`dist/\` (${offenders.reduce((n, p) => n + p.distResolved.length, 0)} package-dependency pairs); ` + - `${packages.length - offenders.length} are clean.`, - ); -} - -// ── self-test ─────────────────────────────────────────────────────────────── -// -// Every reader this repo has written for a resolution config has been wrong at -// least once, and always in the same shape: the instrument was validated -// against the spelling its author had in mind and not against the spelling the -// repo actually uses. So each case below pins the CORRECT form as well as the -// WRONG one — a gate that only proves it catches the defect has not shown it -// leaves compliant configs alone, and a false red on 76 packages is worse than -// the exposure. - -function fixture(root, rel, files) { - const dir = join(root, rel); - for (const [name, content] of Object.entries(files)) { - const path = join(dir, name); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, content, 'utf8'); - } - return dir; -} - -const ARTIFACT_MANIFEST = (name) => - JSON.stringify( - { name, main: 'dist/index.js', types: 'dist/index.d.ts', exports: { '.': { types: './dist/index.d.ts' } } }, - null, - 2, - ); - -function buildFixtureTree() { - const root = join(tmpdir(), `os-type-source-resolution-selftest-${process.pid}`); - rmSync(root, { recursive: true, force: true }); - mkdirSync(join(root, 'packages'), { recursive: true }); - - writeFileSync( - join(root, 'tsconfig.json'), - JSON.stringify({ compilerOptions: { strict: true }, exclude: ['node_modules'] }, null, 2), - 'utf8', - ); - - // The stale-able dependency the fixtures import, with the namespace shape - // `@objectstack/spec` really has. - fixture(root, 'packages/spec', { - 'package.json': ARTIFACT_MANIFEST('@fx/spec'), - 'src/index.ts': 'export const alive = 1;\n', - 'src/ui/index.ts': 'export const ui = 1;\n', - 'src/integration/index.ts': 'export const integration = 1;\n', - }); - - // A sibling sharing the prefix — what `@fx/spec*` also swallows. - fixture(root, 'packages/spec-tools', { - 'package.json': ARTIFACT_MANIFEST('@fx/spec-tools'), - 'src/index.ts': 'export const tools = 1;\n', - }); - - // (1) violating: imports the artifact, no `paths` at all. - fixture(root, 'packages/violator', { - 'package.json': ARTIFACT_MANIFEST('@fx/violator'), - 'tsconfig.json': JSON.stringify({ include: ['src/**/*'] }, null, 2), - 'src/thing.ts': "import { alive } from '@fx/spec';\nexport const thing = alive;\n", - }); - - // (2) compliant: the two-rule shape, exact key + `/*` key. - fixture(root, 'packages/compliant', { - 'package.json': ARTIFACT_MANIFEST('@fx/compliant'), - 'tsconfig.json': JSON.stringify( - { - compilerOptions: { - paths: { '@fx/spec': ['../spec/src/index.ts'], '@fx/spec/*': ['../spec/src/*/index.ts'] }, - }, - include: ['src/**/*'], - }, - null, - 2, - ), - 'src/thing.ts': "import { alive } from '@fx/spec';\nimport { ui } from '@fx/spec/ui';\nexport const t = alive + ui;\n", - }); - - // (3) TYPE-ONLY imports count on this axis — the inversion of the runtime - // gate's rule. A gate that inherited the type-only filter reports this - // package as clean while its declarations come from `dist`. - fixture(root, 'packages/type-only', { - 'package.json': ARTIFACT_MANIFEST('@fx/type-only'), - 'tsconfig.json': JSON.stringify({ include: ['src/**/*'] }, null, 2), - 'src/thing.ts': "import type { Alive } from '@fx/spec';\nexport type T = Alive;\n", - }); - - // (4) JSONC — the CORRECT spelling, buried in the comment style this repo - // really writes (55 of 76 tsconfigs). A reader that cannot strip comments - // reports this compliant package as unreadable. - fixture(root, 'packages/jsonc', { - 'package.json': ARTIFACT_MANIFEST('@fx/jsonc'), - 'tsconfig.json': - '{\n' + - ' "compilerOptions": {\n' + - ' // Why `paths` at all: the dep resolves through `exports` to dist,\n' + - ' // so `@fx/spec` and `@fx/spec/ui` would be a verdict about the last\n' + - ' // build. Both rules are load-bearing and fail INDEPENDENTLY.\n' + - ' "paths": {\n' + - ' "@fx/spec": ["../spec/src/index.ts"],\n' + - ' "@fx/spec/*": ["../spec/src/*/index.ts"]\n' + - ' }\n' + - ' },\n' + - ' "include": ["src/**/*"]\n' + - '}\n', - 'src/thing.ts': "import { alive } from '@fx/spec';\nimport { ui } from '@fx/spec/ui';\nexport const t = alive + ui;\n", - }); - - // ── (5) THE TRAP ────────────────────────────────────────────────────────── - // - // `@fx/spec*` — star NOT preceded by a separator. It matches the bare name, - // every namespace, AND the sibling `@fx/spec-tools`, folding all of them onto - // one target. It does not crash and it type-checks green, which is why it is - // strictly worse than the Vite `ENOTDIR` trap. Every specifier here resolves - // to a path under `src/`, so a gate that only asks "did it land on source" - // certifies this config as correct — which is the exact defect this gate - // exists to find. - fixture(root, 'packages/star-trap', { - 'package.json': ARTIFACT_MANIFEST('@fx/star-trap'), - 'tsconfig.json': JSON.stringify( - { compilerOptions: { paths: { '@fx/spec*': ['../spec/src/index.ts'] } }, include: ['src/**/*'] }, - null, - 2, - ), - 'src/thing.ts': - "import { alive } from '@fx/spec';\nimport { ui } from '@fx/spec/ui';\n" + - "import { tools } from '@fx/spec-tools';\nexport const t = alive + ui + tools;\n", - }); - - // (6) `extends`, with the `paths` living in the PARENT and relative targets - // resolved from the PARENT's directory. Correct spelling; must stay quiet. - fixture(root, 'packages/inherits', { - 'package.json': ARTIFACT_MANIFEST('@fx/inherits'), - 'base/tsconfig.base.json': JSON.stringify( - { compilerOptions: { paths: { '@fx/spec': ['../../spec/src/index.ts'] } } }, - null, - 2, - ), - 'tsconfig.json': JSON.stringify({ extends: './base/tsconfig.base.json', include: ['src/**/*'] }, null, 2), - 'src/thing.ts': "import { alive } from '@fx/spec';\nexport const t = alive;\n", - }); - - // (7) `compilerOptions` merge SHALLOWLY: a child's `paths` REPLACES the - // parent's whole map. The parent aliases `@fx/spec`; the child redeclares - // `paths` for something else, so the inherited rule is GONE — and a gate that - // deep-merged would report this package as compliant when tsc does not. - fixture(root, 'packages/shadowed', { - 'package.json': ARTIFACT_MANIFEST('@fx/shadowed'), - 'base.json': JSON.stringify({ compilerOptions: { paths: { '@fx/spec': ['./spec-shim.ts'] } } }, null, 2), - 'spec-shim.ts': 'export const alive = 1;\n', - 'tsconfig.json': JSON.stringify( - { extends: './base.json', compilerOptions: { paths: { '#internal/*': ['./src/*'] } }, include: ['src/**/*'] }, - null, - 2, - ), - 'src/thing.ts': "import { alive } from '@fx/spec';\nexport const t = alive;\n", - }); - - // (8) tsc's algorithm, not Vite's — part one: an EXACT key wins outright even - // when a pattern key is declared FIRST and also matches. Vite is - // first-match-wins, so a ported resolver sends this to `dist` and reports a - // compliant package. - fixture(root, 'packages/exact-wins', { - 'package.json': ARTIFACT_MANIFEST('@fx/exact-wins'), - 'tsconfig.json': JSON.stringify( - { - compilerOptions: { - paths: { '@fx/*': ['../spec/dist/*'], '@fx/spec': ['../spec/src/index.ts'] }, - }, - include: ['src/**/*'], - }, - null, - 2, - ), - 'src/thing.ts': "import { alive } from '@fx/spec';\nexport const t = alive;\n", - }); - - // (9) …part two: among pattern keys the LONGEST MATCHING PREFIX wins, again - // regardless of order. The short `@fx/*` is declared first and lands on - // `dist`; tsc picks `@fx/spec/*`. - fixture(root, 'packages/longest-prefix-wins', { - 'package.json': ARTIFACT_MANIFEST('@fx/longest-prefix-wins'), - 'tsconfig.json': JSON.stringify( - { - compilerOptions: { - paths: { '@fx/*': ['../spec/dist/*'], '@fx/spec/*': ['../spec/src/*/index.ts'] }, - }, - include: ['src/**/*'], - }, - null, - 2, - ), - 'src/thing.ts': "import { ui } from '@fx/spec/ui';\nexport const t = ui;\n", - }); - - // (10) a `paths` rule is a spelling, not a licence: one landing on `dist/` is - // still a dist-resolved type import. - fixture(root, 'packages/paths-to-dist', { - 'package.json': ARTIFACT_MANIFEST('@fx/paths-to-dist'), - 'tsconfig.json': JSON.stringify( - { compilerOptions: { paths: { '@fx/spec': ['../spec/dist/index.d.ts'] } }, include: ['src/**/*'] }, - null, - 2, - ), - 'src/thing.ts': "import { alive } from '@fx/spec';\nexport const t = alive;\n", - }); - - // (11) a rule whose target does not exist is not a rule — tsc silently falls - // back to node resolution. Reported as its own failure so the diagnostic - // names the typo rather than the package. - fixture(root, 'packages/missing-target', { - 'package.json': ARTIFACT_MANIFEST('@fx/missing-target'), - 'tsconfig.json': JSON.stringify( - { compilerOptions: { paths: { '@fx/spec': ['../spec/source/index.ts'] } }, include: ['src/**/*'] }, - null, - 2, - ), - 'src/thing.ts': "import { alive } from '@fx/spec';\nexport const t = alive;\n", - }); - - // (12) FALSE-POSITIVE GUARD: a package with a tsconfig and no workspace dep - // resolving through `dist/` must not appear at all. A gate that flags - // everything is as useless as one that flags nothing. - fixture(root, 'packages/no-workspace-dep', { - 'package.json': ARTIFACT_MANIFEST('@fx/no-workspace-dep'), - 'tsconfig.json': JSON.stringify({ include: ['src/**/*'] }, null, 2), - 'src/thing.ts': "import { readFileSync } from 'node:fs';\nexport const t = readFileSync;\n", - }); - - // (13) …and a dep whose own types already point at SOURCE is not an artifact. - fixture(root, 'packages/source-dep', { - 'package.json': JSON.stringify({ name: '@fx/source-dep', types: 'src/index.ts', exports: { '.': './src/index.ts' } }, null, 2), - 'src/index.ts': 'export const s = 1;\n', - }); - fixture(root, 'packages/consumes-source', { - 'package.json': ARTIFACT_MANIFEST('@fx/consumes-source'), - 'tsconfig.json': JSON.stringify({ include: ['src/**/*'] }, null, 2), - 'src/thing.ts': "import { s } from '@fx/source-dep';\nexport const t = s;\n", - }); - - // (14) `include` is a real filter: a file OUTSIDE the program cannot decide a - // type verdict, so its imports are not this package's exposure. - fixture(root, 'packages/outside-program', { - 'package.json': ARTIFACT_MANIFEST('@fx/outside-program'), - 'tsconfig.json': JSON.stringify({ include: ['src/**/*'], exclude: ['src/generated'] }, null, 2), - 'src/thing.ts': "export const t = 1;\n", - 'src/generated/gen.ts': "import { alive } from '@fx/spec';\nexport const g = alive;\n", - 'scripts/tool.ts': "import { alive } from '@fx/spec';\nexport const s = alive;\n", - }); - - // (15) `baseUrl` moves where relative targets resolve FROM. Same rule, same - // package, correct — a reader that ignores `baseUrl` calls this a missing - // target and reports a compliant package. - fixture(root, 'packages/base-url', { - 'package.json': ARTIFACT_MANIFEST('@fx/base-url'), - 'tsconfig.json': JSON.stringify( - { compilerOptions: { baseUrl: '..', paths: { '@fx/spec': ['./spec/src/index.ts'] } }, include: ['src/**/*'] }, - null, - 2, - ), - 'src/thing.ts': "import { alive } from '@fx/spec';\nexport const t = alive;\n", - }); - - // (16) fail-closed: a tsconfig this gate cannot parse is UNREADABLE, never a - // package that resolves nothing. - fixture(root, 'packages/unparseable', { - 'package.json': ARTIFACT_MANIFEST('@fx/unparseable'), - 'tsconfig.json': '{ "compilerOptions": { "paths": { "@fx/spec": ["../spec/src/index.ts"] } } /* trailing */ ,,}\n', - 'src/thing.ts': "import { alive } from '@fx/spec';\nexport const t = alive;\n", - }); - - // ── #11490: the population is per PROGRAM, and "named" is the criterion ── - // - // (17) THE CARD'S SHAPE. The build config excludes the test layer — which - // ci.yml requires, so no test file reaches the published artifact — and the - // repo's prescribed repair is a SIBLING config named in `typecheck`. The - // exposure lives ONLY in that sibling's program. Before the widening this - // package was silent. - const testLayerFiles = { - 'src/thing.ts': 'export const thing = 1;\n', - 'src/thing.test.ts': "import { alive } from '@fx/spec';\nexport const t = alive;\n", - }; - fixture(root, 'packages/sibling-config', { - 'package.json': JSON.stringify( - { - name: '@fx/sibling-config', - types: 'dist/index.d.ts', - exports: { '.': { types: './dist/index.d.ts' } }, - scripts: { typecheck: 'tsc --noEmit && tsc --noEmit -p tsconfig.test.json' }, - }, - null, - 2, - ), - 'tsconfig.json': JSON.stringify({ include: ['src/**/*'], exclude: ['**/*.test.ts'] }, null, 2), - 'tsconfig.test.json': JSON.stringify({ extends: './tsconfig.json', include: ['src/**/*'], exclude: [] }, null, 2), - ...testLayerFiles, - }); - - // (18) THE TWIN, and the two-sided acceptance test. The SAME files with the - // SAME import, reached through the BUILD config because this one keeps no - // exclusion. The card's whole finding was that (17) and (18) are the same - // exposure reported in one spelling and silent in the other, so the assertion - // is not "both are reported" but that they are reported IDENTICALLY. - fixture(root, 'packages/build-config-twin', { - 'package.json': JSON.stringify( - { - name: '@fx/build-config-twin', - types: 'dist/index.d.ts', - exports: { '.': { types: './dist/index.d.ts' } }, - scripts: { typecheck: 'tsc --noEmit' }, - }, - null, - 2, - ), - 'tsconfig.json': JSON.stringify({ include: ['src/**/*'] }, null, 2), - ...testLayerFiles, - }); - - // (19) THE FALSE-POSITIVE GUARD ON THE WIDENING ITSELF. Byte-identical to - // (17) except that NO script names the sibling. A config nothing runs decides - // no type verdict, so its imports are not this package's exposure — and a - // widening spelled as `tsconfig*.json` on disk would report this package, - // producing a gate that looks stronger while measuring a program nobody runs. - // That is worse than the state it replaced, because it READS as covered. - fixture(root, 'packages/decorative-config', { - 'package.json': JSON.stringify( - { - name: '@fx/decorative-config', - types: 'dist/index.d.ts', - exports: { '.': { types: './dist/index.d.ts' } }, - scripts: { typecheck: 'tsc --noEmit', 'some:other': 'tsc -p tsconfig.test.json' }, - }, - null, - 2, - ), - 'tsconfig.json': JSON.stringify({ include: ['src/**/*'], exclude: ['**/*.test.ts'] }, null, 2), - 'tsconfig.test.json': JSON.stringify({ extends: './tsconfig.json', include: ['src/**/*'], exclude: [] }, null, 2), - ...testLayerFiles, - }); - - return root; -} - -// Returned by `selfTest()` only after its verdict is printed. The dispatch -// refuses anything else: a `return` that leaves the function above that line -// prints nothing and still exits 0 — a self-test that never finished, reported -// as one that passed (#13798). -const SELF_TEST_VERDICT = 'check-type-source-resolution self-test reached its verdict'; - -function selfTest() { - // The battery ledger this self-test's floor is evaluated against (#13489). - // `battery()` opens a battery; every assertion below is attributed to the one - // most recently opened, so a section that stops running stops registering and - // names ITSELF at the floor rather than going quiet. - const batterySeen = new Map(); - let openBattery = null; - const battery = (name) => { - openBattery = name; - }; - const registerCase = () => { - const b = openBattery ?? UNATTRIBUTED_BATTERY; - batterySeen.set(b, (batterySeen.get(b) ?? 0) + 1); - }; - const root = buildFixtureTree(); - const problems = []; - const expect = (condition, message) => { - registerCase(); - if (!condition) problems.push(message); - }; - const has = (failures, needle) => failures.some((f) => f.includes(needle)); - const reported = (result, rel) => result.failures.some((f) => f.includes(rel)); - - try { - const bare = check(root, {}); - - // ── the defect is caught ────────────────────────────────────────────── - battery('the defect is caught'); - expect(reported(bare, 'packages/violator'), 'a package with no `paths` at all was not reported'); - expect( - reported(bare, 'packages/type-only'), - 'a TYPE-ONLY import of a dist-resolving dep was not counted — the runtime gate\'s filter must NOT be inherited', - ); - expect(reported(bare, 'packages/shadowed'), "a child `paths` block that REPLACES the parent's was deep-merged"); - expect(reported(bare, 'packages/paths-to-dist'), 'a `paths` rule landing on `dist/` was read as aliased to source'); - - // ── THE TRAP: `@fx/spec*`, star not after a separator ───────────────── - // Every specifier in this fixture lands under `src/`, so "did it reach - // source" says yes. The gate must still refuse the spelling. - battery('THE TRAP: `@fx/spec*`, star not after a separator'); - expect(reported(bare, 'packages/star-trap'), 'the `@pkg*` trap (star not preceded by a separator) was NOT flagged'); - expect( - has(bare.failures, '@fx/spec-tools'), - 'the `@pkg*` trap diagnostic did not name the sibling package the key also swallows', - ); - - // ── a rule that matches nothing is not coverage ─────────────────────── - battery('a rule that matches nothing is not coverage'); - expect(reported(bare, 'packages/missing-target'), 'a `paths` target that does not exist was accepted as a rule'); - expect(has(bare.failures, 'does not exist'), 'the missing-target diagnostic did not say the target is missing'); - - // ── the CORRECT spellings must stay quiet ───────────────────────────── - battery('the CORRECT spellings must stay quiet'); - expect(!reported(bare, 'packages/compliant'), 'the two-rule compliant config was reported'); - expect(!reported(bare, 'packages/jsonc'), 'a CORRECT config was reported because its comments were not stripped'); - expect(!reported(bare, 'packages/inherits'), 'a correct `paths` block inherited through `extends` was not seen'); - expect(!reported(bare, 'packages/base-url'), '`baseUrl` was ignored, so a correct config read as a missing target'); - expect( - !reported(bare, 'packages/exact-wins'), - "an EXACT key declared after a matching pattern key did not win — that is Vite's algorithm, not tsc's", - ); - expect( - !reported(bare, 'packages/longest-prefix-wins'), - 'the LONGEST matching prefix did not win among pattern keys — first-match-wins is Vite, not tsc', - ); - - // ── false positives ─────────────────────────────────────────────────── - battery('false positives'); - expect(!reported(bare, 'packages/no-workspace-dep'), 'a package with no workspace dep at all was flagged'); - expect(!reported(bare, 'packages/consumes-source'), 'a dep whose types already point at source was flagged'); - expect( - !reported(bare, 'packages/outside-program'), - '`include`/`exclude` were ignored, so a file outside the program decided the verdict', - ); - - // ── fail-closed ─────────────────────────────────────────────────────── - battery('fail-closed'); - expect(reported(bare, 'packages/unparseable'), 'an unparseable tsconfig was read as resolving nothing'); - expect(has(bare.failures, 'cannot be read'), 'an unparseable tsconfig did not fail as unreadable'); - - // ── #11490: the population is per PROGRAM ───────────────────────────── - // - // The card's own measurement, in fixture form and TWO-SIDED. "The gate - // still passes" was explicitly not the test: the same files with the same - // import were REPORTED through the build config and SILENT through the - // prescribed sibling, so what has to hold is that the two spellings now - // report the SAME THING. - battery('#11490: the population is per PROGRAM'); - expect( - reported(bare, 'packages/sibling-config'), - 'an exposure reachable only through the sibling `tsconfig.test.json` this repo PRESCRIBES was not ' - + 'reported — the gate is per-package again where it must be per-program', - ); - expect(reported(bare, 'packages/build-config-twin'), 'the build-config spelling of the identical files was not reported'); - expect( - JSON.stringify(bare.measured.get('@fx/sibling-config')) === - JSON.stringify(bare.measured.get('@fx/build-config-twin')), - 'the sibling-config spelling and the build-config spelling of the SAME files with the SAME import did ' - + `not report identically: ${JSON.stringify(bare.measured.get('@fx/sibling-config'))} vs ` - + `${JSON.stringify(bare.measured.get('@fx/build-config-twin'))}`, - ); - expect( - JSON.stringify(bare.measured.get('@fx/sibling-config')) === JSON.stringify(['@fx/spec']), - 'the sibling-config exposure was not measured as exactly the dep the test file imports', - ); - // The widening's own false positive: a config on disk that no script runs. - expect( - !reported(bare, 'packages/decorative-config'), - 'a `tsconfig*.json` NO script names was put in the population — a gate that looks stronger while ' - + 'measuring a program nobody runs reads as covered and is worse than the state it replaced', - ); - // The diagnostic must say WHICH program, or the author opens the build - // config, finds no such import, and concludes the gate is wrong. - expect( - has(bare.failures, '(via tsconfig.test.json)'), - 'the diagnostic did not name the program the exposure was reached through', - ); - - // ── the registry, audited in BOTH directions ────────────────────────── - battery('the registry, audited in BOTH directions'); - const measuredNames = { - '@fx/violator': ['@fx/spec'], - '@fx/type-only': ['@fx/spec'], - '@fx/shadowed': ['@fx/spec'], - '@fx/paths-to-dist': ['@fx/spec'], - '@fx/star-trap': ['@fx/spec'], - '@fx/missing-target': ['@fx/spec'], - // #11490 — both spellings of the same exposure, registered identically. - '@fx/sibling-config': ['@fx/spec'], - '@fx/build-config-twin': ['@fx/spec'], - }; - const registered = check(root, measuredNames); - expect( - !has(registered.failures, 'resolve to `dist/` with no'), - 'correctly registered packages still failed the unregistered check', - ); - // Registration silences the REGISTRY complaint and nothing else: the trap - // and the dead rule are config defects, not measured state to grandfather. - expect(reported(registered, 'packages/star-trap'), 'registering a package suppressed the `@pkg*` trap failure'); - expect(reported(registered, 'packages/missing-target'), 'registering a package suppressed the dead-rule failure'); - - const stale = check(root, { ...measuredNames, '@fx/compliant': ['@fx/spec'] }); - expect(has(stale.failures, 'no longer needed'), 'a registry entry for an already-fixed package did not fail'); - - const ghost = check(root, { ...measuredNames, '@fx/ghost': ['@fx/spec'] }); - expect(has(ghost.failures, '@fx/ghost'), 'a registry entry for a non-existent package did not fail'); - - fixture(root, 'packages/other', { - 'package.json': ARTIFACT_MANIFEST('@fx/other'), - 'src/index.ts': 'export const other = 1;\n', - }); - fixture(root, 'packages/violator', { - 'src/second.ts': "import { other } from '@fx/other';\nexport const s = other;\n", - }); - const grown = check(root, measuredNames); - expect(has(grown.failures, 'NEW dist-resolved type import'), 'a new dist-resolved import under an entry did not fail'); - // #8576. The refusal above turns the registry remedy down; this pins that it - // also says WHY, in the text the author reads. Asserted on the planted - // violation, never on a green run — the string only ever prints on failure. - expect( - has(grown.failures, '⛔ SHRINK-ONLY'), - 'the refusal no longer states WHY it refuses — the registry\'s shrink-only nature is back to being ' - + 'comment-only, which tells the maintainer reading the script and not the author tripping the gate', - ); - - // #12572. The same failure carries TWO more load-bearing sentences, and they - // pull against each other on purpose — which is exactly why both need pinning - // from this side rather than being left to a reader's judgement. - // - // THE REFUSAL is a cross-gate contract. `check-ratchet-remedy-authority` - // hand-classifies this file as `refused` (#8435), and it earns that verdict - // from this literal: its PREDICATION shape ("widening … is not the fix") is - // what tells a refusal apart from mere discouragement. Soften this sentence - // and this gate silently becomes an UNMARKED offer of a ratchet-expanding - // remedy — a violation reported over THERE, naming a file whose author was - // editing prose here and had no reason to look. - // - // THE EXCEPTION is what keeps the refusal honest. Since #11490 a package CAN - // move the program set, so an unqualified refusal forbids the only correct - // action for an onboarding — the state #11491 measured 14 remaining - // `TEST_DEBT` entries as walking into. - // - // Neither may be dropped to make room for the other. - expect( - has(grown.failures, 'widening the registry entry is not the fix'), - 'the refusal PREDICATION is gone — `check-ratchet-remedy-authority` classifies this file as ' - + '`refused` from that exact shape, so losing it turns this gate into an unmarked ratchet offer ' - + 'and reds a DIFFERENT gate, naming this file', - ); - expect( - has(grown.failures, 'ONBOARDED'), - 'the failure text no longer names the one case the refusal does not cover — a package that moved ' - + 'the program set itself, which #11490 made possible and 14 queued onboardings each arrive at', - ); - - // #16000. `paths` is the FIRST remedy this message names, and it has a - // precondition the message did not state: the consuming program's `rootDir` - // must contain the dependency's source. Where it does not, that route ends in - // TS6059 billed to a ledger the owning package cannot see — measured by the - // #15978 round, which spent a full cycle discovering it, and re-measured in the - // PR for this card. Two pins, because the halves fail independently: the - // DIAGNOSTIC an author meets on that route, and the fact that what would clear - // it is not a dev seat's to take — a message naming neither recommends a route - // and then names no remaining self-serve option when it closes. - expect( - has(grown.failures, 'TS6059'), - 'the failure text no longer names the diagnostic the `paths` remedy produces when the consuming ' - + "program's `rootDir` excludes the dependency's source — the message then recommends a route " - + 'whose second wall is silent from here, which is the cycle #16000 recorded', - ); - expect( - has(grown.failures, 'maintainer-only'), - 'the failure text no longer says that the routes past that wall are closed to a dev seat — ' - + 'without it this message names `paths` first and names NO remaining self-serve option for ' - + 'the case where `paths` is unavailable, which is the whole of #16000', - ); - - const wide = check(root, { ...measuredNames, '@fx/violator': ['@fx/spec', '@fx/other', '@fx/gone'] }); - expect(has(wide.failures, 'STALE'), 'a registry entry listing a dep that is no longer dist-resolved did not fail'); - - // ── census guard: sibling-config discovery going quiet is INVISIBLE ──── - // - // The zero this leg exists for needs its own positive control: a tree in - // which no package runs a second program must trip it, or the guard is a - // line nobody has ever seen fire. Same shape as (17) minus the `typecheck` - // script that names the sibling — which is also exactly what the whole - // widening looks like after a regression. - battery('census guard: sibling-config discovery going quiet is INVISIBLE'); - const singleProgram = join(tmpdir(), `os-type-source-resolution-single-${process.pid}`); - rmSync(singleProgram, { recursive: true, force: true }); - mkdirSync(join(singleProgram, 'packages'), { recursive: true }); - fixture(singleProgram, 'packages/spec', { - 'package.json': ARTIFACT_MANIFEST('@fx/spec'), - 'src/index.ts': 'export const alive = 1;\n', - }); - fixture(singleProgram, 'packages/lonely', { - 'package.json': ARTIFACT_MANIFEST('@fx/lonely'), - 'tsconfig.json': JSON.stringify({ include: ['src/**/*'] }, null, 2), - 'src/thing.ts': "import { alive } from '@fx/spec';\nexport const t = alive;\n", - }); - const single = check(singleProgram, { '@fx/lonely': ['@fx/spec'] }); - expect( - has(single.failures, 'sibling-config discovery is broken'), - 'a tree where no package runs a second tsc program did not trip the census guard — the #11490 widening ' - + 'can go quiet and the summary still reads like a clean repo', - ); - rmSync(singleProgram, { recursive: true, force: true }); - // ...and it must NOT fire on the real fixture tree, where (17) runs two. - expect( - !has(bare.failures, 'sibling-config discovery is broken'), - 'the multi-program census guard fired on a tree that does have a second program', - ); - - // The shared `typecheck`-script predicate is a plain module with no CI - // invocation of its own; the gates that consolidated onto it fold in its - // cases (#11490). - for (const failure of typecheckConfigsSelfTest()) expect(false, failure); - - // ── census guard: an empty tree is a broken scanner, never a clean repo ─ - const empty = join(tmpdir(), `os-type-source-resolution-empty-${process.pid}`); - rmSync(empty, { recursive: true, force: true }); - mkdirSync(empty, { recursive: true }); - const emptyResult = check(empty, {}); - expect(has(emptyResult.failures, 'the scan is broken'), 'an empty tree did not trip the census guard'); - rmSync(empty, { recursive: true, force: true }); - - // ── the declared population must stay READABLE by the dispatch deriver ─ - // - // scripts/pm/dispatch-gates.mjs decides which cards are told to run this - // gate by scanning this file's module body for the path literals it - // operates on, and its covering rule refuses a literal carrying no path - // separator (after the leading ./ or ../ an extractor strips) as too - // generic. WORKSPACE_PARENT_GLOBS is this gate's WHOLE declared - // population, so an entry that loses its separator takes every package - // under that parent out of the derived gate list SILENTLY: the gate keeps - // working, CI keeps failing on it, and no dispatch brief sends anyone - // here. That is what the bare spelling cost, measured in that constant's - // docblock (#9955). Asserted here rather than left to review because the - // regression is a tidy-up nobody would flag. - for (const glob of WORKSPACE_PARENT_GLOBS) { - expect( - glob.replace(/^(?:\.\.?(?:\/|$))+/, '').includes('/'), - `workspace parent ${glob} carries no path separator, so scripts/pm/dispatch-gates.mjs refuses it as too generic and every package under it drops out of the derived gate list`, - ); - } - - // ── the import clause is bounded to ONE statement (#12555) ──────────── - // - // The same three rows the sibling gate pins, because this file carried the - // byte-identical first alternative. The consequence here is narrower — - // `extractTypeImports` reads only the specifier captures, so an over-greedy - // clause could not corrupt a verdict, only make one vanish — but vanishing is - // a false GREEN on an axis whose whole job is fail-closed, so it is pinned - // just as hard. A detector that silently stops matching reports a spotless - // repo. - battery('the import clause is bounded to ONE statement (#12555)'); - const typeSpecs = (code) => [...new Set(extractTypeImports(code))].sort(); - const tA = typeSpecs("import 'pkg/kernel';\nconst x = 1;\n"); - const tB = typeSpecs("import 'pkg/kernel';\nimport { X } from 'other';\n"); - const tC = typeSpecs("import { X } from 'other';\nimport 'pkg/kernel';\n"); - expect(tA.join() === 'pkg/kernel', 'row A: a lone side-effect import was not seen at all'); - expect( - tB.join() === 'other,pkg/kernel', - 'row B: the side-effect import was swallowed by the NEXT statement\'s clause — the #12555 defect itself', - ); - expect( - tB.join() === tC.join(), - 'rows B/C disagree: the same two statements read differently when reordered, so the verdict is a function of import ORDER', - ); - expect( - typeSpecs("import 'pkg/kernel'\nimport { X } from 'other'\n").join() === 'other,pkg/kernel', - 'a semicolon-less (ASI) pair still spans two statements — a `;`-only clause class would pass row B and fail here', - ); - // This gate's INVERSION of the runtime gate's rule must survive the bound: a - // type-only import always resolves at type time and is counted here on - // purpose (header note 4). The narrowed clause must not have dropped it. - expect( - typeSpecs("import type { A } from '@fx/types';\n").join() === '@fx/types', - 'a type-only import stopped being counted — the statement bound went blind on the majority of this axis', - ); - expect( - typeSpecs('import {\n a,\n b,\n} from \'@fx/multi\';\n').join() === '@fx/multi', - 'a multi-line import clause stopped matching — the statement bound broke line-spanning clauses', - ); - // The dynamic-import alternative is NOT the one that changed, and its extra - // `:` / `<` delimiters are why this regex is not byte-identical to the - // sibling's. Pinned so a later "sync the two gates" edit cannot quietly drop - // them. - expect( - typeSpecs('let v: import(\'@fx/anno\').T;\n').join() === '@fx/anno', - '`import(…)` in a type ANNOTATION stopped being seen — the `:` delimiter was lost from the dynamic alternative', - ); - expect( - typeSpecs('type G = Box;\n').join() === '@fx/generic', - '`import(…)` inside a GENERIC argument stopped being seen — the `<` delimiter was lost from the dynamic alternative', - ); - - // ── the declaration must still BE the workspace (#11510) ────────────── - // - // WORKSPACE_PARENT_GLOBS is a hand-written copy of the `packages:` block, - // and the case above only checks each entry's SHAPE. Nothing checked that - // the entries were still the right ones. Two gates carried byte-identical - // 11-entry copies of this array with the same blind spot in both, which is - // exactly what a hand-maintained mirror does: a workspace root added to - // pnpm-workspace.yaml leaves both walking the old set, both green, and no - // dispatch brief naming either gate for the new root. - // - // The array is NOT replaced by the live parse. It is this gate's declared - // population, the only thing that tells scripts/pm/dispatch-gates.mjs which - // cards belong here, and a runtime parse spells no literal at all — the - // #11190 measurement that made consolidation safe in the first place. So - // the declaration stays and the live parse becomes its CHECK, in both - // directions, the shape check-published-files.mjs already uses. - battery('the declaration must still BE the workspace (#11510)'); - const declaredParents = WORKSPACE_PARENT_GLOBS.map((g) => g.replace(/\/\*+$/, '')); - const liveParents = readWorkspaceGlobs(REPO_ROOT) - .filter((g) => !isExclusionGlob(g)) - .map((g) => g.replace(/\/\*+$/, '')); - for (const parent of liveParents) { - expect( - declaredParents.includes(parent), - `pnpm-workspace.yaml declares the workspace root ${parent}, which WORKSPACE_PARENT_GLOBS does not — this gate walks it but no card is dispatched here for it`, - ); - } - for (const parent of declaredParents) { - expect( - liveParents.includes(parent), - `WORKSPACE_PARENT_GLOBS declares ${parent}, which pnpm-workspace.yaml does not — a declaration that can drift from the workspace is worse than none, it replaces a silent gate with a lying one`, - ); - } - - // The shared enumerator is a plain module with no CI invocation of its own - // (#11510 — being a gate is exactly what it must not be); every script that - // consolidated onto it folds in its checks. - for (const failure of workspaceEnumeratorSelfTest({ root: REPO_ROOT })) expect(false, failure); - for (const failure of workspaceEnumeratorFloorFailures()) expect(false, failure); - } finally { - rmSync(root, { recursive: true, force: true }); - } - - // ── The floor: every declared battery RAN, and ran its cases (#13489) ──── - // - // Evaluated after every battery has had its chance and BEFORE the verdict, so - // the success line below can only be printed by a run in which the set of - // batteries that registered assertions EQUALS the set declared. A set - // difference names WHICH battery stopped; a count says only that something did. - const floorFailure = (message) => { problems.push(message); }; - const declaredBatteries = Object.keys(SELF_TEST_BATTERIES); - let floorBreached = false; - if (declaredBatteries.length < SELF_TEST_BATTERY_FLOOR) { - floorBreached = true; - floorFailure( - `SELF_TEST_BATTERIES declares ${declaredBatteries.length} batteries, below the pinned ` + - `${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`, - ); - } - for (const [name, count] of batterySeen) { - if (declaredBatteries.includes(name)) continue; - floorBreached = true; - floorFailure( - `self-test battery "${name}" registered ${count} case(s) but is not declared in ` + - 'SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.', - ); - } - for (const name of declaredBatteries) { - const count = batterySeen.get(name) ?? 0; - if (count >= SELF_TEST_BATTERIES[name]) continue; - floorBreached = true; - floorFailure( - count === 0 - ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. ` + - 'The verdict below would have claimed those cases hold.' - : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ` + - `${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`, - ); - } - if (floorBreached) { - floorFailure( - 'A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not the ' + - 'number. Find what stopped registering (an early return, a deleted block, a guard that now ' + - 'skips) and restore it.', - ); - } - - if (problems.length > 0) { - console.error('check-type-source-resolution --self-test FAILED:'); - for (const problem of problems) console.error(` - ${problem}`); - process.exit(1); - } - console.log('check-type-source-resolution --self-test OK'); - - return SELF_TEST_VERDICT; -} - -// ── entry point ───────────────────────────────────────────────────────────── - -const argv = process.argv.slice(2); -if (argv.includes('--self-test')) { - if (selfTest() !== SELF_TEST_VERDICT) { - console.error( - '\n✗ check-type-source-resolution self-test: selfTest() returned without reaching its verdict,\n' - + 'so no success line was printed. Exiting 0 here would report a self-test\n' - + 'that never finished as a self-test that passed.\n', - ); - process.exit(1); - } -} else if (argv.includes('--list')) { - printList(REPO_ROOT); -} else { - const { failures, packages, measured, programs } = check(REPO_ROOT, KNOWN_DIST_RESOLVED_TYPE_IMPORTS); - if (failures.length > 0) { - console.error('check-type-source-resolution FAILED\n'); - for (const failure of failures) console.error(` ✗ ${failure}\n`); - console.error( - "A package's types must be a verdict about the source in the checkout. See this file's header for\n" + - 'why the dangerous case is a typecheck that PASSES.', - ); - process.exit(1); - } - console.log( - `check-type-source-resolution OK — ${programs} tsc program(s) across ${packages.length} packages scanned ` + - `(every \`tsconfig*.json\` each package's \`typecheck\` script names, #11490); ` + - `${measured.size} registered as still resolving a workspace dep's types through \`dist/\`.`, - ); -} From 13b2b68efa977e0c74ab427d9e97cb0f3792716b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 06:56:40 +0000 Subject: [PATCH 2/2] chore(scripts): drop the retired gate from the tooling prose that named it as standing Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- docs/audits/gate-census-2026-09.md | 9 ++--- scripts/check-type-check-coverage.mjs | 43 ++++++++++++++---------- scripts/check-undeclared-dep-imports.mjs | 11 +++--- scripts/typecheck-configs.mjs | 40 +++++++++++++--------- scripts/workspace-enumerator.mjs | 6 ++-- 5 files changed, 64 insertions(+), 45 deletions(-) diff --git a/docs/audits/gate-census-2026-09.md b/docs/audits/gate-census-2026-09.md index fc5978f2193..d27efcb28c5 100644 --- a/docs/audits/gate-census-2026-09.md +++ b/docs/audits/gate-census-2026-09.md @@ -18,7 +18,7 @@ Zod validation inside `packages/spec` and the runtime is the **product**, not a | verdict | rows | |:--|--:| -| `keep` | 133 | +| `keep` | 132 | | `retire` | 58 | | `report-only` | 16 | | `keep (session hook; see Method)` | 5 | @@ -26,8 +26,9 @@ Zod validation inside `packages/spec` and the runtime is the **product**, not a | `keep (with its hook)` | 5 | | `keep (ruled)` | 2 | | `retire (ruled)` | 1 | -| **retire (all spellings)** | **59** | -| **keep (all spellings)** | **150** | +| `retire · maintainer ruling 2026-09-18 on #18373` | 1 | +| **retire (all spellings)** | **60** | +| **keep (all spellings)** | **149** | | **report-only** | **16** | | class | rows | @@ -212,7 +213,7 @@ Zod validation inside `packages/spec` and the runtime is the **product**, not a | `scripts/check-tier-file-adoption.mjs` | correctness | a package adopting a nightly tier reads the tier file · origin no card in header · landed 2026-09-10 | required · whole-repo (Lint & Repo Gates) | unmeasured (landed 2026-09-10; <7 days of window coverage) | `keep` | | `scripts/check-turbo-task-graph.mjs` | correctness | turbo.json package tasks name real packages and scripts · origin #12046 · landed 2026-08-25 | required · whole-repo (Lint & Repo Gates) | 0 in window | `keep` | | `scripts/check-type-check-coverage.mjs` | correctness | every package is type-check covered or in the shrink-only DEBT ledger · origin #4311 · landed 2026-07-31 | required · whole-repo (TypeScript Type Check ← debt lane); required · whole-repo (TypeScript Type Check ← source lane) | 20 PRs / 11 days (2026-08-21..09-03; `--re-measure` debt-ledger lane) | `keep` | -| `scripts/check-type-source-resolution.mjs` | correctness | a package's types resolve from source, not stale dist · origin #7849 · landed 2026-08-12 | required · whole-repo (Lint & Repo Gates) | 3 PRs / 3 days (2026-08-20..2026-09-16); 1 self excluded | `keep` | +| `scripts/check-type-source-resolution.mjs` | correctness | a package's types resolve from source, not stale dist · origin #7849 · landed 2026-08-12 | required · whole-repo (Lint & Repo Gates) | 3 PRs / 3 days (2026-08-20..2026-09-16); 1 self excluded | `retire · maintainer ruling 2026-09-18 on #18373` | | `scripts/check-undeclared-dep-imports.mjs` | contract | published `src/**` imports only declared workspace deps · origin #10062 · landed 2026-08-28 | required · whole-repo (Lint & Repo Gates) | 0 in window | `keep` | | `scripts/check-vendor-export-contract.mjs` | contract | a consumer-resolved vendor version exports what we import · origin #10909 · landed 2026-09-07 | required · whole-repo (Lint & Repo Gates); advisory (Validate Package Depen) | 0 in window | `keep` | | `scripts/check-vendor-version-stamps.mjs` | process | none (prose version stamps about a pinned vendor stay equal to the pin) · docs · origin #10073 · landed 2026-08-31 | required · whole-repo (Lint & Repo Gates) | 0 in window | `retire` | diff --git a/scripts/check-type-check-coverage.mjs b/scripts/check-type-check-coverage.mjs index e6f383e7b88..9f550119a0f 100644 --- a/scripts/check-type-check-coverage.mjs +++ b/scripts/check-type-check-coverage.mjs @@ -533,10 +533,14 @@ import { workspaceEnumeratorFloorFailures, workspacePackageDirs, } from './workspace-enumerator.mjs'; -// `typecheck`-script -> tsconfig program set. Shared with -// `check-type-source-resolution.mjs` since #11490, which needs the identical -// answer to decide its POPULATION: two copies of this predicate drift, and the -// symptom of drift is a green gate on either side. +// `typecheck`-script -> tsconfig program set. It moved into its own module in +// #11490, when `check-type-source-resolution.mjs` needed the identical answer +// to decide its POPULATION; that gate was retired under the maintainer ruling +// of 2026-09-18 on #18373, so this file is the only consumer left. ⛔ Folding +// the predicate back in here is a separate decision, not a consequence of that +// retirement -- it floors its own cases in its own battery (PR #15327), which +// this file deliberately does not re-pin. Two copies of this predicate drift, +// and the symptom of drift is a green gate on either side. import { configsNamedByTypecheck, typecheckScriptChain, @@ -4510,12 +4514,13 @@ function graduationRemedy({ ledger, isRoot = false }) { ` (a) The #5286 sibling route: add a \`tsconfig.test.json\` that reaches the ` + `tests and NAME it in the \`typecheck\` script. Always available -- it leaves \`tsconfig.json\` alone.\n` + ` (b) Drop the \`**/*.test.ts\` entry from \`exclude\` in \`tsconfig.json\` (or widen \`include\` to ` + - `reach the test tree). Available ONLY while \`pnpm check:type-source-resolution\` still passes with ` + - `the tests re-admitted: that gate reads \`tsconfig.json\` and nothing else, the re-admitted tests ` + - `import workspace packages this package's src program never held, and its registry is ⛔ SHRINK-ONLY ` + - `-- registering the new ones is not the way out. Measured red on 14 of the 18 entries that have an ` + - `exclusion to drop, so assume (b) is unavailable until that gate says otherwise. Run it before you ` + - `commit; nothing in this gate's own verdict will tell you.` + `reach the test tree). ⛔ The gate that DECIDED whether this route was available, ` + + `\`check:type-source-resolution\`, was RETIRED under the maintainer ruling of 2026-09-18 on #18373 ` + + `-- so nothing measures it for you any more, and ⛔ its silence is not a clearance. What it measured ` + + `has not changed: the re-admitted tests import workspace packages this package's src program never ` + + `held, its registry was ⛔ SHRINK-ONLY so registering the new ones was never the way out, and it read ` + + `red on 14 of the 18 entries that have an exclusion to drop. Treat (b) as the worse route and prefer ` + + `(a), which leaves \`tsconfig.json\` alone; nothing in this gate's own verdict will tell you.` ); } if (ledger === 'DEBT') { @@ -5620,9 +5625,11 @@ function selfTest() { // The observation half is where the :267 blind spot lived: `excludesTests` // read only `tsconfig.json`, so a sibling test config was invisible however // it was wired. `configsNamedByTypecheck` and `typecheckScriptChain` now - // decide it, and since #11490 they live in `scripts/typecheck-configs.mjs` - // because `check-type-source-resolution.mjs` needs the same answer for its - // population. Their cases moved WITH them -- one rule, one home, one battery + // decide it, and since #11490 they live in `scripts/typecheck-configs.mjs`, + // where they moved because `check-type-source-resolution.mjs` needed the same + // answer for its population -- that gate was retired on 2026-09-18 (#18373), + // leaving this file its only consumer. Their cases moved WITH them -- one + // rule, one home, one battery // -- and are folded in here so this gate still fails when the predicate it // depends on breaks. // @@ -6398,13 +6405,15 @@ function selfTest() { + 'that remedy is a no-op on every one of them -- the misfire #11491 was filed on.', }, { - label: 'TEST_DEBT graduation names the gate that DECIDES whether the exclusion route is available', + label: 'TEST_DEBT graduation still carries what the retired gate measured about the exclusion route', message: testDebtGrad, present: ['check:type-source-resolution', 'SHRINK-ONLY', 'tsconfig.test.json'], absent: [], - why: 'the exclusion route reds that gate on 14 of the 18 entries that have an exclusion, and this ' - + 'gate never runs it. A message the author has to read a second gate\'s SOURCE to act on is the ' - + 'half of #11491 that a correct-but-terse rewrite would leave unfixed.', + why: 'the exclusion route read red on 14 of the 18 entries that have an exclusion, and this gate ' + + 'never ran it. A message the author has to read a second gate\'s SOURCE to act on is the half ' + + 'of #11491 that a correct-but-terse rewrite would leave unfixed -- and once that gate was ' + + 'retired (2026-09-18, #18373) the measurement is the ONLY thing left warning the author, so ' + + 'these needles stay exactly as they were.', }, { label: 'the workspace root graduates through `typecheck:root`, never through `typecheck`', diff --git a/scripts/check-undeclared-dep-imports.mjs b/scripts/check-undeclared-dep-imports.mjs index 04e13d7dbe5..8794dbf0a01 100644 --- a/scripts/check-undeclared-dep-imports.mjs +++ b/scripts/check-undeclared-dep-imports.mjs @@ -37,11 +37,12 @@ * rather than externalise. * * Nothing in the tree asserted the invariant in either direction. There is no - * `import/no-extraneous-dependencies` rule in `eslint.config.mjs`, and the two - * gates that look adjacent answer a DIFFERENT question: - * `check:type-source-resolution` and `check:test-source-alias` decide WHICH - * copy of a dependency's types is resolved, never WHETHER the dependency is - * declared. + * `import/no-extraneous-dependencies` rule in `eslint.config.mjs`, and the gate + * that looks adjacent answers a DIFFERENT question: `check:test-source-alias` + * decides WHICH copy of a dependency's types is resolved, never WHETHER the + * dependency is declared. (Its type-axis sibling `check:type-source-resolution` + * answered the same different question until it was retired on 2026-09-18 — + * #18373.) * * ## What counts as DECLARED * diff --git a/scripts/typecheck-configs.mjs b/scripts/typecheck-configs.mjs index ff0026a71bf..e308cbe6780 100644 --- a/scripts/typecheck-configs.mjs +++ b/scripts/typecheck-configs.mjs @@ -9,12 +9,14 @@ * * ## Why this is a shared module and not a copy in each gate * - * Two gates need this predicate and they need the SAME one: + * TWO gates needed this predicate and needed the SAME one. One of them is gone; + * the module is not, and the reason it exists is worth keeping in front of + * whoever considers folding it back in: * * * `check-type-check-coverage.mjs` asks it to decide which programs * ACCOUNT for a package's files -- a config no script invokes reads as - * coverage and delivers none (#5286). - * * `check-type-source-resolution.mjs` asks it to decide which programs are + * coverage and delivers none (#5286). This is the ONE live consumer. + * * `check-type-source-resolution.mjs` asked it to decide which programs were * in its POPULATION at all. That gate read each package's `tsconfig.json` * and only that one, so the sibling `tsconfig.test.json` this repo * PRESCRIBES as the supported repair for a hidden test layer was a whole @@ -22,11 +24,16 @@ * `packages/triggers/trigger-record-change`: the same 7 test files with the * same four dist-resolved type imports were REPORTED when put through the * build config and SILENT through the prescribed sibling -- so following - * the house pattern was what made the exposure invisible. + * the house pattern was what made the exposure invisible. ⛔ RETIRED under + * the maintainer ruling of 2026-09-18 on #18373; the measurement above is + * why the predicate is shaped the way it is and outlives the gate. * - * A second copy of the regex is how those two answers drift apart, and the - * symptom of drift is a green gate on either side. One rule, one home, one set - * of cases -- the shape `workspace-enumerator.mjs` and `invoked-as.mjs` use. + * A second copy of the regex is how two answers drift apart, and the symptom of + * drift is a green gate on either side. One rule, one home, one set of cases -- + * the shape `workspace-enumerator.mjs` and `invoked-as.mjs` use. ⛔ Collapsing + * this back into its remaining caller is a decision of its own, not a + * consequence of the retirement: its cases are floored in THIS file's dispatch + * (PR #15327) and a fold-in does not inherit that floor. * * ## What the answer IS, and the one property a consumer must handle * @@ -35,7 +42,7 @@ * reference written with a directory (`-p ../shared/tsconfig.test.json`) is * credited under its BASENAME as though it named the package's own file. * - * That is a property, not a bug to route around here: both consumers resolve + * That is a property, not a bug to route around here: the consumer resolves * the answer against the package directory, so a name with no file behind it * is dropped. It is stated out loud because the residual case is real -- a * package that BOTH reaches for a config in another directory AND carries a @@ -43,8 +50,9 @@ * while #11490 was implemented: 0 of the workspace's package.json files * reference any tsconfig with a directory prefix, so the case has no instance * today. Do not "fix" it by loosening the class to admit `/` without deciding - * what a config OUTSIDE the package means to each caller -- the two callers do - * not want the same thing there. + * what a config OUTSIDE the package means to the caller -- when there were two + * callers they did not want the same thing there, which is why this was never + * settled in passing. */ import { isEntrypoint } from './invoked-as.mjs'; @@ -199,12 +207,12 @@ const CHAIN_CASES = [ // This is the class-3 placement PR #15309 settled. // // ⚠️ This module is a LIBRARY: `check-type-check-coverage.mjs` folds this -// `selfTest()` into its own, and `check-type-source-resolution.mjs` imports the -// predicates. Those importers call `selfTest()`, which registers into the -// ledger below — harmlessly, because the FLOOR is evaluated only in this file's -// own `--self-test` dispatch, which an importer never reaches. Scoping the check -// to the dispatch is what keeps a fold-in from inheriting a refusal it cannot -// act on. +// `selfTest()` into its own. (`check-type-source-resolution.mjs` imported the +// predicates too, until it was retired on 2026-09-18 — #18373.) An importer +// calls `selfTest()`, which registers into the ledger below — harmlessly, +// because the FLOOR is evaluated only in this file's own `--self-test` +// dispatch, which an importer never reaches. Scoping the check to the dispatch +// is what keeps a fold-in from inheriting a refusal it cannot act on. // // ⛔ The floor is NOT placed at the end of `selfTest()` before its `return`: an // early return anywhere above that line would skip the check entirely — the diff --git a/scripts/workspace-enumerator.mjs b/scripts/workspace-enumerator.mjs index 3da68d894a8..afcd2173bfc 100644 --- a/scripts/workspace-enumerator.mjs +++ b/scripts/workspace-enumerator.mjs @@ -62,9 +62,9 @@ * * The consequence for a reader: each gate keeps declaring its OWN population in * its OWN module body — `ROOT_DIR_WATCH_HINTS` in check-published-files.mjs, - * `WORKSPACE_PARENT_GLOBS` in check-test-source-alias.mjs and - * check-type-source-resolution.mjs. What is consolidated here is the PARSE, - * never the DECLARATION. Those are two different things and the measurement + * `WORKSPACE_PARENT_GLOBS` in check-test-source-alias.mjs (and, until its + * retirement on 2026-09-18 — #18373, in check-type-source-resolution.mjs). + * What is consolidated here is the PARSE, never the DECLARATION. Those are two different things and the measurement * above is why they cannot share a module. * * ## How the divergences are settled, case by case