diff --git a/scripts/check-ci-filter-parity.mjs b/scripts/check-ci-filter-parity.mjs index daf4472a47..75c979a5e8 100644 --- a/scripts/check-ci-filter-parity.mjs +++ b/scripts/check-ci-filter-parity.mjs @@ -99,11 +99,18 @@ * * ## Why it reads the table instead of holding a copy * - * `CROSS_PACKAGE_TEST_INPUTS` is imported from the sibling gate, which exports it - * for this purpose. A copy here would be a second list of the declarations kept - * in step by hand -- exactly the defect this gate exists to close, one file - * further out. Importing that module runs nothing: its dispatch is behind - * `isEntrypoint`, and its own `--self-test` spawns a real child to pin that. + * `CROSS_PACKAGE_TEST_INPUTS` is imported from `scripts/cross-package-test-inputs.mjs`, + * the plain module that declares it. A copy here would be a second list of the + * declarations kept in step by hand -- exactly the defect this gate exists to + * close, one file further out. + * + * That module is a plain module and not the sibling GATE, which is a difference + * this gate is the beneficiary of (#11511). The table used to live inside + * `check-cross-package-test-inputs.mjs`, and `scripts/pm/dispatch-gates.mjs` + * follows a gate's first-party imports one level but never into a file that is + * itself a discovered gate -- so the dispatch derivation named this gate for + * NOTHING the table declares, though the table is precisely this gate's + * population. Measured on 589758d22: 1 (gate, file) pair before, 3253 after. * * ## Wiring * @@ -123,7 +130,7 @@ import process from 'node:process'; import { requireDependency } from './import-prerequisite.mjs'; const { parse } = await requireDependency('yaml', () => import('yaml'), import.meta.url); -import { CROSS_PACKAGE_TEST_INPUTS } from './check-cross-package-test-inputs.mjs'; +import { CROSS_PACKAGE_TEST_INPUTS } from './cross-package-test-inputs.mjs'; import { isEntrypoint } from './invoked-as.mjs'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); diff --git a/scripts/check-cross-package-test-inputs.mjs b/scripts/check-cross-package-test-inputs.mjs index 3de8e38ce6..2566c26164 100644 --- a/scripts/check-cross-package-test-inputs.mjs +++ b/scripts/check-cross-package-test-inputs.mjs @@ -36,7 +36,9 @@ // ── The mechanism ─────────────────────────────────────────────────────────── // // A package whose tests read outside itself declares that radius ONCE, in -// CROSS_PACKAGE_TEST_INPUTS below, and both layers are driven from it: +// CROSS_PACKAGE_TEST_INPUTS -- declared in `scripts/cross-package-test-inputs.mjs` +// and imported here (see the note where it used to sit) -- and both layers are +// driven from it: // // Layer A: `--union-into ` adds the declaring package to the // shard's package set when the diff touches its declared globs. @@ -225,652 +227,45 @@ import { join, resolve, relative, dirname, sep, isAbsolute } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import process from 'node:process'; +import { CROSS_PACKAGE_TEST_INPUTS } from './cross-package-test-inputs.mjs'; +import { matchesAny, selfTest as globMatchSelfTest } from './glob-match.mjs'; import { isEntrypoint } from './invoked-as.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(HERE, '..'); /** - * Packages whose test suites read files outside their own directory, with the - * repo-relative globs they really read. Keep a glob as NARROW as the evidence - * allows and no narrower: too wide only costs cache invalidation, too narrow - * silently restores the #7802 blind spot for that package. + * Where a dev must go to ADD a declaration, for the remedy this gate prints. * - * Every entry names the test that justifies it, so the next person can check - * the radius against the code rather than trusting the glob. - * - * A rationale may cite a sibling path as an EXAMPLE only when that path is - * structurally unable to change status. The mention-shape entries below all - * reach for `check-nul-bytes.mjs`, which is load-bearing rather than habit: - * no test has a reason to READ a gate script, so "named rather than read" - * stays true of it for as long as the sentence exists. A path under active - * test does not qualify -- `sync-template-versions.mjs` was cited that way - * until #9763 taught the collector to see the split-segment read that - * `template-version-stamps.test.ts` had been making all along, and every - * sentence naming it went false at once, in copies that had to be retired one - * at a time. Cite the invariant example, or name no sibling at all. - * - * `heldBy` is that sentence made CHECKABLE for the globs the roster cannot see - * (#10566). Most globs are held mechanically: some path the tests name lands - * inside them, and `globHolderVerdict()` finds it. A read whose path this - * detector cannot NAME -- a loop variable, a `git ls-files` result, an argument - * it cannot fold -- holds a live radius while naming nothing, so those globs - * name the escaping test that reads them instead. The witness is checked rather - * than prose: the named test must still be one of this package's escaping - * tests, so a glob whose only holder stops reading outside the package fails BY - * NAME instead of sitting declared and unheld. - * - * EXPORTED for Layer C (#10379). `scripts/check-ci-filter-parity.mjs` asserts - * that every glob declared here is reachable by ci.yml's `filter` job, and it - * reads THIS table rather than a copy of it -- a second copy of the - * declarations would be the very defect that gate exists to close, one file - * further out. Importing this module runs nothing: the dispatch at the bottom - * is behind `isEntrypoint`, and `--self-test` spawns a real child to pin that. + * Derived from the import specifier rather than spelled, so it cannot go on + * naming this file after the table moved (#11511) -- a remedy that names the + * wrong file is worse than a vague one, because the dev follows it and finds + * nothing to edit. Computing it from the module URL also keeps the string out + * of this module body: `extractWatchHints` strips a leading `./` and then + * refuses what is left for having no separator, so an import specifier scores + * as no population while a repo-relative spelling would score as one. */ -export const CROSS_PACKAGE_TEST_INPUTS = { - '@objectstack/spec': { - globs: [ - // api-methods-batch-conformance.test.ts + system/constants/platform-object-names.test.ts - 'packages/**/*.object.ts', - // src/identity/position-delegatable-enforcer.pin.test.ts reads the lint rule sources - 'packages/lint/src/**', - // scripts/root-index.test.ts reads the index; scripts/category-title.test.ts and - // scripts/file-description.test.ts walk the whole references tree by category. - 'content/docs/references/**', - // scripts/dist-freshness.test.ts stages a fixture around the root scripts dir - 'scripts/**', - // `serve.ts` is named in a comment rather than read, the same shape as - // `check-nul-bytes.mjs` / the realtime protocol page below, and settled - // the same way: the literal collector takes quoted paths without parsing, - // so a mention forces a declaration, and declaring the file is cheaper - // than rewording prose to dodge the scanner. - // scripts/publish-smoke-port-collision.test.ts cites it for the - // measurement that justifies its whole existence — `serve.ts` auto-shifts - // off a busy port whenever `flags.dev` is set, which is the only reason - // publish-smoke.sh cannot trust the port it asked for. One file, not the - // commands tree: the test reads publish-smoke.sh and nothing else. - 'packages/cli/src/commands/serve.ts', - // scripts/liveness/evidence.test.ts resolves the evidence paths the - // liveness ledgers cite, so those files' existence is a spec input. - 'packages/runtime/src/**', - 'packages/objectql/src/validation/**', - 'packages/metadata-protocol/src/**', - 'packages/plugins/plugin-audit/src/**', - // Both of these were read all along and declared by nobody -- they are - // what #9763's reconstruction found the first time it ran, not radii this - // package grew. Each is spelled ascent-relative, which is exactly the - // spelling the flat literal regex below cannot start a match on: - // src/api/error-catalog-docs.test.ts reads the error-catalog page as - // `resolve(__dirname, '../../../../content/docs/api/error-catalog.mdx')` - // and asserts it documents every `StandardErrorCode`. Per-page rather - // than `content/docs/**` for the reason the @objectstack/cli entry - // gives: docs are edited far more often than any package here. - // scripts/strictness-ledger.test.ts reads the audit ledger as - // `resolve(SPEC, '../../docs/audits/...')` and ratchets it against the - // schema files it inventories, so the ledger IS an input to the ratchet. - 'content/docs/api/error-catalog.mdx', - 'docs/audits/2026-07-unknown-key-strictness-ledger.md', - // src/shared/retired-key-migrate-sentence.test.ts judges the ONE - // governed markdown file its population was widened by (#10848, - // maintainer-ruled): the retirement playbook that teaches authors the - // prescription sentence the pin holds. One file, not `.claude/**`. - '.claude/skills/spec-property-retirement/SKILL.md', - ], - heldBy: { - // The two repo-wide `*.object.ts` walkers. Each seeds a recognised - // expression and then descends with `readdirSync(dir)` on a LOOP - // VARIABLE, so the escape verdict resolves and the NAME does not -- the - // trade `pathExpression` documents. Measured: no path on this package's - // roster matches this glob, so these two tests are all that hold it. - 'packages/**/*.object.ts': [ - 'packages/spec/src/data/api-methods-batch-conformance.test.ts', - 'packages/spec/src/system/constants/platform-object-names.test.ts', - ], - }, - }, - '@objectstack/core': { - // src/security/operation-private-keys.pin.test.ts walks `git ls-files` over - // the whole repo and reads every matching source file. - globs: ['packages/**/*.ts'], - }, - '@objectstack/cli': { - // src/commands/serve-verify-security-parity.contract.test.ts diffs - // cli's serve.ts against verify's harness.ts. - // It also pins plugin-security's permission-set test as a third witness. - // - // src/commands/serve-multi-node-cap-advisory.pin.test.ts reads the - // multi-node gate's own `ResolvedMultiNodeVerdict` declaration: serve.ts - // mirrors that shape by hand (the CLI has no static dependency on the - // cluster package), and the pin exists to fail when the two drift. It only - // does that if a producer-only change re-runs cli's tests, which is exactly - // what this declaration buys. - // - // The `examples/` globs are the showcase modules two i18n tests import LIVE - // across the workspace boundary, each named per file because the two read - // DIFFERENT namespaces and are not interchangeable: - // test/i18n-section-coverage.test.ts dynamically imports `contact.view` - // and `semantic-zoo.object` and asserts `toEqual` over an exhaustive - // hardcoded `_sections` key list, so any newly NAMED section in either - // module goes red -- this is what PR #8742 broke in queue build - // 31825946401, where the merge queue was the first signal. - // test/i18n-tab-coverage.test.ts dynamically imports `task-triage.page` - // and asserts the same way over `_tabs`, so it moves with that page's - // filter-only presets and is untouched by `_sections` edits. - // Per-file rather than `examples/app-showcase/**`: the modules above have no - // relative imports of their own, so the read set IS the file set, and the - // wider glob would put cli's suite on every showcase edit. Adding a live - // import outside these paths fails `check:examples-live-imports`, which - // matches each coupling target against these globs -- so narrowing here - // cannot quietly reopen the blind spot. - // - // The `content/docs` globs are hand-written prose three e2e tests pin, to - // enforce the #6730 ruling that the NDJSON exception "stays declared, not just - // implemented" -- the declaration has to be findable in the page a script - // author actually meets, so the page IS an input. All three were invisible to - // this gate until #8995 taught the detector their seed spelling, and the miss - // is not theoretical: PR #8983 reworded `deployment/index.mdx` to "one compact - // JSON document per line", which every fact survived but the literal - // `/one\s+per\s+line/i` pin did not. Undeclared, cli was outside the affected - // set, so PR CI was green and the merge queue was the first signal -- it - // dequeued the PR and took two unrelated PRs down as batch collateral. - // test/cloud-login-json-ndjson.e2e.test.ts reads deployment/cli.mdx and - // deployment/index.mdx. - // test/login-json-ndjson.e2e.test.ts reads deployment/cli.mdx and - // permissions/authentication.mdx (the page describing the device flow). - // test/login-json-noninteractive.e2e.test.ts reads deployment/cli.mdx. - // Per-page rather than `content/docs/**`: docs are edited far more often than - // any package here, and a subtree glob would put cli's e2e suite on every - // documentation PR. - // - // `connector-mcp-plugin.ts` is read by test/serve-capability-identity.test.ts, - // which pins that the connector still registers the name the #7652 repro uses - // rather than importing the class. It surfaced with the three above and has the - // same shape of blind spot. The gate could not name it until #9763: the test - // spells the path ASCENT-RELATIVE (`resolve(HERE, '../../connectors/...')`), - // which the flat literal regex cannot start a match on. The collector now - // reconstructs it, so this glob is held by the read rather than by this - // comment — it was the one entry that already documented the hole, as a fact - // about itself rather than as the general gap it turned out to be. - // - // `check-nul-bytes.mjs` is the one entry no test READS -- it is named in a - // comment in login-json-noninteractive.e2e.test.ts. The literal collector takes - // quoted paths without parsing, so a mention forces a declaration; that is the - // designed trade (over-collection can only widen a radius, never narrow one), - // and declaring one rarely-touched file is cheaper than teaching the scanner to - // tell prose from code, or than rewording a comment to dodge a scanner. - // - // `js-comment-mask.mjs` is the first entry declared for an IMPORT rather than - // a file read, and it now has TWO importers: - // src/commands/serve-verify-security-parity.contract.test.ts (#10453, - // adopting #9367's conversion) and - // src/commands/serve-audit-registration.contract.test.ts (#9863) both import - // `maskComments` from it to separate code from prose in the boot paths they - // scan. This gate did NOT demand the declaration -- its literal collector - // recognises path-shaped reads, and a relative import specifier that escapes - // the package is not one of the spellings it knows. Declared by hand because - // the coupling is real whatever the collector saw: those scans' verdicts are - // a function of this module's masking behaviour, so a change to it has to - // re-run cli's suite. The undetected-import spelling is filed separately as - // #10452; widening a radius by hand is never the reason not to file it. - // - // Its `.d.mts` sibling is declared for BOTH reasons this roster records. It - // is named in that test's prose, and the literal collector takes quoted - // paths without parsing, so a mention forces a declaration (the - // `check-nul-bytes.mjs` entry above settles that trade the same way: - // declaring the file beats rewording a comment to dodge a scanner). It is - // also a real input rather than only a mention -- it is what gives - // `maskComments` its type, so cli's `tsc --noEmit` verdict is a function of - // it. Measured, not assumed: this file arriving on main is exactly what - // turned that test's `@ts-expect-error` into a TS2578 and took the - // typecheck lanes red on a branch that never touched it. - globs: [ - 'packages/verify/src/**', - 'packages/plugins/plugin-security/src/**', - 'packages/services/service-cluster/src/**', - 'packages/connectors/connector-mcp/src/connector-mcp-plugin.ts', - 'examples/app-showcase/src/ui/views/contact.view.ts', - 'examples/app-showcase/src/data/objects/semantic-zoo.object.ts', - 'examples/app-showcase/src/ui/pages/task-triage.page.ts', - 'content/docs/deployment/cli.mdx', - 'content/docs/deployment/index.mdx', - 'content/docs/permissions/authentication.mdx', - 'scripts/check-nul-bytes.mjs', - // This gate's OWN script, the third entry of the mention shape on this - // package: test/scaffold-workspace-consistency.test.ts quotes it while - // explaining where its cross-package read is declared. Settled the way - // check-nul-bytes.mjs above is — the literal collector takes quoted paths - // without parsing, so a mention forces a declaration, and declaring one - // rarely-touched file is cheaper than rewording prose to dodge a scanner. - 'scripts/check-cross-package-test-inputs.mjs', - 'scripts/js-comment-mask.mjs', - 'scripts/js-comment-mask.d.mts', - // `translation.zod.ts` is the second entry no test READS -- named in a - // comment in test/i18n-section-coverage.test.ts, which describes it as the - // DECLARATION face of the schema that test asserts against. It appears - // here only now because that file had no `fs` read at all, so it never - // reached the scan before #10452 relaxed the pre-filter to admit - // import-only escapes; the flat literal collector then took the quoted - // path exactly as it always has. Settled the same way as - // `check-nul-bytes.mjs` above -- declaring one file beats teaching the - // scanner to tell prose from code, and this one costs nothing in practice: - // `@objectstack/spec` is a real dependency of this package, so the graph - // already re-runs these tests on any spec change. - 'packages/spec/src/system/translation.zod.ts', - // The blank template's rendered `pnpm-workspace.yaml`, READ by - // test/scaffold-workspace-consistency.test.ts (#10499). Two scaffold - // paths write that file into a new user's project — this package's - // `renderPnpmWorkspaceYaml()` and create-objectstack's literal - // template — and each package's own ratchets are package-local, so - // neither could ever fail for the other's regression. The consistency - // test compares the two RENDERED outputs, which makes the template file - // a real input to this package's verdict: a template-only diff changes - // what that test measures. Without this declaration such a diff reaches - // neither layer — `turbo ls --affected` would still pick cli up (it - // depends on create-objectstack for the shared `created-summary` - // renderer), but `@objectstack/cli#test` would hash the same and replay - // a cached green over the divergence, which is #7802's Layer B exactly. - // One file, not `packages/create-objectstack/**`: the test reads that - // template and nothing else across the boundary. - 'packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml', - ], - }, - '@objectstack/client': { - // The first entry this gate DERIVED from import specifiers rather than from - // a path-shaped read (#10452), and the reason that half was worth building: - // six tests here import six sibling packages' route ledgers directly by - // relative specifier, and nothing had ever declared any of them. - // src/client-url-conformance.test.ts and src/route-ledger-response-schema.test.ts - // import runtime, rest, service-storage, service-i18n and plugin-auth; - // src/route-ledger-coverage.test.ts imports runtime; - // src/rest-route-ledger-coverage.test.ts imports rest; - // src/service-route-ledger-coverage.test.ts imports the three services, - // service-datasource among them; - // src/auth-route-ledger-coverage.test.ts imports plugin-auth (#11359) — - // the sixth ledger's client half, added last and reading a file the - // five globs below already carried, so it widened no radius. - // Each asserts this client's URL builders still agree with the ledger the - // server side publishes, so a ledger edit changes the verdict by design. - // - // The graph does not carry it and cannot be made to: of the six, only - // `@objectstack/runtime` appears in this package's manifest at all (a - // devDependency) -- `@objectstack/rest`, the three services and - // `plugin-auth` are not dependencies in any form, which is why - // `turbo ls --affected` could not reach client from a ledger-only diff and - // `client#test` hashed the same before and after one. #7802's shape exactly, - // reached by the other spelling. - // - // Per-file rather than `packages/**/src/**`: a ledger is one file per - // package and these tests read nothing else across the boundary, so the - // radius stays the six files the imports name. The roster holds it -- an - // import added outside them fails this gate by name. - globs: [ - 'packages/runtime/src/route-ledger.ts', - 'packages/rest/src/rest-route-ledger.ts', - 'packages/services/service-storage/src/storage-route-ledger.ts', - 'packages/services/service-i18n/src/i18n-route-ledger.ts', - 'packages/services/service-datasource/src/datasource-route-ledger.ts', - 'packages/plugins/plugin-auth/src/auth-route-ledger.ts', - // Below this line: paths these tests NAME in prose rather than read. Each - // docblock cross-references the sibling conformance test it mirrors, or - // the script that records the envelope shape, and the flat literal - // collector takes quoted paths without parsing. Same designed trade as the - // `check-nul-bytes.mjs` entry on `@objectstack/cli` -- over-collection can - // only widen a radius, never narrow one, and declaring the file beats - // rewording a comment to dodge a scanner. Not claimed as real inputs: a - // sibling package's TEST file cannot change this package's verdict. The - // six globs above are the ones the imports hold. - 'packages/runtime/src/route-ledger.conformance.test.ts', - 'packages/rest/src/rest-route-ledger.conformance.test.ts', - 'packages/services/service-storage/src/storage-route-ledger.conformance.test.ts', - 'packages/services/service-i18n/src/i18n-route-ledger.conformance.test.ts', - 'packages/services/service-datasource/src/datasource-route-ledger.conformance.test.ts', - 'packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts', - 'scripts/check-route-envelope.mjs', - ], - }, - '@objectstack/lint': { - // authoring-rule-wiring / validate-rule-compilability / - // lint-startup-registry-verdict.corpus read each authoring rule's source - // by repo-relative path, plus the CLI commands dir and the runtime gate. - // - // The `examples/` globs are the showcase modules two validator tests import - // LIVE across the workspace boundary. Both assert the SHIPPED app is clean - // rather than pinning a fixed shape -- #8515 lifted the pinned-shape cases - // onto the frozen `showcase-shape.fixtures.ts` snapshot, so what survives - // live are the cases that must keep resolving against the real app: - // src/validate-translatable-sections.test.ts imports `Contact`, - // `ContactViews` and `ShowcaseTranslationBundle`; a section introduced - // WITHOUT a name moves it, a correctly named one does not. - // src/validate-translation-references.test.ts imports `Contact` and - // `ContactViews` and asserts every translation key still resolves, so - // renaming or removing a Contact field, view, section or action moves - // it while adding one generally does not. - // Per-file for the same reason as `@objectstack/cli` above: these modules - // have no relative imports of their own, and a live import added outside - // them fails `check:examples-live-imports` by name. - globs: [ - 'packages/cli/src/commands/**', - 'packages/metadata-protocol/src/**', - 'packages/objectql/src/validation/**', - 'packages/services/service-automation/src/**', - 'examples/app-showcase/src/data/objects/contact.object.ts', - 'examples/app-showcase/src/system/translations/index.ts', - 'examples/app-showcase/src/ui/views/contact.view.ts', - ], - heldBy: { - // `const commandsDir = join(repoRoot, 'packages/cli/src/commands')` - // resolves, but every read off it is `readFileSync(join(commandsDir, - // file))` with `file` a variable: the roster gets the DIRECTORY, which a - // file-position read cannot put on it, and never one of the files. - 'packages/cli/src/commands/**': ['packages/lint/src/authoring-rule-wiring.test.ts'], - }, - }, - '@objectstack/platform-objects': { - // src/managed-api-method-affordance-sweep.test.ts (#7934) imports every - // `*.object.ts` in the monorepo and runs `validateManagedApiMethods` over - // it — the population `os lint` never walks, because these objects ship as - // code rather than in an authored stack. - globs: ['packages/**/*.object.ts'], - }, - '@objectstack/plugin-auth': { - // src/managed-extension-fields.test.ts walks every `*.object.ts`, and pins - // core's api-key source alongside it. - globs: [ - 'packages/**/*.object.ts', - 'packages/core/src/security/**', - // src/rate-limit-storage-isolation.test.ts (#6040) walks BOTH consumer - // packages of the `./rate-limit-storage` subpath by directory, checking - // that neither reaches the counter through the package ROOT — which would - // silently reinstate the whole better-auth load for them. The diff that - // breaks that invariant is a diff in one of these two directories, so - // without them declared the affected-subset filter never adds plugin-auth - // and turbo replays a cached green over the scan (#10029, the #7802 - // shape). Measured: before this entry, `@objectstack/plugin-auth#test` - // hashed to `1bf3935543ab055b` both before and after a change under - // `packages/runtime/src`, and the re-run was `>>> FULL TURBO` in 135ms - // while the invariant was live-broken in the tree. - 'packages/runtime/src/**', - 'packages/services/service-sms/src/**', - // The three below are NAMED in that test's prose rather than read by it — - // the same shape as `check-nul-bytes.mjs` on the @objectstack/cli entry - // above, and settled the same way: the literal collector takes quoted - // paths without parsing, so a mention forces a declaration, and declaring - // the file is cheaper than rewording prose to dodge the scanner. All - // three are low-churn, so the added cache invalidation is nominal next to - // the two directories above. - 'scripts/check-published-files.mjs', - 'scripts/check-cross-package-test-inputs.mjs', - 'packages/types/src/node-isolation.test.ts', - ], - heldBy: { - // The pair #10566 was measured on. That test's walk of `PACKAGES_DIR` - // descends on a loop variable, so no `*.object.ts` path reaches this - // package's roster and this glob has no mechanical holder. Since #10161 - // gave plugin-auth a SECOND escaping test, losing the walk no longer - // empties the package either -- the entry stays, the glob goes unheld, - // and before this witness existed nothing reported it. - 'packages/**/*.object.ts': ['packages/plugins/plugin-auth/src/managed-extension-fields.test.ts'], - }, - }, - '@objectstack/plugin-security': { - // src/audience-anchor-set-claims.pin.test.ts pins against spec's - // high-privilege table, and cross-checks spec's own delegatable pin. - globs: ['packages/spec/src/security/**', 'packages/spec/src/identity/**'], - }, - '@objectstack/trigger-record-change': { - // [#11081] src/record-change-integration.test.ts imports - // `@objectstack/runtime`'s shared expected-noise capture so its 84 expected - // authz/organization read refusals are WITHHELD-AND-ASSERTED rather than - // blanket-muted by `logger: { level: 'silent' }`. - // - // ONE file, not `packages/runtime/src/**` (which is the radius - // `plugin-auth` and `dogfood` carry): the helper has no imports of its own, - // so that single path IS the whole escaping read. The narrow radius keeps - // this package's suite off every runtime diff while still moving the - // `#test` hash when the predicate it depends on changes. - // - // The three below are NAMED in this package's prose rather than read by it - // — `slot-lookup-baseline.json` and `kernel.ts` by comments that predate - // this entry, `check-cross-package-test-inputs.mjs` by the import comment - // added with it. Same shape as `check-nul-bytes.mjs` on the - // `@objectstack/cli` entry above, and settled the same way: the literal - // collector takes quoted paths without parsing, so a mention forces a - // declaration, and declaring the file is cheaper than rewording prose to - // dodge the scanner. (They were invisible until now only because a package - // with NO escaping test is never rostered at all.) - globs: [ - 'packages/runtime/src/expected-read-refusal-noise.ts', - 'scripts/check-cross-package-test-inputs.mjs', - 'scripts/slot-lookup-baseline.json', - 'packages/core/src/kernel.ts', - ], - }, - '@objectstack/plugin-approvals': { - // [#11081] src/status-mirror-cascade.integration.test.ts imports the same - // capture for its 25 expected refusals (the six authz tables plus - // `sys_approval_delegation`). Same one-file radius, same reason — plus this - // gate's own path, named in that file's import comment (see the note on the - // sibling entry above for why a mention is declared rather than reworded). - // - // [#11286] src/manager-org-screen-parity.contract.test.ts imports - // `managerIsProvablyOutsideOrg` from plugin-sharing's `team-graph.ts` BY - // RELATIVE SOURCE PATH — the only way in, since that screen is deliberately - // not exported from plugin-sharing's index and the package publishes no - // subpath. The test pins the two independent screens over - // `sys_user.manager_id` to the same verdicts, so the sharing screen is a - // real input to it: without this glob a change THERE would never re-run the - // pin HERE, which is the exact blind spot this gate exists for. One file, - // not `plugin-sharing/src/**` — nothing else in that package is read. - globs: [ - 'packages/runtime/src/expected-read-refusal-noise.ts', - 'scripts/check-cross-package-test-inputs.mjs', - 'packages/plugins/plugin-sharing/src/team-graph.ts', - ], - }, - '@objectstack/dogfood': { - // test/*-conformance.test.ts read a fixed roster of probe files across - // runtime, rest, plugins and services by repo-relative path. Narrow to the - // roster rather than `packages/**/src/**`: the literal-coverage check below - // fails the moment a probe is added outside these, so narrowing here cannot - // quietly reopen the blind spot. - globs: [ - 'packages/client/src/**', - 'packages/mcp/src/**', - 'packages/plugins/plugin-hono-server/src/**', - 'packages/rest/src/**', - 'packages/runtime/src/**', - 'packages/services/service-realtime/src/**', - // The three ledgers test/route-ledger-live-mount-parity.dogfood.test.ts - // IMPORTS, which no read named and nothing declared until #10452 taught - // this gate specifiers. That test mounts the live app and asserts every - // ledger entry is really routed, so each ledger is an input by - // construction. Per-file, matching what the imports name: the rest of - // these services' `src/**` is not read here. - 'packages/services/service-storage/src/storage-route-ledger.ts', - 'packages/services/service-i18n/src/i18n-route-ledger.ts', - 'packages/services/service-settings/src/settings-route-ledger.ts', - // flow-trigger / validation conformance pin spec's zod schemas. - 'packages/spec/src/automation/**', - 'packages/spec/src/data/**', - // showcase-declarative-*.dogfood.test.ts chdir into the showcase app and - // compile it, so the app IS an input, and they assert on the artifact the - // compile pipeline and the metadata plugin produce. - 'examples/app-showcase/**', - 'packages/cli/src/commands/**', - 'packages/metadata/src/**', - // `realtime-protocol.mdx` is named in a comment rather than read, the - // same shape as `check-nul-bytes.mjs` on the @objectstack/cli entry - // above and settled the same way: a mention forces a declaration, and - // declaring the file is cheaper than rewording prose to dodge the - // scanner. Here the coupling is real on top of being cheap — that page - // is what documents the PLANNED realtime transports (`/ws`, SSE - // `/api/v1/stream`), and the #2992 transport tripwires in - // authz-conformance.test.ts are only correct for as long as they cover - // those spellings. A third transport added to the page is exactly the - // change that reopens the #9084 blind spot, so it must re-run this test. - 'content/docs/protocol/kernel/realtime-protocol.mdx', - ], - }, - '@objectstack/formula': { - // src/rls-predicate.test.ts pins spec's RLS zod source against the - // predicate compiler; src/skill-catalog-sync.test.ts pins the published - // formula skill's stdlib table against the implementation. - globs: ['packages/spec/src/security/rls.zod.ts', 'skills/objectstack-formula/**'], - }, - '@objectstack/rest': { - // src/meta-state-route-doc-spelling.test.ts reads the two published prose - // sites that teach the `meta.getLegalNextStates` route and asserts each - // spells it the way this package's REST_ROUTE_LEDGER row does, so the - // ledger row and the prose can no longer drift apart in silence (#10178). - // Per-file rather than `content/docs/**` or `skills/**` for the reason the - // @objectstack/spec entry gives: those roots are edited far more often than - // anything this radius really depends on. - globs: [ - 'content/docs/protocol/objectql/state-machine.mdx', - 'skills/objectstack-automation/SKILL.md', - ], - }, - '@objectstack/metadata-protocol': { - // src/sys-metadata-repository.draft-drain.test.ts reads the durability - // log-level gate's own source to pin that the repository stays inside it. - globs: ['scripts/check-durability-degradation-log-level.mjs'], - }, - '@objectstack/downstream-contract': { - // test/source-resolution.pin.test.ts resolves every spec specifier a - // downstream consumer can import, against spec's real source tree and the - // `exports` map in its package.json. - globs: ['packages/spec/src/**', 'packages/spec/package.json'], - heldBy: { - // `SPEC_SRC` resolves, but the files under it are reached as - // `existsSync(target)` where `target` was computed out of the `exports` - // map, so the roster holds `packages/spec/package.json` and nothing at - // all under `src`. - 'packages/spec/src/**': ['packages/qa/downstream-contract/test/source-resolution.pin.test.ts'], - }, - }, - 'create-objectstack': { - // src/template-consistency.test.ts reads doc frontmatter by repo-relative - // path to decide which templates are internal. - // - // `sync-template-versions.mjs` is a real cross-package READ. Since #9648, - // src/template-version-stamps.test.ts loads the script by URL to assert its - // declaration surface (`stampedPaths()`, `findTemplateDirs()`, the - // `TEXT_STAMPS` table) and runs it with `execFileSync` over a two-template - // fixture (#9554). It was a mention before that test existed, which is what - // the rationale here used to say. - // - // The glob does not rest on that one test, so deleting it would not make - // this declaration wrong — only smaller. The script STAMPS the three - // per-template version surfaces (`package.json` @objectstack/* ranges, - // `objectstack.config.ts` `engines.protocol`, `objectstack.manifest.json` - // `specVersion`) that template-consistency.test.ts ratchets, so a change to - // the stamper is exactly the change those ratchets exist to catch (#9264). - // - // What FORCES the glob is now the read itself. Both tests spell the path as - // `join(repoRoot, 'scripts', 'sync-template-versions.mjs')`, which the flat - // literal collector below cannot see — it only matches a whole repo-relative - // path inside ONE quoted string — so until #9763 what actually held this - // declaration was the quoted MENTION in each test's header comment, and - // rewording either one into unquoted prose unforced a live radius. - // Measured on 06f9848f9, before the fix: drop the glob and unquote both - // mentions and the gate printed `OK ... exit 0`. Measured after: the same - // ablation fails naming template-version-stamps.test.ts, the file that - // really reads. The mentions are ordinary prose again — free to reword. - // - // `.github/workflows/scaffold-e2e.yml` is READ, not merely mentioned: - // src/scaffold-e2e-boot-probe.test.ts extracts the three boot-and-probe - // `run:` scripts out of that file and EXECUTES them, so the workflow is - // literally the code under test. It is the workflow that gates this package - // (its `paths:` filter is `packages/create-objectstack/**`), which is why - // the test lives here rather than beside a shell script in spec (#9779). - // - // Three of the remaining four are NAMED in a test's header rather than - // read, the same shape as `check-nul-bytes.mjs` above and settled the - // same way: the literal collector takes quoted paths without parsing, so - // a mention forces a declaration, and declaring a rarely-touched file is - // cheaper than rewording prose to dodge a scanner. `serve.ts` earns it on - // the merits too — its `flags.dev || NODE_ENV === 'development'` - // port-shift gate is the single fact that decides which fix those - // workflow blocks need, so a change to that branch is exactly the change - // the test's premise would need re-measuring against. The two sibling - // scripts are cited for the contrast that keeps the fixes from being - // copied between them. - // - // `packages/cli/src/commands/init.ts` is the fourth of that shape (#10322): - // scaffold-next-steps-pm.test.ts's header quotes it in backticks while - // explaining that `init.ts`'s own "Next steps" output already threads its - // detected `chosenPm` the same way this package's scaffolder now does — - // it is cited for the contrast, never read. The test execs - // `create-objectstack`'s own CLI via `tsx`, not `init.ts`. - globs: [ - 'content/**', - 'scripts/sync-template-versions.mjs', - // The stamper's own import closure, and a live input for the same reason - // the stamper is: template-version-stamps.test.ts copies the script into - // a fixture and both IMPORTS and SPAWNS it there, so the copy needs every - // relative import the script makes. That fixture derives the closure - // rather than naming files, so this path appears in NO quoted string the - // flat literal collector can see — but a change to it really does break - // that test (measured: drop the closure walk and the same 3 cases fail - // with ERR_MODULE_NOT_FOUND), which is exactly the trigger radius this - // declaration exists to keep honest. - 'scripts/invoked-as.mjs', - '.github/workflows/scaffold-e2e.yml', - 'packages/cli/src/commands/serve.ts', - 'scripts/gen-sdui-manifest.sh', - 'scripts/publish-smoke.sh', - 'packages/cli/src/commands/init.ts', - ], - heldBy: { - // Read through `git grep -- content/docs` and `git ls-files`, so the - // paths are process OUTPUT rather than literals: the pathspec itself is - // the only quoted thing, and a directory in file position never reaches - // the roster. - 'content/**': ['packages/create-objectstack/src/template-consistency.test.ts'], - // The glob whose own rationale above already states the shape this - // witness records: the fixture derives the stamper's import closure - // instead of naming it, so this path "appears in NO quoted string the - // flat literal collector can see" while a change to it really does break - // that test (measured there). - 'scripts/invoked-as.mjs': ['packages/create-objectstack/src/template-version-stamps.test.ts'], - }, - }, -}; - -// ── glob matching ──────────────────────────────────────────────────────────── -// Deliberately dependency-free: this gate runs in CI before anything is built, -// and a `scripts/` gate that can fail on a resolution problem is a gate that -// gets muted. Supports the three constructs the declarations above use: -// `**` (any number of path segments), `*` (within one segment), and literals. -export function globToRegExp(glob) { - let re = ''; - for (let i = 0; i < glob.length; i++) { - const c = glob[i]; - if (c === '*') { - if (glob[i + 1] === '*') { - // `**/` consumes zero or more whole segments; a trailing `**` consumes the rest. - if (glob[i + 2] === '/') { - re += '(?:[^/]+/)*'; - i += 2; - } else { - re += '.*'; - i += 1; - } - } else { - re += '[^/]*'; - } - } else if ('.+?^${}()|[]\\/'.includes(c)) { - re += `\\${c}`; - } else { - re += c; - } - } - return new RegExp(`^${re}$`); -} +const DECLARATION_FILE = relative(REPO_ROOT, fileURLToPath(new URL('./cross-package-test-inputs.mjs', import.meta.url))); + +// ── the declaration table, and the predicate applied to it ─────────────── +// +// Both moved OUT of this gate and into plain modules no workflow invokes +// (#11511), because a gate that is also a library is a module the dispatch +// derivation cannot follow: `scripts/pm/dispatch-gates.mjs` follows a gate's +// first-party imports one level, but never into a file that is itself a +// discovered gate. `check:ci-filter-parity` reads the table and derived +// NOTHING from it while it lived here. +// +// They landed in two modules rather than one on measurement -- the follow +// hands an importer the whole followed module's population, so pairing the +// predicate with the table would have handed `check:examples-live-imports` +// 3105 (gate, file) pairs it never opens. Each module's header carries its own +// half of that measurement. +// +// This gate's own coverage is unchanged: it imports the table, so the follow +// gives every one of those globs back to `check:cross-package-test-inputs`, +// now labelled with the module they came from. -export function matchesAny(path, globs) { - return globs.some((g) => globToRegExp(g).test(path)); -} /** * Whether the declared globs cover a DIRECTORY a test lists with `readdirSync`. @@ -1802,7 +1197,7 @@ function verify() { problems.push( `${name} has test(s) that read outside the package but declares no input radius.\n` + info.tests.map((t) => ` ${t}`).join('\n') + - `\n Add an entry to CROSS_PACKAGE_TEST_INPUTS in ${relative(REPO_ROOT, fileURLToPath(import.meta.url))}\n` + + `\n Add an entry to CROSS_PACKAGE_TEST_INPUTS in ${DECLARATION_FILE}\n` + ` with the repo-relative globs those tests read, then run this gate again\n` + ` for the turbo.json inputs it requires.`, ); @@ -2039,14 +1434,14 @@ function selfTest() { const cases = []; const ok = (label, cond) => cases.push({ label, cond }); - // glob semantics - ok('** spans segments', matchesAny('packages/platform-objects/src/identity/x.object.ts', ['packages/**/*.object.ts'])); - ok('** matches a direct child', matchesAny('packages/a.object.ts', ['packages/**/*.object.ts'])); - ok('* does not span segments', !matchesAny('packages/a/b.object.ts', ['packages/*.object.ts'])); - ok('non-matching extension rejected', !matchesAny('packages/x/src/a.ts', ['packages/**/*.object.ts'])); - ok('trailing ** matches subtree', matchesAny('packages/lint/src/rules/a.ts', ['packages/lint/src/**'])); - ok('literal file glob', matchesAny('content/docs/references/index.mdx', ['content/docs/references/index.mdx'])); - ok('dot is literal', !matchesAny('contentXdocs/references/index.mdx', ['content/docs/references/index.mdx'])); + // glob semantics -- driven from the shared module rather than restated here, + // so this gate and `check:examples-live-imports` are pinned against ONE set + // of cases. The module has no CI invocation of its own (it is not a gate, by + // design), so folding its failures into both importers' `--self-test` IS its + // coverage. Its fixtures are assembled from segments, which is why the cases + // that used to sit here no longer spell any path: a path literal in that + // module would be inherited as a watch hint by every gate importing it. + for (const failure of globMatchSelfTest()) ok(failure, false); // detector shapes -- one per spelling that appears in the repo today const at = (src, depth) => escapingBindings(src, depth).length > 0; @@ -2825,6 +2220,13 @@ function selfTest() { // hand-copied `globToRegExp` rather than import it, naming this load-time gate // as the reason. // +// `globToRegExp` has since moved out to `scripts/glob-match.mjs` (#11511), so +// that particular consumer no longer arrives here at all. The guard stays +// load-bearing regardless: `findEscapingPackages`, `coversDirectory` and +// `globHolderVerdict` are still exported from a file with a CLI, and the +// rule `check:entry-guard` enforces is about the FILE, not about who happens +// to import it today. +// // `isEntrypoint` is the repo's one answer to "was I run?" -- see // `scripts/invoked-as.mjs` for why the hand-typed spellings are wrong, and // `check:entry-guard`, which fails any other spelling in `scripts/**`. diff --git a/scripts/check-examples-live-imports.mjs b/scripts/check-examples-live-imports.mjs index 3d62351b42..a1546fa4b2 100644 --- a/scripts/check-examples-live-imports.mjs +++ b/scripts/check-examples-live-imports.mjs @@ -118,7 +118,16 @@ import { maskComments } from './js-comment-mask.mjs'; // importing it is silent and exit-neutral. The reason for the copy is gone, so // the copy is too -- one implementation, and no way for the two gates to drift // apart on the semantics that decide both their verdicts. -import { globToRegExp } from './check-cross-package-test-inputs.mjs'; +// +// The predicate now comes from a plain module rather than from that gate, and +// which module it is decides what THIS gate derives (#11511). `glob-match.mjs` +// declares no path population; the cross-package DECLARATION TABLE lives in a +// separate module this gate does not import. That separation is the whole +// point: dispatch-gates hands an importer the followed module's population +// whole, so importing the table for a string predicate would claim 3105 +// (gate, file) pairs under `packages/**` for a gate whose subject is +// `examples/`. Import the predicate, never the table. +import { globToRegExp, selfTest as globMatchSelfTest } from './glob-match.mjs'; import { join, resolve, relative, dirname, sep, posix } from 'node:path'; import { fileURLToPath } from 'node:url'; import process from 'node:process'; @@ -872,6 +881,17 @@ function selfTest() { 'the declared form is NOT a SCAN_ROOTS dir (provenance, never a lookup key: the glob form would send the walk at a directory that does not exist)', !SCAN_ROOTS.some((r) => ROOT_DIR_WATCH_HINTS.includes(r.dir)), ], + + // ── the shared glob predicate ── + // + // `glob-match.mjs` is not a gate and has no CI invocation of its own, by + // design: a module with a `check:` name is one the dispatch derivation + // refuses to follow, which is the whole reason it exists. Folding its + // failures into the `--self-test` of each importer IS its coverage, and + // this gate is one of the two importers. Its cases include the pin that + // keeps it free of path literals -- the property that stops THIS gate from + // inheriting a `packages/**` population it never opens. + ...globMatchSelfTest().map((failure) => [failure, false]), ]; let failed = 0; diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs new file mode 100644 index 0000000000..47b2433147 --- /dev/null +++ b/scripts/cross-package-test-inputs.mjs @@ -0,0 +1,672 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * cross-package-test-inputs -- the DECLARATION TABLE of which packages' tests + * read outside their own directory, and the repo-relative globs they read. + * + * The table itself is documented on the export below. This header is about + * where the table LIVES, which is a separate decision and a measured one. + * + * ## Why a plain module, and not the gate that acts on it (#11511) + * + * Three scripts read this table's consequences and two of them read the table + * itself: + * + * scripts/check-cross-package-test-inputs.mjs the gate (Layers A and B) + * scripts/check-ci-filter-parity.mjs Layer C, reads this table + * + * It used to live inside the first of those, exported for the second. That is + * a gate acting as a library, and `scripts/pm/dispatch-gates.mjs` cannot follow + * it: the derivation follows a gate's first-party imports one level, but + * deliberately NEVER into a module that is itself a discovered gate file. So + * the parity gate -- whose population this table literally IS -- derived + * nothing for a card that edits it. Measured on 589758d22, as the (gate, file) + * pairs the derivation would have named: + * + * scripts/check-ci-filter-parity.mjs 1 pair -> 3253 (+3252, honest) + * + * Moving the table to a module no workflow invokes makes that lead derivable + * through the EXISTING follow, with no rule change in the derivation -- the + * shape `scripts/workspace-enumerator.mjs` and `scripts/i18n-bundle-surface.mjs` + * already have. + * + * ## ⛔ THIS MODULE DECLARES A PATH POPULATION -- KEEP PURE HELPERS OUT OF IT + * + * The inverse of `workspace-enumerator.mjs`'s rule, and it is the reason this + * file and `scripts/glob-match.mjs` are two files rather than one. + * + * The follow appends a followed module's watch hints to EVERY importer, whole, + * regardless of which binding the importer actually named. The globs below are + * a population: measured on 589758d22 they cover 3253 of the repo's 6603 + * tracked files. `scripts/check-examples-live-imports.mjs` wants one string + * predicate and nothing else -- its own subject is `examples/` -- so if the + * predicates lived here it would inherit all 3253: + * + * check:examples-live-imports 241 pairs -> 3346 (+3105, fabricated) + * + * That is the exact trade dispatch-gates already refuses on provenance, rebuilt + * one file further out by a tidy-up. A fabricated lead is pasted into every + * dispatch prompt whose surface brushes it and the dev who runs it cannot tell + * it from a real one, which is why it is refused even though it is cheaper than + * the pairs this file gladly hands the parity gate. The predicates therefore + * live in `scripts/glob-match.mjs`, which declares no population at all and + * pins that property against its own bytes. + * + * The rule for the next author, in one line: a binding that is a PREDICATE goes + * in glob-match.mjs, a binding that is a DECLARATION goes here, and neither + * file grows the other's kind. + * + * ## Inert on import + * + * No CLI, no top-level statement that runs anything -- `check:entry-guard`'s + * second rule, which exists because importing a gate for its exports used to + * run the gate (#10610, and the hand-copied `globToRegExp` that defect caused). + * A file of declarations satisfies it by construction; keep it that way. + */ + +/** + * Packages whose test suites read files outside their own directory, with the + * repo-relative globs they really read. Keep a glob as NARROW as the evidence + * allows and no narrower: too wide only costs cache invalidation, too narrow + * silently restores the #7802 blind spot for that package. + * + * Every entry names the test that justifies it, so the next person can check + * the radius against the code rather than trusting the glob. + * + * A rationale may cite a sibling path as an EXAMPLE only when that path is + * structurally unable to change status. The mention-shape entries below all + * reach for `check-nul-bytes.mjs`, which is load-bearing rather than habit: + * no test has a reason to READ a gate script, so "named rather than read" + * stays true of it for as long as the sentence exists. A path under active + * test does not qualify -- `sync-template-versions.mjs` was cited that way + * until #9763 taught the collector to see the split-segment read that + * `template-version-stamps.test.ts` had been making all along, and every + * sentence naming it went false at once, in copies that had to be retired one + * at a time. Cite the invariant example, or name no sibling at all. + * + * `heldBy` is that sentence made CHECKABLE for the globs the roster cannot see + * (#10566). Most globs are held mechanically: some path the tests name lands + * inside them, and `globHolderVerdict()` finds it. A read whose path this + * detector cannot NAME -- a loop variable, a `git ls-files` result, an argument + * it cannot fold -- holds a live radius while naming nothing, so those globs + * name the escaping test that reads them instead. The witness is checked rather + * than prose: the named test must still be one of this package's escaping + * tests, so a glob whose only holder stops reading outside the package fails BY + * NAME instead of sitting declared and unheld. + * + * READ BY TWO GATES, never copied into either. `check-cross-package-test-inputs` + * drives Layers A and B from it; `scripts/check-ci-filter-parity.mjs` asserts + * for Layer C that every glob declared here is reachable by ci.yml's `filter` + * job. Both import THIS table rather than hold a copy -- a second copy of the + * declarations would be the very defect those gates exist to close, one file + * further out. Importing this module runs nothing; see the header. + */ +export const CROSS_PACKAGE_TEST_INPUTS = { + '@objectstack/spec': { + globs: [ + // api-methods-batch-conformance.test.ts + system/constants/platform-object-names.test.ts + 'packages/**/*.object.ts', + // src/identity/position-delegatable-enforcer.pin.test.ts reads the lint rule sources + 'packages/lint/src/**', + // scripts/root-index.test.ts reads the index; scripts/category-title.test.ts and + // scripts/file-description.test.ts walk the whole references tree by category. + 'content/docs/references/**', + // scripts/dist-freshness.test.ts stages a fixture around the root scripts dir + 'scripts/**', + // `serve.ts` is named in a comment rather than read, the same shape as + // `check-nul-bytes.mjs` / the realtime protocol page below, and settled + // the same way: the literal collector takes quoted paths without parsing, + // so a mention forces a declaration, and declaring the file is cheaper + // than rewording prose to dodge the scanner. + // scripts/publish-smoke-port-collision.test.ts cites it for the + // measurement that justifies its whole existence — `serve.ts` auto-shifts + // off a busy port whenever `flags.dev` is set, which is the only reason + // publish-smoke.sh cannot trust the port it asked for. One file, not the + // commands tree: the test reads publish-smoke.sh and nothing else. + 'packages/cli/src/commands/serve.ts', + // scripts/liveness/evidence.test.ts resolves the evidence paths the + // liveness ledgers cite, so those files' existence is a spec input. + 'packages/runtime/src/**', + 'packages/objectql/src/validation/**', + 'packages/metadata-protocol/src/**', + 'packages/plugins/plugin-audit/src/**', + // Both of these were read all along and declared by nobody -- they are + // what #9763's reconstruction found the first time it ran, not radii this + // package grew. Each is spelled ascent-relative, which is exactly the + // spelling the flat literal regex below cannot start a match on: + // src/api/error-catalog-docs.test.ts reads the error-catalog page as + // `resolve(__dirname, '../../../../content/docs/api/error-catalog.mdx')` + // and asserts it documents every `StandardErrorCode`. Per-page rather + // than `content/docs/**` for the reason the @objectstack/cli entry + // gives: docs are edited far more often than any package here. + // scripts/strictness-ledger.test.ts reads the audit ledger as + // `resolve(SPEC, '../../docs/audits/...')` and ratchets it against the + // schema files it inventories, so the ledger IS an input to the ratchet. + 'content/docs/api/error-catalog.mdx', + 'docs/audits/2026-07-unknown-key-strictness-ledger.md', + // src/shared/retired-key-migrate-sentence.test.ts judges the ONE + // governed markdown file its population was widened by (#10848, + // maintainer-ruled): the retirement playbook that teaches authors the + // prescription sentence the pin holds. One file, not `.claude/**`. + '.claude/skills/spec-property-retirement/SKILL.md', + ], + heldBy: { + // The two repo-wide `*.object.ts` walkers. Each seeds a recognised + // expression and then descends with `readdirSync(dir)` on a LOOP + // VARIABLE, so the escape verdict resolves and the NAME does not -- the + // trade `pathExpression` documents. Measured: no path on this package's + // roster matches this glob, so these two tests are all that hold it. + 'packages/**/*.object.ts': [ + 'packages/spec/src/data/api-methods-batch-conformance.test.ts', + 'packages/spec/src/system/constants/platform-object-names.test.ts', + ], + }, + }, + '@objectstack/core': { + // src/security/operation-private-keys.pin.test.ts walks `git ls-files` over + // the whole repo and reads every matching source file. + globs: ['packages/**/*.ts'], + }, + '@objectstack/cli': { + // src/commands/serve-verify-security-parity.contract.test.ts diffs + // cli's serve.ts against verify's harness.ts. + // It also pins plugin-security's permission-set test as a third witness. + // + // src/commands/serve-multi-node-cap-advisory.pin.test.ts reads the + // multi-node gate's own `ResolvedMultiNodeVerdict` declaration: serve.ts + // mirrors that shape by hand (the CLI has no static dependency on the + // cluster package), and the pin exists to fail when the two drift. It only + // does that if a producer-only change re-runs cli's tests, which is exactly + // what this declaration buys. + // + // The `examples/` globs are the showcase modules two i18n tests import LIVE + // across the workspace boundary, each named per file because the two read + // DIFFERENT namespaces and are not interchangeable: + // test/i18n-section-coverage.test.ts dynamically imports `contact.view` + // and `semantic-zoo.object` and asserts `toEqual` over an exhaustive + // hardcoded `_sections` key list, so any newly NAMED section in either + // module goes red -- this is what PR #8742 broke in queue build + // 31825946401, where the merge queue was the first signal. + // test/i18n-tab-coverage.test.ts dynamically imports `task-triage.page` + // and asserts the same way over `_tabs`, so it moves with that page's + // filter-only presets and is untouched by `_sections` edits. + // Per-file rather than `examples/app-showcase/**`: the modules above have no + // relative imports of their own, so the read set IS the file set, and the + // wider glob would put cli's suite on every showcase edit. Adding a live + // import outside these paths fails `check:examples-live-imports`, which + // matches each coupling target against these globs -- so narrowing here + // cannot quietly reopen the blind spot. + // + // The `content/docs` globs are hand-written prose three e2e tests pin, to + // enforce the #6730 ruling that the NDJSON exception "stays declared, not just + // implemented" -- the declaration has to be findable in the page a script + // author actually meets, so the page IS an input. All three were invisible to + // this gate until #8995 taught the detector their seed spelling, and the miss + // is not theoretical: PR #8983 reworded `deployment/index.mdx` to "one compact + // JSON document per line", which every fact survived but the literal + // `/one\s+per\s+line/i` pin did not. Undeclared, cli was outside the affected + // set, so PR CI was green and the merge queue was the first signal -- it + // dequeued the PR and took two unrelated PRs down as batch collateral. + // test/cloud-login-json-ndjson.e2e.test.ts reads deployment/cli.mdx and + // deployment/index.mdx. + // test/login-json-ndjson.e2e.test.ts reads deployment/cli.mdx and + // permissions/authentication.mdx (the page describing the device flow). + // test/login-json-noninteractive.e2e.test.ts reads deployment/cli.mdx. + // Per-page rather than `content/docs/**`: docs are edited far more often than + // any package here, and a subtree glob would put cli's e2e suite on every + // documentation PR. + // + // `connector-mcp-plugin.ts` is read by test/serve-capability-identity.test.ts, + // which pins that the connector still registers the name the #7652 repro uses + // rather than importing the class. It surfaced with the three above and has the + // same shape of blind spot. The gate could not name it until #9763: the test + // spells the path ASCENT-RELATIVE (`resolve(HERE, '../../connectors/...')`), + // which the flat literal regex cannot start a match on. The collector now + // reconstructs it, so this glob is held by the read rather than by this + // comment — it was the one entry that already documented the hole, as a fact + // about itself rather than as the general gap it turned out to be. + // + // `check-nul-bytes.mjs` is the one entry no test READS -- it is named in a + // comment in login-json-noninteractive.e2e.test.ts. The literal collector takes + // quoted paths without parsing, so a mention forces a declaration; that is the + // designed trade (over-collection can only widen a radius, never narrow one), + // and declaring one rarely-touched file is cheaper than teaching the scanner to + // tell prose from code, or than rewording a comment to dodge a scanner. + // + // `js-comment-mask.mjs` is the first entry declared for an IMPORT rather than + // a file read, and it now has TWO importers: + // src/commands/serve-verify-security-parity.contract.test.ts (#10453, + // adopting #9367's conversion) and + // src/commands/serve-audit-registration.contract.test.ts (#9863) both import + // `maskComments` from it to separate code from prose in the boot paths they + // scan. This gate did NOT demand the declaration -- its literal collector + // recognises path-shaped reads, and a relative import specifier that escapes + // the package is not one of the spellings it knows. Declared by hand because + // the coupling is real whatever the collector saw: those scans' verdicts are + // a function of this module's masking behaviour, so a change to it has to + // re-run cli's suite. The undetected-import spelling is filed separately as + // #10452; widening a radius by hand is never the reason not to file it. + // + // Its `.d.mts` sibling is declared for BOTH reasons this roster records. It + // is named in that test's prose, and the literal collector takes quoted + // paths without parsing, so a mention forces a declaration (the + // `check-nul-bytes.mjs` entry above settles that trade the same way: + // declaring the file beats rewording a comment to dodge a scanner). It is + // also a real input rather than only a mention -- it is what gives + // `maskComments` its type, so cli's `tsc --noEmit` verdict is a function of + // it. Measured, not assumed: this file arriving on main is exactly what + // turned that test's `@ts-expect-error` into a TS2578 and took the + // typecheck lanes red on a branch that never touched it. + globs: [ + 'packages/verify/src/**', + 'packages/plugins/plugin-security/src/**', + 'packages/services/service-cluster/src/**', + 'packages/connectors/connector-mcp/src/connector-mcp-plugin.ts', + 'examples/app-showcase/src/ui/views/contact.view.ts', + 'examples/app-showcase/src/data/objects/semantic-zoo.object.ts', + 'examples/app-showcase/src/ui/pages/task-triage.page.ts', + 'content/docs/deployment/cli.mdx', + 'content/docs/deployment/index.mdx', + 'content/docs/permissions/authentication.mdx', + 'scripts/check-nul-bytes.mjs', + // This gate's OWN script, the third entry of the mention shape on this + // package: test/scaffold-workspace-consistency.test.ts quotes it while + // explaining where its cross-package read is declared. Settled the way + // check-nul-bytes.mjs above is — the literal collector takes quoted paths + // without parsing, so a mention forces a declaration, and declaring one + // rarely-touched file is cheaper than rewording prose to dodge a scanner. + 'scripts/check-cross-package-test-inputs.mjs', + 'scripts/js-comment-mask.mjs', + 'scripts/js-comment-mask.d.mts', + // `translation.zod.ts` is the second entry no test READS -- named in a + // comment in test/i18n-section-coverage.test.ts, which describes it as the + // DECLARATION face of the schema that test asserts against. It appears + // here only now because that file had no `fs` read at all, so it never + // reached the scan before #10452 relaxed the pre-filter to admit + // import-only escapes; the flat literal collector then took the quoted + // path exactly as it always has. Settled the same way as + // `check-nul-bytes.mjs` above -- declaring one file beats teaching the + // scanner to tell prose from code, and this one costs nothing in practice: + // `@objectstack/spec` is a real dependency of this package, so the graph + // already re-runs these tests on any spec change. + 'packages/spec/src/system/translation.zod.ts', + // The blank template's rendered `pnpm-workspace.yaml`, READ by + // test/scaffold-workspace-consistency.test.ts (#10499). Two scaffold + // paths write that file into a new user's project — this package's + // `renderPnpmWorkspaceYaml()` and create-objectstack's literal + // template — and each package's own ratchets are package-local, so + // neither could ever fail for the other's regression. The consistency + // test compares the two RENDERED outputs, which makes the template file + // a real input to this package's verdict: a template-only diff changes + // what that test measures. Without this declaration such a diff reaches + // neither layer — `turbo ls --affected` would still pick cli up (it + // depends on create-objectstack for the shared `created-summary` + // renderer), but `@objectstack/cli#test` would hash the same and replay + // a cached green over the divergence, which is #7802's Layer B exactly. + // One file, not `packages/create-objectstack/**`: the test reads that + // template and nothing else across the boundary. + 'packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml', + ], + }, + '@objectstack/client': { + // The first entry this gate DERIVED from import specifiers rather than from + // a path-shaped read (#10452), and the reason that half was worth building: + // six tests here import six sibling packages' route ledgers directly by + // relative specifier, and nothing had ever declared any of them. + // src/client-url-conformance.test.ts and src/route-ledger-response-schema.test.ts + // import runtime, rest, service-storage, service-i18n and plugin-auth; + // src/route-ledger-coverage.test.ts imports runtime; + // src/rest-route-ledger-coverage.test.ts imports rest; + // src/service-route-ledger-coverage.test.ts imports the three services, + // service-datasource among them; + // src/auth-route-ledger-coverage.test.ts imports plugin-auth (#11359) — + // the sixth ledger's client half, added last and reading a file the + // five globs below already carried, so it widened no radius. + // Each asserts this client's URL builders still agree with the ledger the + // server side publishes, so a ledger edit changes the verdict by design. + // + // The graph does not carry it and cannot be made to: of the six, only + // `@objectstack/runtime` appears in this package's manifest at all (a + // devDependency) -- `@objectstack/rest`, the three services and + // `plugin-auth` are not dependencies in any form, which is why + // `turbo ls --affected` could not reach client from a ledger-only diff and + // `client#test` hashed the same before and after one. #7802's shape exactly, + // reached by the other spelling. + // + // Per-file rather than `packages/**/src/**`: a ledger is one file per + // package and these tests read nothing else across the boundary, so the + // radius stays the six files the imports name. The roster holds it -- an + // import added outside them fails this gate by name. + globs: [ + 'packages/runtime/src/route-ledger.ts', + 'packages/rest/src/rest-route-ledger.ts', + 'packages/services/service-storage/src/storage-route-ledger.ts', + 'packages/services/service-i18n/src/i18n-route-ledger.ts', + 'packages/services/service-datasource/src/datasource-route-ledger.ts', + 'packages/plugins/plugin-auth/src/auth-route-ledger.ts', + // Below this line: paths these tests NAME in prose rather than read. Each + // docblock cross-references the sibling conformance test it mirrors, or + // the script that records the envelope shape, and the flat literal + // collector takes quoted paths without parsing. Same designed trade as the + // `check-nul-bytes.mjs` entry on `@objectstack/cli` -- over-collection can + // only widen a radius, never narrow one, and declaring the file beats + // rewording a comment to dodge a scanner. Not claimed as real inputs: a + // sibling package's TEST file cannot change this package's verdict. The + // six globs above are the ones the imports hold. + 'packages/runtime/src/route-ledger.conformance.test.ts', + 'packages/rest/src/rest-route-ledger.conformance.test.ts', + 'packages/services/service-storage/src/storage-route-ledger.conformance.test.ts', + 'packages/services/service-i18n/src/i18n-route-ledger.conformance.test.ts', + 'packages/services/service-datasource/src/datasource-route-ledger.conformance.test.ts', + 'packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts', + 'scripts/check-route-envelope.mjs', + ], + }, + '@objectstack/lint': { + // authoring-rule-wiring / validate-rule-compilability / + // lint-startup-registry-verdict.corpus read each authoring rule's source + // by repo-relative path, plus the CLI commands dir and the runtime gate. + // + // The `examples/` globs are the showcase modules two validator tests import + // LIVE across the workspace boundary. Both assert the SHIPPED app is clean + // rather than pinning a fixed shape -- #8515 lifted the pinned-shape cases + // onto the frozen `showcase-shape.fixtures.ts` snapshot, so what survives + // live are the cases that must keep resolving against the real app: + // src/validate-translatable-sections.test.ts imports `Contact`, + // `ContactViews` and `ShowcaseTranslationBundle`; a section introduced + // WITHOUT a name moves it, a correctly named one does not. + // src/validate-translation-references.test.ts imports `Contact` and + // `ContactViews` and asserts every translation key still resolves, so + // renaming or removing a Contact field, view, section or action moves + // it while adding one generally does not. + // Per-file for the same reason as `@objectstack/cli` above: these modules + // have no relative imports of their own, and a live import added outside + // them fails `check:examples-live-imports` by name. + globs: [ + 'packages/cli/src/commands/**', + 'packages/metadata-protocol/src/**', + 'packages/objectql/src/validation/**', + 'packages/services/service-automation/src/**', + 'examples/app-showcase/src/data/objects/contact.object.ts', + 'examples/app-showcase/src/system/translations/index.ts', + 'examples/app-showcase/src/ui/views/contact.view.ts', + ], + heldBy: { + // `const commandsDir = join(repoRoot, 'packages/cli/src/commands')` + // resolves, but every read off it is `readFileSync(join(commandsDir, + // file))` with `file` a variable: the roster gets the DIRECTORY, which a + // file-position read cannot put on it, and never one of the files. + 'packages/cli/src/commands/**': ['packages/lint/src/authoring-rule-wiring.test.ts'], + }, + }, + '@objectstack/platform-objects': { + // src/managed-api-method-affordance-sweep.test.ts (#7934) imports every + // `*.object.ts` in the monorepo and runs `validateManagedApiMethods` over + // it — the population `os lint` never walks, because these objects ship as + // code rather than in an authored stack. + globs: ['packages/**/*.object.ts'], + }, + '@objectstack/plugin-auth': { + // src/managed-extension-fields.test.ts walks every `*.object.ts`, and pins + // core's api-key source alongside it. + globs: [ + 'packages/**/*.object.ts', + 'packages/core/src/security/**', + // src/rate-limit-storage-isolation.test.ts (#6040) walks BOTH consumer + // packages of the `./rate-limit-storage` subpath by directory, checking + // that neither reaches the counter through the package ROOT — which would + // silently reinstate the whole better-auth load for them. The diff that + // breaks that invariant is a diff in one of these two directories, so + // without them declared the affected-subset filter never adds plugin-auth + // and turbo replays a cached green over the scan (#10029, the #7802 + // shape). Measured: before this entry, `@objectstack/plugin-auth#test` + // hashed to `1bf3935543ab055b` both before and after a change under + // `packages/runtime/src`, and the re-run was `>>> FULL TURBO` in 135ms + // while the invariant was live-broken in the tree. + 'packages/runtime/src/**', + 'packages/services/service-sms/src/**', + // The three below are NAMED in that test's prose rather than read by it — + // the same shape as `check-nul-bytes.mjs` on the @objectstack/cli entry + // above, and settled the same way: the literal collector takes quoted + // paths without parsing, so a mention forces a declaration, and declaring + // the file is cheaper than rewording prose to dodge the scanner. All + // three are low-churn, so the added cache invalidation is nominal next to + // the two directories above. + 'scripts/check-published-files.mjs', + 'scripts/check-cross-package-test-inputs.mjs', + 'packages/types/src/node-isolation.test.ts', + ], + heldBy: { + // The pair #10566 was measured on. That test's walk of `PACKAGES_DIR` + // descends on a loop variable, so no `*.object.ts` path reaches this + // package's roster and this glob has no mechanical holder. Since #10161 + // gave plugin-auth a SECOND escaping test, losing the walk no longer + // empties the package either -- the entry stays, the glob goes unheld, + // and before this witness existed nothing reported it. + 'packages/**/*.object.ts': ['packages/plugins/plugin-auth/src/managed-extension-fields.test.ts'], + }, + }, + '@objectstack/plugin-security': { + // src/audience-anchor-set-claims.pin.test.ts pins against spec's + // high-privilege table, and cross-checks spec's own delegatable pin. + globs: ['packages/spec/src/security/**', 'packages/spec/src/identity/**'], + }, + '@objectstack/trigger-record-change': { + // [#11081] src/record-change-integration.test.ts imports + // `@objectstack/runtime`'s shared expected-noise capture so its 84 expected + // authz/organization read refusals are WITHHELD-AND-ASSERTED rather than + // blanket-muted by `logger: { level: 'silent' }`. + // + // ONE file, not `packages/runtime/src/**` (which is the radius + // `plugin-auth` and `dogfood` carry): the helper has no imports of its own, + // so that single path IS the whole escaping read. The narrow radius keeps + // this package's suite off every runtime diff while still moving the + // `#test` hash when the predicate it depends on changes. + // + // The three below are NAMED in this package's prose rather than read by it + // — `slot-lookup-baseline.json` and `kernel.ts` by comments that predate + // this entry, `check-cross-package-test-inputs.mjs` by the import comment + // added with it. Same shape as `check-nul-bytes.mjs` on the + // `@objectstack/cli` entry above, and settled the same way: the literal + // collector takes quoted paths without parsing, so a mention forces a + // declaration, and declaring the file is cheaper than rewording prose to + // dodge the scanner. (They were invisible until now only because a package + // with NO escaping test is never rostered at all.) + globs: [ + 'packages/runtime/src/expected-read-refusal-noise.ts', + 'scripts/check-cross-package-test-inputs.mjs', + 'scripts/slot-lookup-baseline.json', + 'packages/core/src/kernel.ts', + ], + }, + '@objectstack/plugin-approvals': { + // [#11081] src/status-mirror-cascade.integration.test.ts imports the same + // capture for its 25 expected refusals (the six authz tables plus + // `sys_approval_delegation`). Same one-file radius, same reason — plus this + // gate's own path, named in that file's import comment (see the note on the + // sibling entry above for why a mention is declared rather than reworded). + // + // [#11286] src/manager-org-screen-parity.contract.test.ts imports + // `managerIsProvablyOutsideOrg` from plugin-sharing's `team-graph.ts` BY + // RELATIVE SOURCE PATH — the only way in, since that screen is deliberately + // not exported from plugin-sharing's index and the package publishes no + // subpath. The test pins the two independent screens over + // `sys_user.manager_id` to the same verdicts, so the sharing screen is a + // real input to it: without this glob a change THERE would never re-run the + // pin HERE, which is the exact blind spot this gate exists for. One file, + // not `plugin-sharing/src/**` — nothing else in that package is read. + globs: [ + 'packages/runtime/src/expected-read-refusal-noise.ts', + 'scripts/check-cross-package-test-inputs.mjs', + 'packages/plugins/plugin-sharing/src/team-graph.ts', + ], + }, + '@objectstack/dogfood': { + // test/*-conformance.test.ts read a fixed roster of probe files across + // runtime, rest, plugins and services by repo-relative path. Narrow to the + // roster rather than `packages/**/src/**`: the literal-coverage check below + // fails the moment a probe is added outside these, so narrowing here cannot + // quietly reopen the blind spot. + globs: [ + 'packages/client/src/**', + 'packages/mcp/src/**', + 'packages/plugins/plugin-hono-server/src/**', + 'packages/rest/src/**', + 'packages/runtime/src/**', + 'packages/services/service-realtime/src/**', + // The three ledgers test/route-ledger-live-mount-parity.dogfood.test.ts + // IMPORTS, which no read named and nothing declared until #10452 taught + // this gate specifiers. That test mounts the live app and asserts every + // ledger entry is really routed, so each ledger is an input by + // construction. Per-file, matching what the imports name: the rest of + // these services' `src/**` is not read here. + 'packages/services/service-storage/src/storage-route-ledger.ts', + 'packages/services/service-i18n/src/i18n-route-ledger.ts', + 'packages/services/service-settings/src/settings-route-ledger.ts', + // flow-trigger / validation conformance pin spec's zod schemas. + 'packages/spec/src/automation/**', + 'packages/spec/src/data/**', + // showcase-declarative-*.dogfood.test.ts chdir into the showcase app and + // compile it, so the app IS an input, and they assert on the artifact the + // compile pipeline and the metadata plugin produce. + 'examples/app-showcase/**', + 'packages/cli/src/commands/**', + 'packages/metadata/src/**', + // `realtime-protocol.mdx` is named in a comment rather than read, the + // same shape as `check-nul-bytes.mjs` on the @objectstack/cli entry + // above and settled the same way: a mention forces a declaration, and + // declaring the file is cheaper than rewording prose to dodge the + // scanner. Here the coupling is real on top of being cheap — that page + // is what documents the PLANNED realtime transports (`/ws`, SSE + // `/api/v1/stream`), and the #2992 transport tripwires in + // authz-conformance.test.ts are only correct for as long as they cover + // those spellings. A third transport added to the page is exactly the + // change that reopens the #9084 blind spot, so it must re-run this test. + 'content/docs/protocol/kernel/realtime-protocol.mdx', + ], + }, + '@objectstack/formula': { + // src/rls-predicate.test.ts pins spec's RLS zod source against the + // predicate compiler; src/skill-catalog-sync.test.ts pins the published + // formula skill's stdlib table against the implementation. + globs: ['packages/spec/src/security/rls.zod.ts', 'skills/objectstack-formula/**'], + }, + '@objectstack/rest': { + // src/meta-state-route-doc-spelling.test.ts reads the two published prose + // sites that teach the `meta.getLegalNextStates` route and asserts each + // spells it the way this package's REST_ROUTE_LEDGER row does, so the + // ledger row and the prose can no longer drift apart in silence (#10178). + // Per-file rather than `content/docs/**` or `skills/**` for the reason the + // @objectstack/spec entry gives: those roots are edited far more often than + // anything this radius really depends on. + globs: [ + 'content/docs/protocol/objectql/state-machine.mdx', + 'skills/objectstack-automation/SKILL.md', + ], + }, + '@objectstack/metadata-protocol': { + // src/sys-metadata-repository.draft-drain.test.ts reads the durability + // log-level gate's own source to pin that the repository stays inside it. + globs: ['scripts/check-durability-degradation-log-level.mjs'], + }, + '@objectstack/downstream-contract': { + // test/source-resolution.pin.test.ts resolves every spec specifier a + // downstream consumer can import, against spec's real source tree and the + // `exports` map in its package.json. + globs: ['packages/spec/src/**', 'packages/spec/package.json'], + heldBy: { + // `SPEC_SRC` resolves, but the files under it are reached as + // `existsSync(target)` where `target` was computed out of the `exports` + // map, so the roster holds `packages/spec/package.json` and nothing at + // all under `src`. + 'packages/spec/src/**': ['packages/qa/downstream-contract/test/source-resolution.pin.test.ts'], + }, + }, + 'create-objectstack': { + // src/template-consistency.test.ts reads doc frontmatter by repo-relative + // path to decide which templates are internal. + // + // `sync-template-versions.mjs` is a real cross-package READ. Since #9648, + // src/template-version-stamps.test.ts loads the script by URL to assert its + // declaration surface (`stampedPaths()`, `findTemplateDirs()`, the + // `TEXT_STAMPS` table) and runs it with `execFileSync` over a two-template + // fixture (#9554). It was a mention before that test existed, which is what + // the rationale here used to say. + // + // The glob does not rest on that one test, so deleting it would not make + // this declaration wrong — only smaller. The script STAMPS the three + // per-template version surfaces (`package.json` @objectstack/* ranges, + // `objectstack.config.ts` `engines.protocol`, `objectstack.manifest.json` + // `specVersion`) that template-consistency.test.ts ratchets, so a change to + // the stamper is exactly the change those ratchets exist to catch (#9264). + // + // What FORCES the glob is now the read itself. Both tests spell the path as + // `join(repoRoot, 'scripts', 'sync-template-versions.mjs')`, which the flat + // literal collector below cannot see — it only matches a whole repo-relative + // path inside ONE quoted string — so until #9763 what actually held this + // declaration was the quoted MENTION in each test's header comment, and + // rewording either one into unquoted prose unforced a live radius. + // Measured on 06f9848f9, before the fix: drop the glob and unquote both + // mentions and the gate printed `OK ... exit 0`. Measured after: the same + // ablation fails naming template-version-stamps.test.ts, the file that + // really reads. The mentions are ordinary prose again — free to reword. + // + // `.github/workflows/scaffold-e2e.yml` is READ, not merely mentioned: + // src/scaffold-e2e-boot-probe.test.ts extracts the three boot-and-probe + // `run:` scripts out of that file and EXECUTES them, so the workflow is + // literally the code under test. It is the workflow that gates this package + // (its `paths:` filter is `packages/create-objectstack/**`), which is why + // the test lives here rather than beside a shell script in spec (#9779). + // + // Three of the remaining four are NAMED in a test's header rather than + // read, the same shape as `check-nul-bytes.mjs` above and settled the + // same way: the literal collector takes quoted paths without parsing, so + // a mention forces a declaration, and declaring a rarely-touched file is + // cheaper than rewording prose to dodge a scanner. `serve.ts` earns it on + // the merits too — its `flags.dev || NODE_ENV === 'development'` + // port-shift gate is the single fact that decides which fix those + // workflow blocks need, so a change to that branch is exactly the change + // the test's premise would need re-measuring against. The two sibling + // scripts are cited for the contrast that keeps the fixes from being + // copied between them. + // + // `packages/cli/src/commands/init.ts` is the fourth of that shape (#10322): + // scaffold-next-steps-pm.test.ts's header quotes it in backticks while + // explaining that `init.ts`'s own "Next steps" output already threads its + // detected `chosenPm` the same way this package's scaffolder now does — + // it is cited for the contrast, never read. The test execs + // `create-objectstack`'s own CLI via `tsx`, not `init.ts`. + globs: [ + 'content/**', + 'scripts/sync-template-versions.mjs', + // The stamper's own import closure, and a live input for the same reason + // the stamper is: template-version-stamps.test.ts copies the script into + // a fixture and both IMPORTS and SPAWNS it there, so the copy needs every + // relative import the script makes. That fixture derives the closure + // rather than naming files, so this path appears in NO quoted string the + // flat literal collector can see — but a change to it really does break + // that test (measured: drop the closure walk and the same 3 cases fail + // with ERR_MODULE_NOT_FOUND), which is exactly the trigger radius this + // declaration exists to keep honest. + 'scripts/invoked-as.mjs', + '.github/workflows/scaffold-e2e.yml', + 'packages/cli/src/commands/serve.ts', + 'scripts/gen-sdui-manifest.sh', + 'scripts/publish-smoke.sh', + 'packages/cli/src/commands/init.ts', + ], + heldBy: { + // Read through `git grep -- content/docs` and `git ls-files`, so the + // paths are process OUTPUT rather than literals: the pathspec itself is + // the only quoted thing, and a directory in file position never reaches + // the roster. + 'content/**': ['packages/create-objectstack/src/template-consistency.test.ts'], + // The glob whose own rationale above already states the shape this + // witness records: the fixture derives the stamper's import closure + // instead of naming it, so this path "appears in NO quoted string the + // flat literal collector can see" while a change to it really does break + // that test (measured there). + 'scripts/invoked-as.mjs': ['packages/create-objectstack/src/template-version-stamps.test.ts'], + }, + }, +}; diff --git a/scripts/glob-match.mjs b/scripts/glob-match.mjs new file mode 100644 index 0000000000..d0e0b0347d --- /dev/null +++ b/scripts/glob-match.mjs @@ -0,0 +1,189 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * glob-match -- the ONE turbo-flavoured glob predicate the `scripts/` gates + * share, and deliberately nothing else. + * + * Supports the three constructs the declarations in this repo use, and only + * those: `**` (any number of whole path segments), `*` (within one segment), + * and literals. Dependency-free on purpose -- these gates run in CI before + * anything is built, and a `scripts/` gate that can fail on a resolution + * problem is a gate that gets muted. + * + * ## Why a plain module, and why it is SEPARATE from the table (#11511) + * + * `globToRegExp` used to live in `scripts/check-cross-package-test-inputs.mjs` + * beside the declaration table it is applied to. Two other gates read out of + * that file, and `scripts/pm/dispatch-gates.mjs` follows a gate's first-party + * imports one level but NEVER into a module that is itself a discovered gate + * file -- so neither importer derived anything from it. + * + * Splitting the two halves out is what fixes that, and they had to land in TWO + * modules rather than one. The follow appends a followed module's watch hints + * to every importer WHOLE, regardless of which binding the importer named, so a + * single shared module would have handed `check:examples-live-imports` -- which + * wants this predicate and whose own subject is `examples/` -- the whole + * cross-package declaration table. Measured on 589758d22: + * + * one shared module check:examples-live-imports 241 -> 3346 (+3105) + * this split check:examples-live-imports 241 -> 241 ( 0) + * + * The declarations therefore live in `scripts/cross-package-test-inputs.mjs`, + * which is imported only by the two gates that really read them. + * + * ## ⛔ THIS MODULE DECLARES NO PATH POPULATION, AND MUST NOT GROW ONE + * + * That zero above is the whole point of this file existing separately, so it is + * pinned mechanically rather than by review: `selfTest` reads THIS FILE's own + * bytes and fails on any path-shaped literal in the module body -- in the + * predicate, in an error message, or in a fixture. Every fixture below is + * ASSEMBLED from segments for that reason, the same discipline and the same + * measured reason as `scripts/workspace-enumerator.mjs`. + * + * A glob belongs to whatever gate or table DECLARES it. What is consolidated + * here is the SEMANTICS of matching one, never a list of them. + * + * ## Inert on import + * + * No CLI and no top-level statement that runs anything, per `check:entry-guard`'s + * second rule. That rule exists because importing a gate for its exports used to + * run the gate: `check-examples-live-imports.mjs` hand-copied `globToRegExp` + * rather than pay that cost, and the copy was only retired once #10610 put the + * dispatch behind an entry guard. Two copies of these semantics is the drift + * this module now makes impossible -- the gates that decide whether a declared + * input glob really covers a read MUST agree about what `**` means. + */ + +import { readFileSync } from 'node:fs'; + +import { maskComments } from './js-comment-mask.mjs'; + +/** + * One glob, anchored, as a RegExp over repo-relative POSIX paths. + * + * @param {string} glob + * @returns {RegExp} + */ +export function globToRegExp(glob) { + let re = ''; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + // `**/` consumes zero or more whole segments; a trailing `**` consumes the rest. + if (glob[i + 2] === '/') { + re += '(?:[^/]+/)*'; + i += 2; + } else { + re += '.*'; + i += 1; + } + } else { + re += '[^/]*'; + } + } else if ('.+?^${}()|[]\\/'.includes(c)) { + re += `\\${c}`; + } else { + re += c; + } + } + return new RegExp(`^${re}$`); +} + +/** + * Whether any of `globs` matches `path`. + * + * @param {string} path repo-relative, POSIX separators + * @param {string[]} globs + * @returns {boolean} + */ +export function matchesAny(path, globs) { + return globs.some((g) => globToRegExp(g).test(path)); +} + +/** + * The shared assertions, returned rather than printed so each importing gate + * can fold them into its own `--self-test` report. + * + * This module is deliberately not a gate (see the header), so it has no CI + * invocation of its own: its coverage is that its importers run `--self-test` + * in lint.yml and call this. + * + * @returns {string[]} failure descriptions; empty means OK + */ +export function selfTest() { + const failures = []; + const t = (name, ok) => { + if (!ok) failures.push(`glob-match: ${name}`); + }; + // Every fixture path is ASSEMBLED, never spelled -- see the header's "declares + // no path population" rule, which the last case in this function enforces + // against these very lines. + const P = (...segments) => segments.join('/'); + const OBJ = '*.object.ts'; + + // ── `**` spans whole segments ───────────────────────────────────────────── + t('** spans segments', matchesAny(P('packages', 'platform-objects', 'src', 'identity', 'x.object.ts'), [P('packages', '**', OBJ)])); + t('** matches a direct child', matchesAny(P('packages', 'a.object.ts'), [P('packages', '**', OBJ)])); + t('trailing ** matches a subtree', matchesAny(P('packages', 'lint', 'src', 'rules', 'a.ts'), [P('packages', 'lint', 'src', '**')])); + + // ── `*` stays inside one segment ────────────────────────────────────────── + t('* does not span segments', !matchesAny(P('packages', 'a', 'b.object.ts'), [P('packages', OBJ)])); + t('* matches within one segment', matchesAny(P('packages', 'a.object.ts'), [P('packages', OBJ)])); + + // ── literals are literal ────────────────────────────────────────────────── + const IDX = P('content', 'docs', 'references', 'index.mdx'); + t('a literal file glob matches itself', matchesAny(IDX, [IDX])); + t('the dot is a literal, not a wildcard', !matchesAny(IDX.replace('/docs', 'Xdocs'), [IDX])); + t('a non-matching extension is rejected', !matchesAny(P('packages', 'x', 'src', 'a.ts'), [P('packages', '**', OBJ)])); + + // The distinction `coversDirectory` exists for, pinned from this side too: a + // subtree glob is written to match FILES, so it does NOT match the bare + // directory string it covers. + t('a subtree glob does not match the bare directory it covers', !matchesAny(P('packages', 'lint', 'src'), [P('packages', 'lint', 'src', '**')])); + + t('an empty glob list matches nothing', !matchesAny(P('packages', 'a.ts'), [])); + t('the regex is anchored at both ends', !globToRegExp(P('packages', 'a.ts')).test(P('vendor', 'packages', 'a.ts', 'b'))); + + // ── the property this module exists to keep: NO path population ─────────── + // + // Read off THIS FILE's own bytes so a stale copy cannot satisfy it. A + // path-shaped literal added here is inherited as a watch hint by every + // importing gate: priced in the header at +3105 (gate, file) pairs for the + // single caller that wants nothing but a predicate. + // + // The predicate below is deliberately STRICTER than the one it guards + // (`extractWatchHints` in scripts/pm/dispatch-gates.mjs): any quoted literal + // containing a separator counts here, where that scanner also applies + // namespace refusals and self-test masking. It can therefore only refuse + // MORE than the real scanner -- it fails loudly for a literal the derivation + // would have ignored, and never passes one the derivation would have taken. + // + // `maskComments`, never a hand-rolled comment strip: a glob CONTAINS a + // comment opener -- the `/` and `*` of a `packages/*` are exactly `/*` -- so a + // naive stripper starts a comment at the literal it is looking for and eats + // forward to the next `*/`, deleting the evidence. Measured on + // workspace-enumerator, where that mistake let a planted population through. + try { + const self = readFileSync(new URL(import.meta.url), 'utf8'); + const body = maskComments(self); + const offending = [...body.matchAll(/['"`]([^'"`\n]{2,120})['"`]/g)] + .map((m) => m[1]) + .filter((raw) => /^[\w.@][\w.@/*-]*$/.test(raw)) + // The same leading-`./` strip extractWatchHints applies before it asks + // whether a literal is pathy, so a relative import specifier scores the + // way it really scores there (no separator left -> not a hint) instead of + // reading as a population declaration. + .map((raw) => raw.replace(/^(?:\.\.?(?:\/|$))+/, '')) + .filter((raw) => raw.includes('/')) + .filter((raw) => !raw.startsWith('node:')); + t( + `no path-shaped literal in this module body can become a watch hint (found: ${offending.join(', ') || 'none'})`, + offending.length === 0, + ); + } catch (err) { + failures.push(`glob-match: could not read own source to check for path literals (${err?.message})`); + } + + return failures; +} diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index e095491479..6d1c0b5c4a 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -4301,12 +4301,19 @@ function selfTest() { // gates rather than fixtures — a fixture cannot show that the tree still has // the shape. If one of these gates stops declaring its root, re-point the // case at whatever gate then does; deleting one deletes the evidence. - const crossPkgHints = extractWatchHints(readFileSync(join(ROOT, 'scripts/check-cross-package-test-inputs.mjs'), 'utf8')); + // Re-pointed at the module that DECLARES the table (#11511): it moved out of + // check-cross-package-test-inputs.mjs into a plain module precisely so the + // follow below could reach it, and this read is of the declaration, not of + // the gate. Exactly what the paragraph above asks for -- "if one of these + // gates stops declaring its root, re-point the case at whatever gate then + // does". Left pointing at the gate it would have gone green over an empty + // hint list, which is the vacuous-pass shape these cases exist to refuse. + const crossPkgHints = extractWatchHints(readFileSync(join(ROOT, 'scripts/cross-package-test-inputs.mjs'), 'utf8')); // NOT `scripts/check-nul-bytes.mjs`: that gate names that file explicitly // too, so the case would pass with the declaration still refused — measured, // it survived the ablation. Pick a scripts path reachable ONLY through the // declared subtree, or the case pins nothing. - t('the cross-package gate reaches the root scripts dir it declares', crossPkgHints.some((h) => hintCovers(h, 'scripts/pm/dispatch-gates.mjs'))); + t('the cross-package declaration table reaches the root scripts dir it declares', crossPkgHints.some((h) => hintCovers(h, 'scripts/pm/dispatch-gates.mjs'))); t('and the content tree it declares', crossPkgHints.some((h) => hintCovers(h, 'content/docs/getting-started/index.mdx'))); const governedHints = extractWatchHints(readFileSync(join(ROOT, 'scripts/pm/check-governed-merges.mjs'), 'utf8')); t('the governed-merge gate reaches the published skills catalog it declares', governedHints.some((h) => hintCovers(h, 'skills/objectstack-upgrade/SKILL.md')));