diff --git a/scripts/pm/dispatch-gates.data.mjs b/scripts/pm/dispatch-gates.data.mjs index 6d27a6cfc98..76306d98fd6 100644 --- a/scripts/pm/dispatch-gates.data.mjs +++ b/scripts/pm/dispatch-gates.data.mjs @@ -936,6 +936,13 @@ export const ESCAPABLE_LITERAL_LEDGER_ROWS = Object.freeze([ * each test "really" reads does NOT qualify: that map is a second thing * that goes stale, and the run is cheap enough not to need one (measured * below). + * - population-net entries (#31257, fifteen): when the gate's own source + * declares a population this derivation can MATCH (a subtree it really + * walks, beside a filter the derivation reads), the ordinary path match + * names it and the entry is redundant. ⛔ Re-deciding a REFUSE-WIDE row to + * make a path match possible does NOT qualify (it takes a ruling), and ⛔ + * neither does the net going quiet on a card: the net is the gate's own + * first filter, so a quiet net is a quiet gate, not a cleared one. * * * Delete an entry the day its criterion is met, not before. @@ -1120,6 +1127,156 @@ export const CHANGE_KIND_ROWS = [ }, ], }, + { + kind: "reaches a plugin init-time service lookup or a providesServices declaration (the init-service gate's own pre-filter over non-test package TypeScript, read from CONTENT)", + matches: 'init-service-lookup', + gates: [ + { + name: 'check:init-service-contract', + why: "every init()-time service lookup (getService and its two siblings) a plugin unit makes must name a service its own declarations order ahead of it, against the providers the whole tree declares in providesServices. So adding a lookup, dropping a dependency, or removing or renaming a provider moves it. Its population is a whole root, so it printed in the declared-WIDE block and no card was handed it (#31257); this row reaches it through a population net, the gate's own walk plus its own first filter, measured against the gate's own pass (217 of 4498 files; its --list names 31, none missed). It needs NO build, about 3 seconds. Repair by declaring the provider in dependencies or optionalDependencies, or by moving the lookup to start() or a ready hook; never by deleting the declaration that made it pass", + }, + ], + }, + { + kind: "reaches a settings read or a hook emit before the settings bind window (the settings bind-window gate's own pre-filter over non-test package TypeScript, read from CONTENT)", + matches: 'settings-bind-read', + gates: [ + { + name: 'check:settings-bind-window', + why: "a plugin may not read the settings service from a hook that fires before settings binds, and the pinned list of pre-bind hooks is re-derived from every trigger() site. So a new settings read, a new early emit, or a provider change moves it. Its population is a whole root, so it printed in the declared-WIDE block and no card was handed it (#31257); this row reaches it through a population net, the gate's own walk plus its own first filter, measured against the gate's own pass (657 of 4498 files; its --list names 11, none missed). ⚠ It follows a hook name through an import, and the net reads the judged file only. It needs NO build, about 4 seconds. Repair by reading settings from a phase at or after the bind, or by declaring the settings provider; the shrink-only ledger is not a quick fix", + }, + ], + }, + { + kind: "reaches a startup-time service-registry probe, an open capability registry or a providesServices declaration (the startup-registry gate's own pre-filter over non-test package TypeScript, read from CONTENT)", + matches: 'startup-registry-probe', + gates: [ + { + name: 'check:startup-registry-verdict', + why: "a constructor or init() that probes the service registry (getService, tryGetService, hasService and kin) or enumerates an open capability registry may not record a final verdict the boot can still contradict, judged against the providers the whole tree declares. So a new probe, a new recorded verdict, or a provider change moves it. Its population is a whole root, so it printed in the declared-WIDE block and no card was handed it (#31257); this row reaches it through a population net, the gate's own walk plus its own first filter, measured against the gate's own pass (222 of 4498 files; its --list names 29, none missed). It needs NO build, about 4 seconds. Repair by deferring the verdict past the boot (or declaring the provider so it is final); a baseline row is a recorded debt, never a quick fix", + }, + ], + }, + { + kind: "declares a logger-shaped sink or calls a log channel (the optional-error-sink gate's own CHANNEL_PREFILTER over non-test package TypeScript, read from CONTENT)", + matches: 'optional-error-sink', + gates: [ + { + name: 'check:optional-error-sink', + why: "every sink type that declares an optional `error` channel must also guarantee a `warn` fallback, so a degraded durability report cannot be printed into silence; the baseline is shrink-only and a baselined sink that turned green fails as stale. So a new or reshaped logger type moves it. Its population is a whole root, so it printed in the declared-WIDE block and no card was handed it (#31257); this row reaches it through a population net, the gate's own walk plus its own first filter, measured against the gate's own pass (1070 of 4498 files, the gate's own pre-filter is that wide; its --list names 204, none missed). It needs NO build, about 5 seconds. Repair by making `warn` required beside the optional `error`, or `error` required; never by baselining a new sink", + }, + ], + }, + { + kind: "calls defineActionDescriptor (the resume-authority gate's own pre-filter over shipped package and example TypeScript, read from CONTENT)", + matches: 'action-descriptor', + gates: [ + { + name: 'check:resume-authority-declared', + why: "every defineActionDescriptor() literal that can pause a flow must declare its resumeAuthority, and the scan must find at least one descriptor. So a new or edited descriptor moves it. Its population is a whole root, so it printed in the declared-WIDE block and no card was handed it (#31257); this row reaches it through a population net, the gate's own walk plus its own first filter, measured against the gate's own pass (19 of 4691 files; its --list names 16, none missed). It needs NO build, about 1 second. Repair by declaring resumeAuthority on the descriptor", + }, + ], + }, + { + kind: "adds or edits a route module or a response writer: a -routes.ts file, a named off-convention module, a dispatcher domain, or any non-test package TypeScript calling .json( (the route-envelope gate's own discovery, read from the PATH and CONTENT)", + matches: 'route-response-writer', + gates: [ + { + name: 'check:route-envelope', + why: "every module that answers REST must be declared in its MODULES table with the envelope it writes, and every Hono or Express response writer its walk discovers outside that table fails as NOT DECLARED; the dispatcher domains are enumerated exhaustively. So a new route module, a new writer or a reshaped body moves it, and so does one the table names going away. Its population is a whole root, so it printed in the declared-WIDE block and no card was handed it (#31257); this row reaches it through a population net, the gate's own walk plus its own first filter, measured against the gate's own pass (160 of 4498 files; all 56 files the MODULES table names are inside it). It needs NO build, about 4 seconds. Repair by declaring the module (or the writer) with the envelope it really writes; the closed non-conforming list only shrinks", + }, + ], + }, + { + kind: "calls a guarded verify stand-in helper, or edits the verify package source that declares them (the verify stand-in gate's own census pre-filter, read from CONTENT)", + matches: 'verify-stand-in-call', + gates: [ + { + name: 'check:verify-stand-in', + why: "every exported verify helper whose first parameter is a published stand-in interface must be classified (guarded in eslint.config.mjs VERIFY_STAND_IN_CHECKS, or exempt with a reason), and no call site of a guarded helper may erase the driver argument's type with an assertion. So a new helper in packages/verify/src, or a new or edited call site anywhere in packages or examples, moves it. Its population is a whole root, so it printed in the declared-WIDE block and no card was handed it (#31257); this row reaches it through a population net, the gate's own walk plus its own first filter, measured against the gate's own pass (83 of 12003 files: the guarded names, read from the ESLint config the gate imports, plus the verify source). It needs NO build, about 2 seconds. Repair by passing a driver that satisfies the stand-in without a cast, or by classifying a new helper", + }, + ], + }, + { + kind: "mounts a wildcard route (a quoted pattern ending in /*, the wildcard gate's own pre-filter over non-test package TypeScript, read from CONTENT)", + matches: 'wildcard-mount', + gates: [ + { + name: 'check:wildcard-fallthrough', + why: "every handler mounted on a wildcard namespace must be declared in its MOUNTS table as yielding (it takes next and calls it), exempt with a reason, or ratcheted against an issue, and a declared mount that is gone fails as stale. So a new or edited wildcard mount moves it. Its population is a whole root, so it printed in the declared-WIDE block and no card was handed it (#31257); this row reaches it through a population net, the gate's own walk plus its own first filter, measured against the gate's own pass (143 of 4498 files; its --list names 7, none missed). It needs NO build, about 2 seconds. Repair by yielding to the next handler, or by declaring the mount; never by leaving one unaudited", + }, + ], + }, + { + kind: "names a live test database URL (OS_TEST_MYSQL_URL or OS_TEST_POSTGRES_URL, the live-db gate's own marker for a live suite, read from CONTENT)", + matches: 'live-db-suite', + gates: [ + { + name: 'check:live-db-isolation', + why: "no live suite anywhere in packages, apps or examples may name its database or schema with a constant, because CI points every live leg at one shared server and every suite drops what it created; at least two live suites must be found. So a new live suite, or a renamed database in one, moves it. Its population is a whole root, so it printed in the declared-WIDE block and no card was handed it (#31257); this row reaches it through a population net, the gate's own walk plus its own first filter, measured against the gate's own pass (101 of 12010 files, test files included as the gate reads them; all 65 live suites inside it). It needs NO build, about 6 seconds. Repair by deriving the name from the test file, as the driver-sql matrix does", + }, + ], + }, + { + kind: "reads a test-runner variable (TEST, VITEST or JEST_WORKER_ID) in product source under packages, apps or examples (judged by the runner-env gate's OWN reader, imported, read from CONTENT)", + matches: 'runner-env-read', + gates: [ + { + name: 'check:runner-env-posture', + why: "product source (under a src segment, not a test, not generated) must not branch on a test-runner variable. This row asks the gate's own isProductSource and findRunnerEnvReads, imported rather than copied, so it fires exactly on a file the gate would flag. Its population is a whole root, so it printed in the declared-WIDE block and no card was handed it (#31257); this row reaches it through a population net, the gate's own walk plus its own first filter, measured against the gate's own pass. It needs NO build, about 9 seconds. Repair by injecting the behaviour the test needs instead of sniffing the runner", + }, + ], + }, + { + kind: "adds or edits non-test source under packages/rest/src (the meta-type gate's whole population, read from the PATH)", + matches: 'meta-type-route-source', + gates: [ + { + name: 'check:meta-type-normalized', + why: "no decision in the REST package may be made on the RAW /meta/:type route param, because the plural spelling is canonical and a raw comparison is the authorization bypass it already was three times. So any non-test source there moves it. Its population is one directory, but its declared-WIDE reason is the test-file filter no glob spells, so it printed in the declared-WIDE block and no card was handed it (#31257); this row names that whole population by path: 56 files, the gate's own count. It needs NO build, under a second. Repair by normalising once with RestServer.metaTypeSingular and comparing that local", + }, + ], + }, + { + kind: "adds, edits, moves or deletes a sql-driver seam module directly under the driver-sql package source (the tenant gate's own directory listing, read from the PATH)", + matches: 'sql-driver-seam-module', + gates: [ + { + name: 'check:tenant-chokepoint', + why: "every read query builder in the SQL drivers must route through applyTenantScope, and the gate judges the three driver files plus every sql-driver-*.ts module it LISTS from the driver-sql source directory at run time. Its watch hints spell the modules that exist, so a NEW seam module (the next split of the driver) printed in the artifact-roster block and no card was handed it (#31257); this row names the listed population by path (36 modules). It needs NO build, about 1 second. Repair by routing the builder through applyTenantScope, or by recording the unscoped one where the gate says", + }, + ], + }, + { + kind: "reads both authorization grant tables in non-test package TypeScript (the authz-resolver gate's own question, read from CONTENT)", + matches: 'grant-table-reader', + gates: [ + { + name: 'check:authz-resolver', + why: "request-context grant aggregation must live only in the shared core resolver: any non-test package file that queries every grant table (sys_user_position and sys_user_permission_set) and is not allow-listed with a reason fails as a possible duplicate resolver. So a new file reading both tables moves it. It printed in the artifact-roster block: it spells the files it already knows, and the walk that finds the next one is a whole root, so no card touching an unlisted file was handed it (#31257); this row reaches it through a population net, the gate's own walk plus its own first filter (62 of 4489 files name both tables). It needs NO build, about 2 seconds. Repair by delegating to resolveAuthzContext; an ALLOW entry needs a reason a reader can check", + }, + ], + }, + { + kind: "stamps a lowercase error code in package TypeScript (judged by the error-code-casing gate's OWN reader, imported, read from CONTENT)", + matches: 'lowercase-error-code', + gates: [ + { + name: 'check:error-code-casing', + why: "an error code in a code position (an emission, assignment, comparison, union member or fallback) must be SCREAMING_SNAKE (ADR-0112). This row asks the gate's own findViolations, imported rather than copied, so it fires exactly on a file the gate would flag (today 4 files, all on its exempt list). It printed in the artifact-roster block: it spells the files it already knows, and the walk that finds the next one is a whole root, so no card touching an unlisted file was handed it (#31257); this row reaches it through a population net, the gate's own walk plus its own first filter. It needs NO build, about 13 seconds. Repair by spelling the code in upper case; the exempt list is for files that are not error surfaces, never a quick fix", + }, + ], + }, + { + kind: "declares a class, or casts through any, in package source (the object-def param-keys gate's own subjects, read from CONTENT)", + matches: 'object-def-class', + gates: [ + { + name: 'check:object-def-param-keys', + why: "an object-definition parameter must declare every key read off it, an override may not narrow the base class's inline parameter type, and neither may escape through an index signature or an opaque annotation. Its corpus is every tracked .ts under a package src, so a class anywhere (a base or an override, in any package) or an `as any` cast read moves it. It printed in the artifact-roster block: it spells the files it already knows, and the walk that finds the next one is a whole root, so no card touching an unlisted file was handed it (#31257); this row reaches it through a population net, the gate's own walk plus its own first filter (1248 of 4236 files; its --list names 244, none missed). It needs NO build, about 7 seconds. Repair by declaring the key on the parameter type (and on every override); the arm-C ledger is a maintainer's call", + }, + ], + }, ]; /** diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 098f38af9ac..c0eee2c1701 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -462,6 +462,10 @@ import { isTestPath } from '../check-undeclared-dep-imports.mjs'; // this tool still runs in a checkout with no install. import { CORPUS as ADR_ANCHOR_CORPUS } from '../check-adr-symbol-anchors.mjs'; import { extractAnchors } from '../symbol-anchors.mjs'; +// The two whole-root gates whose per-file reader the population nets IMPORT rather than copy (#31257): +// a file the net reaches is a file the gate itself flags. Both modules are dependency-free. +import { SCANNED_ROOTS as RUNNER_ENV_SCANNED_ROOTS, findRunnerEnvReads, isProductSource as isRunnerEnvProductSource } from '../check-runner-env-posture.mjs'; +import { findViolations as findErrorCodeCasingViolations } from '../check-error-code-casing.mjs'; // The hand-written tables — ledgers, marker grammars, the change-kind roster, the tier globs — are // DATA, and live beside this file; this engine loads them, matches paths against them and derives. // Every table is re-exported under the name it always had, so no consumer moved. @@ -11001,6 +11005,221 @@ export function breaksAnAdrAnchor(path, targets = adrAnchorTargets, onDisk = (p) return anchoredFiles.has(rel) && !onDisk(rel); } +/** + * The POPULATION NETS (#31257): for fifteen lint gates that walk a whole root, + * the gate's own population and the gate's own first filter, so a diff that can + * move the gate derives it and a diff that cannot does not. + * + * ## Why these gates reached no card + * + * Eleven of them declare `wide-population`: their walk is a bare root (every + * non-test source under `packages`, and sometimes `apps` or `examples` too), the + * bare-root ledger refuses that declaration as REFUSE-WIDE, so they print in the + * declared-WIDE block and never in `--commands`. Four more sit in the + * artifact-roster block: they spell the files they already know, while the walk + * that finds the next one is a whole root or a directory listing. The seat ruling + * on #30763 keeps both blocks out of `--commands` wholesale, so each family is + * closed here, one net per gate. + * + * ## What a net is + * + * The POPULATION half is the gate's walk, read off its source: the roots, the + * file names it keeps, the names and directories it skips. The CONTENT half is + * the gate's own first filter, where it has one: the cheap pre-filter most of + * these gates run before their AST pass (`PREFILTER_TOKENS`, `CHANNEL_PREFILTER`, + * `text.includes(FACTORY)`, `source.includes('.json(')`), so a file the net skips + * is a file the gate skips too. Two gates expose their per-file reader and carry + * no ledger, so they are IMPORTED instead of copied: `check:runner-env-posture` + * (`isProductSource`, `findRunnerEnvReads`) and `check:error-code-casing` + * (`findViolations`). Both modules are dependency-free, so this tool still runs + * in a checkout with no install. The other nets are copies, and the self-test + * holds each copy to the gate's source and, where the gate has `--list`, to the + * files its own pass names. + * + * A PATH limb counts with nothing read: a file the gate keeps by NAME (a + * `-routes.ts` module, a seam module the tenant gate lists from its directory), + * and the two nets whose whole population is narrow enough to name outright. + * + * A population file with nothing on disk is judged on the content it HAD: the + * blob at HEAD for an uncommitted delete, else the blob before the commit that + * deleted it. A move or a delete can strand a ledger row, a provider a consumer + * was declared against, or a vacuity floor, so a file that passed the net before + * it went still counts. A path git never held (a surface not written yet) does + * not: nothing was there to strand. + * + * Measured at 07559ce5 (net hits / population, and recall against the gate's + * own `--list` where it has one, every listed file inside the net): + * + * init-service-contract 217 / 4498 --list 31 files, 0 missed + * settings-bind-window 657 / 4498 --list 11 files, 0 missed + * startup-registry-verdict 222 / 4498 --list 29 files, 0 missed + * optional-error-sink 1070 / 4498 --list 204 files, 0 missed + * resume-authority-declared 19 / 4691 --list 16 files, 0 missed + * route-envelope 160 / 4498 MODULES 56 files, 0 missed + * verify-stand-in 83 / 12003 (the census prefilter itself) + * wildcard-fallthrough 143 / 4498 --list 7 files, 0 missed + * live-db-isolation 101 / 12010 65 live suites, 0 missed + * runner-env-posture 0 / 4357 (the gate's own reader; tree clean) + * meta-type-normalized 56 / 56 (path only: the gate's 56 files) + * tenant-chokepoint 36 / 36 (path only: the listed seam modules) + * authz-resolver 62 / 4489 (both grant tables named) + * error-code-casing 4 / 11748 (the gate's own reader; 4 exempt) + * object-def-param-keys 1248 / 4236 --list 244 files, 0 missed + * + * ## What a net cannot see + * + * A gate that follows an IMPORT out of the file it judges (settings-bind-window + * resolves a hook name through one) can be moved by the imported file alone; the + * net reads the judged file only. And a net is a copy: a new token in the gate's + * pre-filter widens the gate and not the net until the self-test reds on it. + */ +const NET_STANDARD_SKIP_DIRS = ['node_modules', 'dist', 'build', '.turbo', '.next', 'coverage']; +const NET_NON_TEST_TS = /\.d\.ts$|\.(?:test|spec|conformance)\./; +const NET_SERVICE_LOOKUPS = ['getService', 'getServiceAsync', 'getServiceScoped']; + +let verifyGuarded = null; +/** + * The stand-in helpers the verify gate guards: the keys of the ESLint config's + * `VERIFY_STAND_IN_CHECKS`, which the gate imports. Read as TEXT here, because + * the config module imports ESLint plugins and this tool runs with no install. + * An object this reader cannot find THROWS: an empty set would reach nothing. + */ +export function verifyStandInGuarded(source = null) { + if (source === null && verifyGuarded) return verifyGuarded; + const text = source ?? readTrackedSource('eslint.config.mjs') ?? ''; + const block = /export const VERIFY_STAND_IN_CHECKS = \{([^}]*)\}/.exec(text); + const names = block ? [...block[1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:/gm)].map((m) => m[1]) : []; + if (names.length === 0) { + throw new Error( + 'dispatch-gates: eslint.config.mjs declares no VERIFY_STAND_IN_CHECKS entry this reader can see, so the ' + + 'verify stand-in net would reach nothing. The table moved: re-point this reader with it', + ); + } + if (source === null) verifyGuarded = names; + return names; +} + +export const POPULATION_NETS = Object.freeze({ + 'init-service-lookup': { + gate: 'check:init-service-contract', script: 'scripts/check-init-service-contract.mjs', + roots: ['packages'], file: /\.ts$/, skip: NET_NON_TEST_TS, skipDirs: NET_STANDARD_SKIP_DIRS, + tokens: [...NET_SERVICE_LOOKUPS, 'providesServices'], + }, + 'settings-bind-read': { + gate: 'check:settings-bind-window', script: 'scripts/check-settings-bind-window.mjs', + roots: ['packages'], file: /\.ts$/, skip: NET_NON_TEST_TS, skipDirs: NET_STANDARD_SKIP_DIRS, + tokens: [...NET_SERVICE_LOOKUPS, 'providesServices', 'trigger'], + }, + 'startup-registry-probe': { + gate: 'check:startup-registry-verdict', script: 'scripts/check-startup-registry-verdict.mjs', + roots: ['packages'], file: /\.ts$/, skip: NET_NON_TEST_TS, skipDirs: [...NET_STANDARD_SKIP_DIRS, '.git', '.cache'], + tokens: ['providesServices', 'getService', 'getServiceAsync', 'getServiceOptional', 'tryGetService', 'hasService', 'nodeExecutors', 'actionDescriptors'], + }, + 'optional-error-sink': { + gate: 'check:optional-error-sink', script: 'scripts/check-optional-error-sink-contract.mjs', + roots: ['packages'], file: /\.ts$/, skip: /\.d\.ts$|\.(?:test|spec)\.ts$/, skipDirs: NET_STANDARD_SKIP_DIRS, + tokens: ['error', 'warn', 'info', 'debug', 'log', 'fatal', 'trace', 'verbose', 'silly'], + pattern: /\b(?:error|warn|info|debug|log|fatal|trace|verbose|silly)\s*\??\s*[:(]/, + }, + 'action-descriptor': { + gate: 'check:resume-authority-declared', script: 'scripts/check-resume-authority-declared.mjs', + roots: ['packages', 'examples'], file: /\.(?:ts|tsx|mts)$/, skip: /\.d\.ts$|\.(?:test|spec)\.(?:ts|tsx|mts)$/, + skipDirs: ['node_modules', 'dist', '.git', '.cache'], tokens: ['defineActionDescriptor'], + }, + 'route-response-writer': { + gate: 'check:route-envelope', script: 'scripts/check-route-envelope.mjs', + roots: ['packages'], file: /\.ts$/, skip: /\.d\.ts$|\.(?:test|conformance)\./, skipDirs: NET_STANDARD_SKIP_DIRS, + tokens: ['.json('], + offConvention: ['i18n-service-plugin.ts', 'rest-server.ts', 'error-response.ts'], + domainDir: 'packages/runtime/src/domains', + path(rel) { + const name = rel.slice(rel.lastIndexOf('/') + 1); + return name.endsWith('-routes.ts') || this.offConvention.includes(name) || rel.startsWith(`${this.domainDir}/`); + }, + }, + 'verify-stand-in-call': { + gate: 'check:verify-stand-in', script: 'scripts/check-verify-stand-in-erasure.mjs', + roots: ['packages', 'examples'], file: /\.(?:ts|tsx|mts|cts)$/, skip: /\.d\.ts$/, skipDirs: NET_STANDARD_SKIP_DIRS, + skipDotDirs: true, tokens: () => verifyStandInGuarded(), + path: (rel) => rel.startsWith('packages/verify/src/'), + }, + 'wildcard-mount': { + gate: 'check:wildcard-fallthrough', script: 'scripts/check-wildcard-fallthrough.mjs', + roots: ['packages'], file: /\.ts$/, skip: /\.d\.ts$|\.(?:test|conformance)\./, skipDirs: NET_STANDARD_SKIP_DIRS, + tokens: ['/*`', "/*'", '/*"'], + }, + 'live-db-suite': { + gate: 'check:live-db-isolation', script: 'scripts/check-live-db-isolation.mjs', + roots: ['packages', 'apps', 'examples'], file: /\.m?ts$/, skip: /\.d\.ts$/, skipDirs: NET_STANDARD_SKIP_DIRS, + skipDotDirs: true, tokens: ['OS_TEST_MYSQL_URL', 'OS_TEST_POSTGRES_URL'], + }, + 'runner-env-read': { + gate: 'check:runner-env-posture', script: 'scripts/check-runner-env-posture.mjs', + roots: RUNNER_ENV_SCANNED_ROOTS, file: /./, skip: /^$/, skipDirs: [...NET_STANDARD_SKIP_DIRS, '.git'], + keep: isRunnerEnvProductSource, content: (source) => findRunnerEnvReads(source).length > 0, + }, + 'meta-type-route-source': { + gate: 'check:meta-type-normalized', script: 'scripts/check-meta-type-normalized.mjs', + roots: ['packages/rest/src'], file: /\.(?:m|c)?tsx?$/, skip: /\.(?:test|spec)\.(?:m|c)?tsx?$/, + skipDirs: ['node_modules', 'dist'], pathOnly: true, + }, + 'sql-driver-seam-module': { + gate: 'check:tenant-chokepoint', script: 'scripts/check-tenant-chokepoint.mjs', + roots: ['packages/drivers/driver-sql/src'], flat: true, file: /^sql-driver-.+\.ts$/, skip: /\.(?:test|spec)\.ts$|\.d\.ts$/, + skipDirs: [], pathOnly: true, + }, + 'grant-table-reader': { + gate: 'check:authz-resolver', script: 'scripts/check-single-authz-resolver.mjs', + roots: ['packages'], file: /\.[mc]?ts$/, skip: /\.(?:test|d)\.[mc]?ts$/, skipDirs: ['node_modules', 'dist', '__tests__'], + tokens: ['sys_user_position', 'sys_user_permission_set'], every: true, + }, + 'lowercase-error-code': { + gate: 'check:error-code-casing', script: 'scripts/check-error-code-casing.mjs', + roots: ['packages'], file: /\.(?:ts|tsx)$/, skip: /\.d\.ts$/, skipDirs: ['node_modules', 'dist', '.turbo', 'coverage'], + content: (source, rel) => findErrorCodeCasingViolations(source, rel).length > 0, + }, + 'object-def-class': { + gate: 'check:object-def-param-keys', script: 'scripts/check-object-def-param-keys.mjs', + roots: ['packages'], file: /\.ts$/, skip: /\.(?:test|spec)\.ts$/, skipDirs: [], keep: (rel) => rel.includes('/src/'), + content: (source) => /\bclass\s/.test(source) || /\bas\s+any\b/.test(source), + }, +}); + +/** Is this repo-relative path inside the net's POPULATION — the gate's own walk? */ +export function inNetPopulation(rel, net) { + const root = net.roots.find((r) => rel.startsWith(`${r}/`)); + if (!root) return false; + const dirs = rel.slice(root.length + 1).split('/'); + const name = dirs.pop(); + if (net.flat && dirs.length > 0) return false; + if (dirs.some((d) => net.skipDirs.includes(d) || (net.skipDotDirs && d.startsWith('.')))) return false; + if (!net.file.test(name) || net.skip.test(name)) return false; + return net.keep ? net.keep(rel) : true; +} + +/** The content a deleted path last had: HEAD's blob, else the blob before the commit that deleted it. */ +function readDeletedSource(rel) { + const head = runGit(['show', `HEAD:${rel}`], ROOT); + if (head.status === 0) return head.stdout; + const deleting = runGit(['rev-list', '-n', '1', 'HEAD', '--', rel], ROOT).stdout.trim(); + if (!deleting) return null; + const before = runGit(['show', `${deleting}^:${rel}`], ROOT); + return before.status === 0 ? before.stdout : null; +} + +export function reachesPopulationNet(path, net, readSource = readTrackedSource, readPrior = readDeletedSource) { + const rel = path.replace(/^\.\//, ''); + if (!inNetPopulation(rel, net)) return false; + if (net.pathOnly || net.path?.(rel)) return true; + let source = readSource(rel); + if (source === null || source === undefined) source = existsSync(nodePath.join(ROOT, rel)) ? null : readPrior(rel); + if (source === null || source === undefined) return false; + if (net.content) return net.content(source, rel); + if (net.pattern) return net.pattern.test(source); + const tokens = typeof net.tokens === 'function' ? net.tokens() : net.tokens; + return net.every ? tokens.every((t) => source.includes(t)) : tokens.some((t) => source.includes(t)); +} + /** * Is this path inside the ROOT package's tsc program — the population the * root's `typecheck:root` script checks (until the root graduated, the @@ -11320,6 +11539,8 @@ const CHANGE_KIND_PREDICATES = Object.freeze({ 'cli-command-literal': (path) => carriesACliCommandLiteral(path), 'adr-anchored-file': (path) => breaksAnAdrAnchor(path), 'ui-scripts-suite-input': (path) => readByUiScriptsSuite(path), + // One per population net (#31257), each bound to its own net. + ...Object.fromEntries(Object.entries(POPULATION_NETS).map(([key, net]) => [key, (path) => reachesPopulationNet(path, net)])), }); export function bindChangeKindRows(rows, predicates = CHANGE_KIND_PREDICATES) { diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index 90f572e06da..f6c336c51f9 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -291,7 +291,13 @@ import { carriesACliCommandLiteral, cliBinaryNames, cliCommandSurfaces, + POPULATION_NETS, + inNetPopulation, + reachesPopulationNet, + verifyStandInGuarded, } from './dispatch-gates.mjs'; +import { SCANNED_ROOTS as RUNNER_ENV_ROOTS, isProductSource as runnerEnvIsProductSource } from '../check-runner-env-posture.mjs'; +import { findViolations as casingFindViolations } from '../check-error-code-casing.mjs'; import { CORPUS as ADR_ANCHOR_CORPUS } from '../check-adr-symbol-anchors.mjs'; import { COMPOUND_ANCHOR_LEDGER, @@ -305,6 +311,7 @@ import { ROOT_WALK_RESIDUE_LEDGER, TIER_DEFAULT, TIER_FLOOR, + CHANGE_KIND_ROWS, } from './dispatch-gates.data.mjs'; /** @@ -4580,7 +4587,11 @@ export function selfTest({ tier = 'full' } = {}) { // print STALE exactly as designed, and a `includes('pnpm check:i18n')` pin // stayed green through it — every prefix-preserving rename is invisible to a // substring, which is the one class of rot the STALE branch exists to catch. - const i18nHit = changeKindLines(['packages/services/service-messaging/src/objects/http-delivery.object.ts'], resolved); + // Read through the entries that name check:i18n, not the whole table: since + // #31257 a population net may fire on the same live file for its own reason, + // and that is no fact about this section. + const i18nRows = CHANGE_KIND_GATES.filter((k) => k.gates.some((g) => g.name === 'check:i18n')); + const i18nHit = changeKindLines(['packages/services/service-messaging/src/objects/http-delivery.object.ts'], resolved, i18nRows); // One kind line plus one line per gate in the entry — TWO gates since #11671 // added the stale-fill ratchet to the same file surface. The count is pinned // (not `>= 1`) so a gate silently dropped from the entry fails here. @@ -5038,6 +5049,139 @@ export function selfTest({ tier = 'full' } = {}) { t('an undiscoverable ADR anchor gate renders STALE for this entry', changeKindLines([ADR_SPECIMEN], () => null, adrEntry).filter((l) => l.includes('⚠ check:adr-symbol-anchors: STALE')).length === 1); + // ── The population nets (#31257) ───────────────────────────────────────── + // + // Fifteen whole-root gates, each reached through its own net: the gate's walk + // plus its first filter. Per net, table-driven: a real diff shape is a hit, the + // walk's own exclusion and the filter's own refusal are misses, the net's + // copy is held to the gate's SOURCE, and a live specimen derives the gate + // through the whole table. The slow tier holds each net to the files the + // gate's own --list names. Fixture sources are assembled where a whole-tree + // gate scanning this file could read them as its own subject. + const NET_CASES = [ + { key: 'init-service-lookup', hit: ['packages/plugins/plugin-x/src/x-plugin.ts', "export const p = { name: 'x', init(ctx) { ctx.getService('settings'); } };"], + walkMiss: 'packages/plugins/plugin-x/src/x-plugin.conformance.ts', specimen: 'packages/services/service-settings/src/settings-service-plugin.ts', + spelled: ["const SERVICE_LOOKUP_CALLEES = new Set(['getService', 'getServiceAsync', 'getServiceScoped']);", "const PREFILTER_TOKENS = [...SERVICE_LOOKUP_CALLEES, 'providesServices'];", "walk(join(ROOT, 'packages'));"] }, + { key: 'settings-bind-read', hit: ['packages/plugins/plugin-x/src/x-plugin.ts', "await ctx.trigger('app:seeded');"], + walkMiss: 'packages/plugins/plugin-x/src/x-plugin.spec.ts', specimen: 'packages/services/service-settings/src/settings-service-plugin.ts', + spelled: ["const SERVICE_LOOKUP_CALLEES = new Set(['getService', 'getServiceAsync', 'getServiceScoped']);", "const EMIT_CALLEE = 'trigger';", "const PREFILTER_TOKENS = [...SERVICE_LOOKUP_CALLEES, 'providesServices', EMIT_CALLEE];"] }, + { key: 'startup-registry-probe', hit: ['packages/plugins/plugin-x/src/x-plugin.ts', "init(ctx) { if (!ctx.hasService('cache')) this.cacheless = true; }"], + walkMiss: 'packages/plugins/plugin-x/src/x-plugin.test.ts', specimen: 'packages/services/service-settings/src/settings-service-plugin.ts', + spelled: ["if (!text.includes('providesServices')) continue;", 'for (const probe of SERVICE_REGISTRY_PROBES.keys()) if (text.includes(probe)) interesting = true;', + 'for (const registry of OPEN_CAPABILITY_REGISTRIES.keys()) if (text.includes(registry)) interesting = true;'], + tokensFrom: (src) => [...['SERVICE_REGISTRY_PROBES', 'OPEN_CAPABILITY_REGISTRIES'].flatMap((name) => { + const block = new RegExp(`const ${name} = new Map\\(\\[([\\s\\S]*?)\\n\\]\\);`).exec(src)?.[1] ?? ''; + return [...block.matchAll(/^ {4}\[\s*'(\w+)'/gm)].map((m) => m[1]); + }), 'providesServices'] }, + { key: 'optional-error-sink', hit: ['packages/core/src/x-logger.ts', 'export interface XSink { error?: (m: string) => void; info: (m: string) => void }'], + walkMiss: 'packages/core/src/x-logger.test.ts', specimen: 'packages/core/src/hook-dispatch.ts', + spelled: ['const CHANNEL_PREFILTER = new RegExp(`\\\\b(?:${[...LOG_CHANNELS].join(\'|\')})\\\\s*\\\\??\\\\s*[:(]`);', 'if (!CHANNEL_PREFILTER.test(text)) continue;'], + tokensFrom: (src) => [.../const LOG_CHANNELS = new Set\(\[([\s\S]*?)\]\);/.exec(src)?.[1].matchAll(/'(\w+)'/g) ?? []].map((m) => m[1]) }, + { key: 'action-descriptor', hit: ['examples/app-x/src/actions/x.ts', "export const x = defineActionDescriptor({ type: 'x.pause' });"], + walkMiss: 'examples/app-x/src/actions/x.test.tsx', specimen: 'packages/plugins/plugin-approvals/src/approval-node.ts', + spelled: ["const FACTORY = 'defineActionDescriptor';", 'if (!text.includes(FACTORY)) continue;', "const DEFAULT_SCAN_ROOTS = ['packages', 'examples'];"] }, + { key: 'route-response-writer', hit: ['packages/plugins/plugin-x/src/x-plugin.ts', 'app.get(p, (c) => c.json({ ok: true }));'], + walkMiss: 'packages/plugins/plugin-x/src/x-plugin.conformance.ts', specimen: 'packages/rest/src/rest-server.ts', + spelled: ["if (!source.includes('.json(')) continue;", "if (entry.endsWith('-routes.ts') || OFF_CONVENTION_MODULES.has(entry)) {", "const DISPATCHER_DOMAIN_DIR = 'packages/runtime/src/domains';"] }, + { key: 'verify-stand-in-call', hit: ['packages/drivers/driver-x/src/x-driver.verify.test.ts', `const r = await ${'check'}ReadCoercion(driver as never);`], + walkMiss: 'packages/drivers/driver-x/src/x-driver.d.ts', specimen: 'packages/verify/src/index.ts', + spelled: ['if (!guarded.some((g) => text.includes(g))) continue;', "const SCAN_ROOTS = ['packages', 'examples'];", "await requireDependency('../eslint.config.mjs'"] }, + { key: 'wildcard-mount', hit: ['packages/plugins/plugin-x/src/x-plugin.ts', 'app.all(`${base}/*`, (c, next) => next());'], + walkMiss: 'packages/plugins/plugin-x/src/x-plugin.test.ts', specimen: 'packages/plugins/plugin-auth/src/auth-plugin.ts', + spelled: ["if (!text.includes('/*`') && !text.includes(\"/*'\") && !text.includes('/*\"')) continue;", "walk(join(ROOT, 'packages'));"] }, + { key: 'live-db-suite', hit: ['packages/drivers/driver-x/src/x.live.test.ts', `const url = process.env.${'OS_TEST'}_POSTGRES_URL;`], + walkMiss: 'packages/drivers/driver-x/src/x.live.d.ts', specimen: 'packages/drivers/driver-sql/src/live-dialect-matrix.isolation.test.ts', + spelled: ['OS_TEST_(?:MYSQL|POSTGRES)_URL', "const ROOTS = ['packages', 'apps', 'examples'];", "/\\.m?ts$/.test(entry.name) && !entry.name.endsWith('.d.ts')"] }, + { key: 'runner-env-read', hit: ['packages/core/src/x.ts', `if (process.env.${'VITEST'}) return;`], walkMiss: 'packages/core/src/x.test.ts', imported: true }, + { key: 'meta-type-route-source', hit: ['packages/rest/src/x-handler.ts', null], walkMiss: 'packages/rest/src/x-handler.test.ts', specimen: 'packages/rest/src/rest-server.ts', + spelled: ["const SCAN_DIRS = [join('packages', 'rest', 'src')];", "/\\.(m|c)?tsx?$/.test(entry) && !/\\.(test|spec)\\.(m|c)?tsx?$/.test(entry)"] }, + { key: 'sql-driver-seam-module', hit: ['packages/drivers/driver-sql/src/sql-driver-next-split.ts', null], walkMiss: 'packages/drivers/driver-sql/src/nested/sql-driver-next-split.ts', + specimen: 'packages/drivers/driver-sql/src/sql-driver-crud.ts', + spelled: [".filter((n) => /^sql-driver-.+\\.ts$/.test(n) && !/\\.(test|spec)\\.ts$/.test(n) && !n.endsWith('.d.ts'))", "...seamModulesOf(listing(join('packages', 'drivers', 'driver-sql', 'src')))"] }, + { key: 'grant-table-reader', hit: ['packages/plugins/plugin-x/src/x-grants.ts', "await ql.find('sys_user_position', q); await ql.find('sys_user_permission_set', q);"], + walkMiss: 'packages/plugins/plugin-x/src/__tests__/x-grants.ts', specimen: 'packages/core/src/security/resolve-authz-context.ts', + spelled: ["const GRANT_TABLES = ['sys_user_position', 'sys_user_permission_set'];", 'return GRANT_TABLES.every((t) => queriesGrantTable(src, t));', "const SCAN_ROOTS = ['packages'];"] }, + { key: 'lowercase-error-code', hit: ['packages/core/src/x.ts', `throw Object.assign(new Error('x'), { ${'code'}: 'not_found' });`], walkMiss: 'packages/core/src/x.d.ts', imported: true }, + { key: 'object-def-class', hit: ['packages/drivers/driver-x/src/x-driver.ts', 'export class XDriver extends SqlDriver {}'], + walkMiss: 'packages/drivers/driver-x/test/x-driver.ts', specimen: 'packages/drivers/driver-turso/src/turso-driver.ts', + spelled: ["f.endsWith('.ts') && f.includes('/src/') && !/\\.(test|spec)\\.ts$/.test(f)", "execFileSync('git', ['-C', root, 'ls-files', 'packages']"] }, + ]; + t('the case table covers every population net, once', JSON.stringify(NET_CASES.map((c) => c.key).sort()) === JSON.stringify(Object.keys(POPULATION_NETS).sort())); + const NOTHING_PRIOR = () => null; + for (const c of NET_CASES) { + const net = POPULATION_NETS[c.key]; + const [hitPath, hitText] = c.hit; + const reach = (p, text) => reachesPopulationNet(p, net, codeSrc(text), NOTHING_PRIOR); + t(`${c.key}: the real diff shape is a hit (${hitPath})`, reach(hitPath, hitText ?? '')); + t(`${c.key}: …and the gate's walk excludes ${c.walkMiss}, so the net does too`, !reach(c.walkMiss, hitText ?? '')); + if (!net.pathOnly) { + t(`${c.key}: a population file without the gate's filter is not a hit`, !reach(hitPath, 'export const unrelated = 1;\n')); + t(`${c.key}: a deleted file is judged on the content it had — a hit when that passed the filter`, + reachesPopulationNet(hitPath, net, () => null, () => hitText) && !reachesPopulationNet(hitPath, net, () => null, () => 'export const unrelated = 1;\n')); + t(`${c.key}: a path git never held is not a hit`, !reachesPopulationNet(hitPath, net, () => null, NOTHING_PRIOR)); + } + t(`${c.key}: a path outside the gate's roots is not a hit`, !reach(`docs/${hitPath}`, hitText ?? '')); + const gateSource = readFileSync(nodePath.join(ROOT, net.script), 'utf8'); + const unspelled = (c.spelled ?? []).filter((s) => !gateSource.includes(s)); + t(`${c.key}: the gate's source still spells what the net copies (unspelled: ${unspelled.join(' | ') || 'none'})`, unspelled.length === 0); + if (c.tokensFrom) { + const own = [...new Set(c.tokensFrom(gateSource))].sort(); + t(`${c.key}: the net's tokens are exactly the gate's filter vocabulary (${own.join(', ')})`, own.length > 0 && JSON.stringify(own) === JSON.stringify([...net.tokens].sort())); + } + const entry = CHANGE_KIND_GATES.filter((k) => k.gates.some((g) => g.name === net.gate)); + t(`${c.key}: exactly one entry names ${net.gate}, bound to this net`, entry.length === 1 && entry[0].gates.length === 1 && CHANGE_KIND_ROWS.filter((r) => r.gates.some((g) => g.name === net.gate)).map((r) => r.matches).join() === c.key); + if (c.specimen) { + const lines = changeKindLines([c.specimen], resolved); + t(`${c.key}: through the whole table, the live specimen ${c.specimen} derives ${net.gate}`, lines.some((l) => l.includes(`- pnpm ${net.gate} —`))); + } + const line = changeKindLines([hitPath], resolved, entry.map((k) => ({ ...k, matches: (p) => reach(p, hitText ?? '') }))) + .find((l) => l.includes(`- pnpm ${net.gate} —`)) ?? ''; + t(`${c.key}: the rendered lead names the gate runnably and says it needs no build and why no card had it`, /needs NO build/.test(line) && /#31257/.test(line)); + } + t('the imported readers are the gates\' own functions, not copies', + POPULATION_NETS['runner-env-read'].keep === runnerEnvIsProductSource && POPULATION_NETS['runner-env-read'].roots === RUNNER_ENV_ROOTS + && reachesPopulationNet('packages/core/src/x.ts', POPULATION_NETS['lowercase-error-code'], codeSrc(`({ ${'code'}: 'not_found' })`)) === (casingFindViolations(`({ ${'code'}: 'not_found' })`, 'packages/core/src/x.ts').length > 0)); + t('the verify net reads the guarded helpers off the ESLint config the gate imports', verifyStandInGuarded().includes(['check', 'ReadCoercion'].join(''))); + t('…and a config with no such table THROWS rather than reaching nothing', throwsNaming(() => verifyStandInGuarded('export default [];'), 'VERIFY_STAND_IN_CHECKS')); + t('a docs-only page derives none of the fifteen', !changeKindLines(['content/docs/ai/agents.mdx'], resolved) + .some((l) => Object.values(POPULATION_NETS).some((n) => l.includes(`- pnpm ${n.gate} —`)))); + + // The two roster families closed by MEASUREMENT, not by a row: every file + // the gate reads is a literal its own source spells, and the path + // derivation already names the gate for each of those files. + for (const [script, readsOf] of [ + ['scripts/check-stack-collection-maps.mjs', (src) => [...src.matchAll(/^\s+file: '([^']+)'/gm), ...src.matchAll(/readSource\('([^']+)'\)/g)].map((m) => m[1])], + ['scripts/check-filter-alias-parity.mjs', (src) => [...src.matchAll(/^const (?:SPEC|PROTOCOL|REST)_FILE = '([^']+)';/gm)].map((m) => m[1])], + ]) { + const src = readFileSync(nodePath.join(ROOT, script), 'utf8'); + const reads = [...new Set(readsOf(src))]; + const hints = extractWatchHints(src, script); + const unmatched = reads.filter((f) => !hints.includes(f)); + t(`${script}: every source file it reads (${reads.length}) is a literal the path derivation already matches (unmatched: ${unmatched.join(', ') || 'none'})`, + reads.length >= 3 && reads.every((f) => existsSync(nodePath.join(ROOT, f))) && unmatched.length === 0); + t(`${script}: …and it walks no root to find another`, !/readdirSync\(|ls-files/.test(src)); + } + + slow('population nets: each held to the files the gate\'s own --list names, and discriminating on the live tree', () => { + const tracked = trackedFiles(); + for (const c of NET_CASES) { + const net = POPULATION_NETS[c.key]; + const population = tracked.filter((f) => inNetPopulation(f, net)); + if (!net.pathOnly) { + const hits = population.filter((f) => reachesPopulationNet(f, net)); + t(`${c.key}: the net discriminates — ${hits.length} of ${population.length} population files`, population.length > 30 && hits.length < population.length); + } + } + for (const key of ['init-service-lookup', 'settings-bind-read', 'startup-registry-probe', 'optional-error-sink', 'action-descriptor', 'wildcard-mount', 'object-def-class']) { + const net = POPULATION_NETS[key]; + const run = spawnSync(process.execPath, [net.script, '--list'], { cwd: ROOT, encoding: 'utf8', maxBuffer: 1 << 26 }); + const listed = [...new Set([...`${run.stdout}${run.stderr}`.matchAll(/(?:^|\s)((?:packages|examples|apps)\/[\w@./-]+\.[mc]?tsx?):\d+/gm)].map((m) => m[1]))]; + const missed = listed.filter((f) => !reachesPopulationNet(f, net)); + t(`${key}: every file ${net.gate} --list names is inside the net (exit ${run.status}${run.status === EXIT_PREREQUISITE_NOT_MET ? ': install the workspace first' : ''}; ${listed.length} listed, missed: ${missed.slice(0, 5).join(', ') || 'none'})`, + run.status === 0 && listed.length > 3 && missed.length === 0); + } + }); + // ── The ui/scripts/__tests__ suite (#26318) ───────────────────────────────── // // The 178 files run only in CI's Test UI shards, so no family named them and a @@ -5147,7 +5291,7 @@ export function selfTest({ tier = 'full' } = {}) { // rendered delimiters for the reason the entry above states: a bare substring // stays green through a prefix-preserving rename, the one rot the STALE branch // exists to catch. - const formHit = changeKindLines(['packages/spec/src/data/object.form.ts', 'packages/spec/src/data/field.form.ts'], resolved); + const formHit = changeKindLines(['packages/spec/src/data/object.form.ts', 'packages/spec/src/data/field.form.ts'], resolved, i18nRows); // The `?? ''` is not defensive noise: reverse-verifying this block by making // every config opt out emptied `formHit`, and the bare index CRASHED the whole // self-test on a TypeError — one stack in place of 180-odd named verdicts. A @@ -7792,7 +7936,8 @@ export function selfTest({ tier = 'full' } = {}) { 'scripts/pm/check-expected-skips.mjs:137 self-test-reads', // 736 -> 738 (#26318): the data import and its re-export each gained UI_SCRIPTS_SUITE_ROOTS. // 738 -> 744 (#30763): the ADR anchor corpus import and its comment. - 'scripts/pm/dispatch-gates.mjs:744 inherited-population', + // 744 -> 748 (#31257): the two imported gate readers and their comment. + 'scripts/pm/dispatch-gates.mjs:748 inherited-population', // Ninth row, added with the declaration it names: the recogniser's mailbox // doorbell battery pins the seven key templates verbatim in the pm-dispatch // references' project-forms.md, a population claim on a governed file, so