diff --git a/scripts/check-dev-prereqs.mjs b/scripts/check-dev-prereqs.mjs index fa721327d6..9aafe04a29 100644 --- a/scripts/check-dev-prereqs.mjs +++ b/scripts/check-dev-prereqs.mjs @@ -1,5 +1,24 @@ #!/usr/bin/env node // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// Note on gate derivation (#10542), because this file looks like it needs a +// population declaration and does not. It enumerates the workspace at runtime +// out of pnpm-workspace.yaml, which is the shape that card is about — but two +// facts make a declaration the wrong move here, and both were read off the +// source rather than assumed: +// +// - lint.yml runs `--self-test` ONLY, and says so in the step name. The +// EXISTENCE and FRESHNESS scans below are for `pnpm dev`, a local preflight +// no pull request schedules. A workspace-wide declaration would name this +// gate for every packages/ card in the tree for a scan CI never runs. +// - the one hint this file does contribute is honest and load-bearing: the +// FRESHNESS half really does read the spec package's dist, so a card there +// names this gate for a read it genuinely performs. It scores `silent` for +// cards elsewhere, and that is the correct verdict rather than a blind spot. +// +// So there is nothing to declare and no marker to carry: a `no-path-population` +// declaration would contradict the hint above, and dispatch-gates' self-test +// asserts exactly that pair cannot coexist. /** * check:dev-prereqs — confirm the workspace is BUILT, and that the one artifact diff --git a/scripts/check-pnpm-filter-targets.mjs b/scripts/check-pnpm-filter-targets.mjs index 43a1c6bfd0..72b1117b3e 100644 --- a/scripts/check-pnpm-filter-targets.mjs +++ b/scripts/check-pnpm-filter-targets.mjs @@ -110,6 +110,44 @@ const JS_EXTENSIONS = ['.mjs', '.mts', '.cjs', '.js', '.ts']; const HASH_COMMENT_EXTENSIONS = ['.sh', '.bash', '.yml', '.yaml']; const SCANNED_EXTENSIONS = [...JS_EXTENSIONS, ...HASH_COMMENT_EXTENSIONS, '.json']; +/** + * The half of `scannedFiles`' population that `scripts/pm/dispatch-gates.mjs` + * could not see, written in the syntax that derivation CAN read (#10542). + * + * ── The defect this repairs ───────────────────────────────────────────────── + * + * `scannedFiles` walks THREE carriers, and the derivation saw only one of them. + * `.github/workflows/**` is already declared below in a spelling that carries a + * separator, so a workflow card names this gate. The scripts/ walk is spelled + * `join(root, 'scripts')` — a bare single-segment word, which `extractWatchHints` + * drops before `hintCovers` is ever consulted — so a card editing any script in + * the tree named this gate NOWHERE, including the cards most likely to add the + * very `--filter` spelling it exists to judge. + * + * ── Why `scripts/**` is honest here, with the measurement ─────────────────── + * + * This is the `subtree` case: the walk descends the whole of scripts/ and every + * file carrying a scanned extension is judged. Measured on this tree, the + * declaration names 235 tracked files under scripts/ and this gate reads 228 of + * them — 97.0%. The 7 it skips are the non-code files the extension filter + * drops, not a subtree it never opens. + * + * ── Why the workspace manifests stay UNDECLARED ───────────────────────────── + * + * `scannedFiles` also reads every workspace member's `package.json` — a real + * read, and one this declaration deliberately does not reach. The instrument + * cannot express it: a root hint covers a whole SUBTREE, so declaring the + * workspace globs (the shape check-published-files.mjs legitimately takes, + * because it walks every file of every member) would name this gate for all + * 5263 tracked files under packages/, apps/ and examples/ in order to reach the + * ~78 manifests it actually opens — 1.5% precision, pasted into every card + * whose surface brushes a package. `hintCovers`' docblock prices a fabricated + * lead above a missing one, so the manifest half stays a documented blind spot + * rather than a wholesale claim. The refusal is pinned below, so a later author + * who adds the workspace globs meets an assertion instead of this paragraph. + */ +const ROOT_DIR_WATCH_HINTS = ['scripts/**']; + /** * Blank the regions whose `--filter` spellings are prose rather than commands. * @@ -455,6 +493,33 @@ export function selfTest() { ), ); + // ---- the dispatch-gates declaration (#10542) ----------------------------- + // + // Enforcement cannot hold any of these: ROOT_DIR_WATCH_HINTS is read by + // another tool entirely, so a wrong or stale one runs green here forever and + // pays itself out as a dev dispatched on a scripts/ card with this gate + // missing from the brief. Reconciled against the LIVE sweep rather than + // re-spelled, so a carrier that moves cannot leave the declaration behind. + const sweptRoots = new Set( + scannedFiles(root).map((f) => f.split('/')[0]).filter((s) => s.length > 0), + ); + ok( + 'the declared subtree is one this gate really walks', + ROOT_DIR_WATCH_HINTS.every((h) => sweptRoots.has(h.replace(/\/\*+$/, ''))), + ); + ok( + 'scripts/ is declared in the subtree spelling (hintCovers refuses the bare word, so a tidy-up back to a directory name re-opens the blind spot silently)', + ROOT_DIR_WATCH_HINTS.includes('scripts/**'), + ); + ok( + 'every declared entry carries a path separator', + ROOT_DIR_WATCH_HINTS.every((h) => h.includes('/')), + ); + ok( + 'the workspace globs stay UNDECLARED (they would name 5263 files to reach ~78 manifests — the measurement is in the docblock)', + !ROOT_DIR_WATCH_HINTS.some((h) => /^(packages|apps|examples)(\/|$)/.test(h)), + ); + if (failures.length === 0) { console.log( `✓ check-pnpm-filter-targets --self-test: ${checked} assertions — a dead filter observed RED in all ` diff --git a/scripts/check-prerelease-pin-watch.mjs b/scripts/check-prerelease-pin-watch.mjs index e38f4bca2c..fb8b8d4c1a 100644 --- a/scripts/check-prerelease-pin-watch.mjs +++ b/scripts/check-prerelease-pin-watch.mjs @@ -114,6 +114,18 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; +// This gate's whole population is ONE repo-root file, and the derivation +// already reaches it — through the trigger key, not through a hint. Read from +// the source rather than assumed: `prerelease-pin-watch.yml` declares +// pnpm-workspace.yaml in its `paths:` filter, so a card that edits the pins +// gets this gate named with `CI trigger in prerelease-pin-watch.yml` as its +// provenance. What it carries no hint for is a bare repo-root FILENAME, which +// `hintCovers` refuses as too generic; the subtree-spelling escape would be a +// second, weaker route to a lead the workflow already delivers, and a second +// spelling of one population is what drifts. Nothing else in the tree moves +// this gate: it reads the `overrides` block and npm, and nothing more. +// +// dispatch-gates: no-path-population -- population is pnpm-workspace.yaml alone, and prerelease-pin-watch.yml already names it in `paths:` — the CI-trigger key reaches it, so a hint would be a second spelling of one population const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const DEFAULT_WORKSPACE = join(REPO_ROOT, 'pnpm-workspace.yaml'); const DEFAULT_REGISTRY = 'https://registry.npmjs.org'; diff --git a/scripts/check-published-files.mjs b/scripts/check-published-files.mjs index 74ed98b583..49ba301b4a 100644 --- a/scripts/check-published-files.mjs +++ b/scripts/check-published-files.mjs @@ -190,6 +190,71 @@ function matcher(pattern) { return (rel) => rx.test(rel) || rel.startsWith(prefix); } +/** + * This gate's population, written in the syntax `scripts/pm/dispatch-gates.mjs` + * can read (#10542). + * + * ── The defect this repairs ───────────────────────────────────────────────── + * + * The dispatch derivation names a gate for a card by scanning the gate's own + * module body for the path literals it operates on. This gate computes its + * population at RUNTIME instead — `workspaceGlobs()` below parses + * pnpm-workspace.yaml — so it spelled no workspace path literal anywhere, and + * the derivation therefore named it for NO card in the tree. Measured against + * the four layout specimens #10542 uses (a flat package, a nested package, an + * app manifest, an example manifest), `coveringKey` returned null for all four. + * + * That is a strictly worse failure than a gate with a stale hardcoded list: a + * runtime-computed population is invisible rather than wrong, so nothing in + * the output says the gate was ever considered. + * + * ── Why the glob spelling, and why it is not a second source of truth ─────── + * + * `hintCovers` refuses a literal with no path separator (`packages`, `apps`, + * `examples`) as too generic — measured, at +139084 fabricated (gate, file) + * pairs if bare top-level words were admitted, because those words are path + * COMPONENTS in dozens of gates that never read the root. The sanctioned escape + * is for a gate to declare its own subtree in a spelling that carries a + * separator, which is what these entries do. They are the workspace globs + * VERBATIM, so the glob collapse reduces each back to the root it names and to + * nothing else. + * + * Nothing in this gate reads this array — `workspaceGlobs()` still parses the + * YAML, and remains the only thing the scan walks. The self-test reconciles the + * two in BOTH directions against that live parse, so a workspace root added to + * or removed from pnpm-workspace.yaml fails here rather than leaving this + * declaration describing a workspace that moved. A declaration that can drift + * from the scan is worse than none: it replaces a silent gate with a lying one. + * + * ── Why the WHOLE workspace is honest here, with the measurement ──────────── + * + * This is the `subtree` case, not the `filtered` one check-examples-live-imports + * refuses. `walk()` below enumerates EVERY non-build file of every publishable + * member and MINIMAL judges each of them against FORBIDDEN, so the declaration + * names files this gate really opens. Measured on this tree: the declaration + * names 5263 tracked files and the gate judges 4803 of them — 91.3%. The 460 it + * does not judge are the members whose OWN manifests this gate read in order to + * exclude them (`private`), which is itself a read of the declared subtree, so + * a manifest card there is a true lead rather than a fabricated one. + * + * The contrast that sets the boundary is in check-published-readme-exports.mjs, + * which enumerates the same members and scores 2.8% — its refusal docblock + * carries that measurement and declines the same declaration. + */ +const ROOT_DIR_WATCH_HINTS = [ + 'packages/*', + 'packages/adapters/*', + 'packages/apps/*', + 'packages/connectors/*', + 'packages/drivers/*', + 'packages/plugins/*', + 'packages/qa/*', + 'packages/services/*', + 'packages/triggers/*', + 'apps/*', + 'examples/*', +]; + /** * The `packages:` globs from pnpm-workspace.yaml. Blank lines and comments are * skipped rather than treated as the end of the list: stopping early would drop @@ -330,14 +395,49 @@ function selfTest() { failures.push(`FORBIDDEN("${path}") === ${actual}, expected ${expected}`); } } + + // ── the dispatch-gates declaration (#10542) ─────────────────────────────── + // + // Enforcement cannot hold any of these: ROOT_DIR_WATCH_HINTS is read by + // another tool entirely, so a wrong or stale one runs green here forever and + // pays itself out as a dev dispatched on a packaging card with this gate + // missing from the brief. Both directions are reconciled against the LIVE + // parse rather than re-spelled, so a workspace root that moves cannot leave + // the declaration describing the old one. + const declaredRoots = ROOT_DIR_WATCH_HINTS.map((h) => h.replace(/\/\*+$/, '')); + const liveGlobs = workspaceGlobs(); + const liveRoots = liveGlobs.map((g) => g.replace(/\/\*+$/, '')); + const declarationCases = [ + [ + 'every workspace glob this gate walks is declared (a root with no path separator is refused as too generic, so the population needs the glob spelling)', + liveRoots.every((r) => declaredRoots.includes(r)), + ], + [ + 'and it declares no root the workspace does not have (a declaration that can drift from the scan is worse than none — it replaces a silent gate with a lying one)', + declaredRoots.every((r) => liveRoots.includes(r)), + ], + [ + 'every declared entry carries a path separator (the whole point of the spelling: hintCovers refuses a bare top-level word, so a tidy-up back to directory names re-opens the blind spot silently)', + ROOT_DIR_WATCH_HINTS.every((h) => h.includes('/')), + ], + [ + 'no declared entry is the bare root itself (provenance, never a lookup key)', + ROOT_DIR_WATCH_HINTS.every((h) => !liveRoots.includes(h)), + ], + ]; + for (const [name, ok] of declarationCases) { + if (!ok) failures.push(`ROOT_DIR_WATCH_HINTS: ${name}`); + } + if (failures.length > 0) { console.error(`✗ check:published-files --self-test — ${failures.length} failure(s)\n`); for (const f of failures) console.error(` ${f}`); process.exit(1); } console.log( - `✓ check:published-files --self-test — ${cases.length} pattern case(s) and ` + - `${forbidden.length} classification case(s).`, + `✓ check:published-files --self-test — ${cases.length} pattern case(s), ` + + `${forbidden.length} classification case(s) and ${declarationCases.length} ` + + `population-declaration case(s) over ${liveGlobs.length} live workspace glob(s).`, ); } diff --git a/scripts/check-published-readme-exports.mjs b/scripts/check-published-readme-exports.mjs index 0eabb93493..a5af4114fe 100644 --- a/scripts/check-published-readme-exports.mjs +++ b/scripts/check-published-readme-exports.mjs @@ -300,6 +300,60 @@ const WORKSPACE_FILE = 'pnpm-workspace.yaml'; const SELF = 'scripts/check-published-readme-exports.mjs'; const BASELINE_REL = 'scripts/published-readme-exports.baseline.json'; +/** + * ⛔ THIS GATE DECLARES NO WORKSPACE POPULATION, DELIBERATELY (#10542). + * + * There is no `ROOT_DIR_WATCH_HINTS` array below, and adding one would be a + * regression rather than a fix. This docblock is that decision, with the + * measurement that made it, because the card that dispatched the work assumed + * the opposite and the source says otherwise. + * + * ── What #10542 expected ──────────────────────────────────────────────────── + * + * `scripts/pm/dispatch-gates.mjs` names a gate for a card by scanning the + * gate's module body for path literals. This gate computes its population at + * RUNTIME (it parses pnpm-workspace.yaml, exactly as check-published-files.mjs + * does), so it names no workspace path and the derivation named it for no card. + * #10542 grouped it with check-published-files.mjs and said of the pair that + * they "really do read every published package's manifest, so their population + * genuinely is the workspace and the #10114 escape applies directly". + * + * ── What the source actually does, and the measurement that decides it ────── + * + * The two gates enumerate the same members and then diverge completely. + * check-published-files.mjs JUDGES every non-build file it walks — MINIMAL + * tests each one against FORBIDDEN — so a declaration of the workspace globs + * names files it really opens: 4803 of the 5263 tracked files the declaration + * would name, 91.3%. + * + * This gate walks the same trees and then narrows twice, to published markdown + * and to the manifests: `publishedMarkdown` keeps only `.md` paths the `files` + * whitelist admits, and the rest of the read surface is `/package.json` + * plus the built type entry under `/dist/`. Measured on the same tree, + * the same declaration would name 5263 tracked files to reach 149 — 2.8%. + * + * That is the `filtered` shape check-examples-live-imports.mjs refuses by name + * at 1.6%, and `hintCovers`' docblock prices a fabricated lead above a missing + * one: a `packages/**`-class declaration here would paste this gate into every + * dispatch prompt whose surface brushes any package source file, and 97 of + * every 100 of those leads would name a gate that never opens the file. The + * "22 leads is the same as none" failure, bought at a worse ratio than the + * wholesale admission the derivation already refuses. + * + * ── What this gate is left with, and why that is the honest state ─────────── + * + * A card editing this script names it by identity. A card editing a published + * README or a manifest names it by nothing — a real blind spot, and one the + * instrument cannot close: a root hint covers a whole SUBTREE, and there is no + * spelling for "the README of each workspace member". Recorded here rather than + * repaired falsely; if the derivation ever grows a narrower key than a subtree + * root, this is the gate to revisit first. + * + * The refusal is pinned in `--self-test` rather than left in this paragraph, so + * a later author who adds the workspace globs meets an assertion. + */ +const DECLARED_WORKSPACE_POPULATION = []; + // ⛔ SHRINK-ONLY. The authority token the #8435 convention requires; the // baseline is a maintainer's registry, never an author's escape hatch. const RATCHET_AUTHORITY_MARKER = '⛔ MAINTAINER-ONLY'; @@ -3400,6 +3454,20 @@ function selfTest() { ); } + // The dispatch-gates population refusal (#10542). Enforcement cannot hold + // this: the declaration is read by another tool entirely, so a wrongly-added + // one runs green here forever and pays itself out as a fabricated lead in + // every packages/** dispatch prompt. See DECLARED_WORKSPACE_POPULATION's + // docblock for the 2.8% measurement that refused it. + if (DECLARED_WORKSPACE_POPULATION.length !== 0) { + failures.push( + 'this gate must declare NO workspace population: it narrows the walk to published ' + + 'markdown and manifests, so the workspace globs would name 5263 tracked files to ' + + 'reach 149 (2.8%). check-published-files.mjs walks the same members and judges every ' + + 'file it finds (91.3%), which is why the declaration is honest THERE and not here.', + ); + } + if (failures.length > 0) { console.error(`✗ check:published-readme-exports --self-test — ${failures.length} failure(s)\n`); for (const f of failures) console.error(` ${f}`); diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 51add75991..1b9df36d2d 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -478,6 +478,64 @@ export function declaredNoCheckFamiliesReason(workflowText) { return m ? m[1].trim() : null; } +/** + * A GATE SCRIPT's own declaration that it deliberately has no path population — + * a whole-line comment anywhere in the script's source: + * + * // dispatch-gates: no-path-population -- + * # dispatch-gates: no-path-population -- (shell gates) + * + * ## What it is for (#10542) + * + * `undetermined` is this derivation's honest bucket — "source names no path at + * all, NOT known irrelevant" — and it is honest precisely because it does not + * claim to know why. That is the right verdict and the wrong report: measured + * over this tree, the bucket holds gates whose emptiness has three completely + * different causes, and a reader cannot tell them apart: + * + * the derivation CANNOT place it a gate whose population is a registry, + * not a path (the spec-liveness job) + * the derivation NEED NOT place it a gate whose CI invocation is its own + * `--self-test`, so no card's file surface + * should ever schedule it + * the derivation ALREADY places it a gate whose population is one repo-root + * file its own workflow names in `paths:`, + * reached through the trigger key (#9171) + * rather than through a hint + * + * The last two are FINISHED work that reads exactly like the unexamined pile. + * #10542 was filed against a count of that pile, and the count could not have + * distinguished them — which is the failure this marker retires: a family whose + * emptiness has been read and explained says so, in its own source, and the + * residue reports it apart from the families nobody has looked at. + * + * ## Why a marker IN the gate, never a list in this script + * + * Same reason as `declaredNoCheckFamiliesReason` above, one level down: a + * hardcoded roster here is a second copy of a fact that belongs on the thing it + * describes, and it drifts silently — the gate grows a real population, or gets + * renamed, and the roster keeps vouching for it. A marker the gate carries is + * read fresh on every run. + * + * The reason is REQUIRED, not just the marker. An opt-out with no reason reads + * identically to a placeholder nobody will revisit, and is exactly the shape a + * reviewer cannot tell apart from a gate whose population was never examined. + * + * ⚠️ This marker is NOT an escape from declaring a real population. A gate that + * walks a subtree declares it (the `ROOT_DIR_WATCH_HINTS` idiom); a gate whose + * population is a repo-root file declares the subtree spelling. The marker is + * for the families where BOTH of those are false, and the self-test holds that + * line by asserting the live tree's markers are only ever on families this + * derivation leaves unplaced. + */ +const NO_PATH_POPULATION_MARKER = + /^[ \t]*(?:\/\/|#)[ \t]*dispatch-gates:[ \t]*no-path-population[ \t]*--[ \t]*(\S.*)$/m; + +export function declaredNoPathPopulation(scriptSource) { + const m = NO_PATH_POPULATION_MARKER.exec(String(scriptSource)); + return m ? m[1].trim() : null; +} + /** * The workflows (by filename) that violate the #9187 coverage invariant: * @@ -2174,6 +2232,10 @@ export const CHANGE_KIND_GATES = [ name: 'check:engine-double-contract', why: 'it walks every *.test.* file for fake engine doubles and fails when one declares delete()/update() without routing through assertEngineDeleteDispatch/assertEngineUpdateDispatch, against a shrink-only per-file baseline. A new double, or a new test file carrying one, moves it — and so does a delegating pass-through seam wrapping a real engine, which is the reading that missed it twice. Repair by fixing the double, never by raising the baseline. Cheap and whole-tree: one run answers for the whole repo and names the file and line', }, + { + name: 'check:cross-package-test-inputs', + why: "it walks packages/, apps/ and examples/ for tests that read or import OUTSIDE their own package, and fails when turbo.json's `inputs` for that package does not declare what the test really reads — so a new test, or a new cross-package read in an existing one, moves it. Listed as a KIND rather than by path (#10542): its walk covers 5263 tracked files to judge the 2611 test files among them, so a subtree declaration would name it at 49.6% precision, while the kind names it at the granularity it actually judges. Repair by declaring the input in turbo.json, never by moving the fixture", + }, { name: 'check:where-matcher', why: 'it walks every *.test.ts file for hand-written WHERE matchers and fails on a NEW silently-wrong one (a combinator read as a field name), against a shrink-only baseline. It rides the same test code as the double gate — one new fake engine tripped both, one round apart, because these steps run sequentially inside the ESLint job and the first failure aborts the rest. Conforming by REFUSING the unsupported shape is the convention most of the discovered matchers already follow; the suite cannot notice this class, which is why the gate exists', @@ -2505,7 +2567,10 @@ export function unreachableLines(unreachable, swept) { } export function residueLines( - { discovered, matched, undetermined, silent, unfiltered, unreachable, swept, artifactRosters, invertedRosters }, + { + discovered, matched, undetermined, silent, unfiltered, unreachable, swept, + artifactRosters, invertedRosters, documentedNoPopulation, + }, kinds = CHANGE_KIND_GATES, ) { const placed = matched + undetermined + silent; @@ -2549,11 +2614,30 @@ export function residueLines( "(the rosters whose common directory contains one of the caller's paths — a subset, never omitted)", ); } + // Held to the same standard as the counts above, and for the same reason + // (#10542): this one sizes the part of `undetermined` that has been READ and + // explained, and a count that could go missing quietly would render the one + // line separating examined from unexamined as a line with `undefined` in it. + if ( + !Number.isInteger(documentedNoPopulation) + || documentedNoPopulation < 0 + || documentedNoPopulation > undetermined + ) { + throw new Error( + `documented-no-population count is not derivable: got ${String(documentedNoPopulation)} of ${undetermined} undetermined ` + + '(it is a subset of the undetermined families, read from each gate\'s own marker — never omitted)', + ); + } const unplaced = undetermined + silent; return [ `Residue — all ${discovered} discovered famil(ies) placed, derived at runtime:`, ` ${matched} matched above · ${undetermined} undetermined (their sources name no path at all — NOT known irrelevant)` + ` · ${silent} silent (their sources name paths, none of which cover yours).`, + ` ${documentedNoPopulation} of those ${undetermined} undetermined famil(ies) DECLARE that they have no path population, each with its own` + + ' reason, read from a marker in the gate\'s source and printed against it under --residue. Those have been examined; the rest of the bucket' + + ' has not, and the two used to read alike. A declaration is not an escape from having a population: a gate that walks a subtree declares it' + + ' (the ROOT_DIR_WATCH_HINTS idiom) and a gate whose population is a repo-root FILE declares the subtree spelling — the marker is only for the' + + ' families where both of those are false.', ' A `silent` verdict is this derivation\'s weakest claim, not a clearance, and there are two ways to earn it that have' + ' nothing to do with your paths: a gate that computes its own population and names only its baseline artifact scores' + ' silent for every card in the tree, and so does one whose population is a repo-root FILE it spells as a bare' + @@ -2905,8 +2989,15 @@ export function discoverFamilies() { entry.files = files; for (const f of files) { const abs = join(ROOT, f); - if (existsSync(abs)) entry.hints.push(...extractWatchHints(readFileSync(abs, 'utf8'))); + if (!existsSync(abs)) continue; + // ONE read, two answers — the hints and the gate's own no-population + // declaration — so the pair can never describe different revisions of a + // file, the same discipline the trigger paths take above. + const source = readFileSync(abs, 'utf8'); + entry.hints.push(...extractWatchHints(source)); + entry.noPopulationReason ??= declaredNoPathPopulation(source); } + entry.noPopulationReason ??= null; } return { byCheck, workflows }; } @@ -2986,6 +3077,13 @@ function derive(paths, { showResidue = false } = {}) { ? ` names: ${[...new Set(entry.hints)].slice(0, 3).join(', ')}${entry.hints.length > 3 ? ', …' : ''}` : ''; console.log(` - ${runnableInvocation(entry)} [${[...entry.workflows].join(', ')}]${names}`); + // The gate's own account of why it names nothing (#10542), printed + // against the family rather than only counted in the residue: a reader + // looking at this listing is deciding whether to go READ the gate, and + // that is exactly the decision this declaration answers. + if (entry.noPopulationReason) { + console.log(` ↳ declared no path population — ${entry.noPopulationReason}`); + } // The silence split (#10784): a family that declared only ARTIFACTS // said the same words in this listing as one that really does not read // your file. The note says which, and raises its voice only where the @@ -3016,6 +3114,7 @@ function derive(paths, { showResidue = false } = {}) { swept: swept.length, artifactRosters: rosters.length, invertedRosters: rosters.filter((r) => r.coversYourPath).length, + documentedNoPopulation: undetermined.filter(([, e]) => e.noPopulationReason).length, })) { console.log(line); } @@ -4247,7 +4346,10 @@ function selfTest() { const resolved = (name) => `pnpm ${name}`; const kindHit = changeKindLines(['packages/objectql/src/engine.test.ts'], resolved); - t('a test path emits the convention section', kindHit.length === 6 && kindHit[0].includes('adds or edits a test file')); + // Seven: the kind's own heading plus its six gates (#10542 added + // check:cross-package-test-inputs, whose judged population is exactly this + // kind rather than a subtree any path hint can name). + t('a test path emits the convention section', kindHit.length === 7 && kindHit[0].includes('adds or edits a test file')); // All three halves anchor on the rendered DELIMITERS (`- pnpm x —`), for the // reason the i18n entry's pins below state at length: a bare `includes` is // satisfied by every name that merely STARTS WITH the expected one, so a @@ -4766,12 +4868,13 @@ function selfTest() { // The table's own rot detector: a name no live run discovers must say so, // never disappear quietly. const stale = changeKindLines(['a.test.ts'], () => null); - // Six, not five, since the root-program entry joined the table: `a.test.ts` - // is a root-level TypeScript file, so it is BOTH a test file and inside the - // root tsc program and legitimately hits two kinds. The ratchet therefore - // renders twice, under a different `why` each time — pinned just below, - // because a bare count cannot tell that apart from one kind rotting away. - t('an undiscoverable gate renders as STALE', stale.filter((l) => l.includes('STALE')).length === 6); + // Seven, not six, since #10542 added check:cross-package-test-inputs to the + // test-file kind. `a.test.ts` is a root-level TypeScript file, so it is BOTH + // a test file and inside the root tsc program and legitimately hits two + // kinds. The ratchet therefore renders twice, under a different `why` each + // time — pinned just below, because a bare count cannot tell that apart from + // one kind rotting away. + t('an undiscoverable gate renders as STALE', stale.filter((l) => l.includes('STALE')).length === 7); t('a root-level test file hits both kinds, so the ratchet renders STALE under each', stale.filter((l) => l.includes('\u26a0 check:type-check-debt: STALE')).length === 2); // Per NAME, anchored on both sides of the rendered name (`⚠ x: STALE`), so the // pair that shares one script is reported apart: a count alone stays green if @@ -4779,6 +4882,10 @@ function selfTest() { // leading substring stays green through a `-v2` rename — the two ways this // table has actually rotted. t('the coverage half renders STALE under its own name', stale.some((l) => l.includes('⚠ check:type-check-coverage: STALE'))); + t( + 'and so does the cross-package-inputs entry, anchored on both sides of its own name', + stale.some((l) => l.includes('⚠ check:cross-package-test-inputs: STALE')), + ); t('the ratchet half renders STALE under its own name', stale.some((l) => l.includes('⚠ check:type-check-debt: STALE'))); t('the engine-double ratchet renders STALE under its own name', stale.some((l) => l.includes('⚠ check:engine-double-contract: STALE'))); t('the where-matcher ratchet renders STALE under its own name', stale.some((l) => l.includes('⚠ check:where-matcher: STALE'))); @@ -4809,6 +4916,10 @@ function selfTest() { // — a count alone stays green if one is dropped and another added. t('check:engine-double-contract is a live family, so naming it in the table is not a guess', liveFamilies.has('check:engine-double-contract')); t('check:where-matcher is a live family too — the gate the prose never named', liveFamilies.has('check:where-matcher')); + t( + 'check:cross-package-test-inputs is a live family (#10542 moved it here from a path derivation that could name it at 49.6% precision at best)', + liveFamilies.has('check:cross-package-test-inputs'), + ); // ── The check-family coverage guard (#9187) ─────────────────────────────── // @@ -4875,6 +4986,119 @@ function selfTest() { checkFamilyCoverageGaps([{ file: 'x.yml', text: exemptedWf }]).length === 0, ); + // ── The gate-level no-population declaration (#10542) ───────────────────── + // + // The workflow-level marker above says "this workflow names no gate"; this + // one says "this gate names no path, and here is why". Both directions are + // pinned, and so is the live tree, because the whole value of the second is + // that it separates families that have been READ from families nobody has + // looked at — and a marker that quietly stopped parsing would merge them back + // together while every count still printed. + t( + 'a gate-level no-population declaration reads its reason back', + declaredNoPathPopulation('// dispatch-gates: no-path-population -- CI runs the self-test only\n') + === 'CI runs the self-test only', + ); + t( + 'the shell comment spelling is read too (shell gates carry # comments, and the derivation discovers them)', + declaredNoPathPopulation('#!/usr/bin/env bash\n# dispatch-gates: no-path-population -- a shell gate reason\n') + === 'a shell gate reason', + ); + t('no marker present reads as no declared no-population', declaredNoPathPopulation('// just a comment\n') === null); + t( + 'the marker with no reason text does not count as declared (an opt-out with no reason reads exactly like a placeholder nobody will revisit)', + declaredNoPathPopulation('// dispatch-gates: no-path-population\n') === null, + ); + t( + 'the marker must be its OWN line — a mention inside prose is a discussion of the convention, not a declaration under it', + declaredNoPathPopulation('// see the dispatch-gates: no-path-population -- marker for how to opt out\n') === null, + ); + // The residue count that carries it refuses a missing or impossible value in + // the same shape as every other count in that line: a subset that could go + // absent quietly renders as `undefined` in the one line a reader needs. + const residueArgs = { + discovered: 3, matched: 1, undetermined: 1, silent: 1, unfiltered: 0, + unreachable: 0, swept: 10, artifactRosters: 0, invertedRosters: 0, + }; + t( + 'the residue REFUSES an omitted documented-no-population count', + (() => { + try { + residueLines({ ...residueArgs }); + return false; + } catch { + return true; + } + })(), + ); + t( + 'and refuses one larger than the undetermined bucket it is a subset of', + (() => { + try { + residueLines({ ...residueArgs, documentedNoPopulation: 2 }); + return false; + } catch { + return true; + } + })(), + ); + t( + 'and renders the count when it is derivable', + residueLines({ ...residueArgs, documentedNoPopulation: 1 }) + .some((l) => /1 of those 1 undetermined famil\(ies\) DECLARE/.test(l)), + ); + + // The live half. A marker is a claim about a gate, so it is held against the + // real derivation: a family that DOES name paths must not be carrying one. + // Without this the marker rots in the direction that costs — a gate grows a + // real population, keeps its old declaration, and the residue keeps vouching + // that its emptiness was examined. + const liveDiscovery = discoverFamilies(); + const declaredEmpty = [...liveDiscovery.byCheck].filter(([, e]) => e.noPopulationReason); + t( + `the live tree carries at least one no-population declaration (the guard is not vacuous; found ${declaredEmpty.length})`, + declaredEmpty.length > 0, + ); + const contradicted = declaredEmpty.filter(([, e]) => (e.hints ?? []).length > 0).map(([c]) => c); + t( + `no family both DECLARES no path population and names paths anyway (contradicted: ${contradicted.join(', ') || 'none'})`, + contradicted.length === 0, + ); + t( + 'every live declaration carries a non-empty reason', + declaredEmpty.every(([, e]) => typeof e.noPopulationReason === 'string' && e.noPopulationReason.length > 0), + ); + + // ── Hints come from the COMMAND's named scripts, never from their imports ── + // + // Load-bearing, and pinned here because a decision rests on it (#10542). The + // card proposed consolidating the dozen private pnpm-workspace.yaml parsers + // into one shared enumerator, on the reasoning that it would make the + // population declarable in ONE place. Under this derivation it would do the + // opposite: `resolveCheckToFiles` reads the script paths out of the npm + // script's COMMAND STRING, and `discoverFamilies` scans exactly those files. + // A module a gate imports is never opened, so moving a population declaration + // into a shared enumerator DELETES it from every gate that imports it — which + // would silently undo the very declarations #10540 and this card added. + // + // So the consolidation is blocked on teaching this derivation to follow + // first-party imports, not on the gates. Stated as an assertion rather than + // as that paragraph, so a later author measures it instead of trusting it. + const importingFamily = [...liveDiscovery.byCheck].find( + ([, e]) => (e.files ?? []).length === 1 + && existsSync(join(ROOT, e.files[0])) + && /^\s*import\s[^\n]*\sfrom\s+'\.\//m.test(readFileSync(join(ROOT, e.files[0]), 'utf8')), + ); + t('the live tree has a single-file family that imports a sibling module (the pin is not vacuous)', Boolean(importingFamily)); + if (importingFamily) { + const [, entry] = importingFamily; + const ownHints = new Set(extractWatchHints(readFileSync(join(ROOT, entry.files[0]), 'utf8'))); + t( + 'a family\'s hints are exactly those of the scripts its COMMAND names — an imported module contributes none, so a shared enumerator cannot carry a population declaration for its callers', + [...new Set(entry.hints)].every((h) => ownHints.has(h)), + ); + } + // The live guard: every REAL paths-filtered workflow either discovers a // family or declares why not. This is what actually fails CI the day a new // paths-filtered workflow adds an undiscoverable verification step and @@ -5036,7 +5260,7 @@ function selfTest() { // every discovered family, and it names no gate. The second is the one that // rots — a hand-written list of gate names in this paragraph is exactly what // was wrong with it — so it is asserted directly rather than by inspection. - const residue = residueLines({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, swept: 6000, artifactRosters: 4, invertedRosters: 1 }); + const residue = residueLines({ discovered: 98, documentedNoPopulation: 0, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, swept: 6000, artifactRosters: 4, invertedRosters: 1 }); t('the residue summary states the discovered total', residue.some((l) => l.includes('98'))); t('the residue summary states each bucket', residue.some((l) => l.includes('35 undetermined')) && residue.some((l) => l.includes('55 silent'))); t('the residue summary points at the flag that lists the unplaced families', residue.some((l) => l.includes('--residue') && l.includes('90'))); @@ -5047,7 +5271,7 @@ function selfTest() { // constant sentence would be a line the reader learns to skip. t('the residue summary sizes the artifact rosters inside silent', residue.some((l) => l.includes('4 of those 55'))); t('and calls out the ones whose roster sits where the card is', residue.some((l) => l.includes('For 1 of them') && l.includes('EITHER direction'))); - const noInverted = residueLines({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, swept: 6000, artifactRosters: 4, invertedRosters: 0 }); + const noInverted = residueLines({ discovered: 98, documentedNoPopulation: 0, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, swept: 6000, artifactRosters: 4, invertedRosters: 0 }); t('with none of them there it says THAT instead, rather than printing the warning at zero', noInverted.some((l) => l.includes('None of their rosters')) && !noInverted.join('\n').includes('EITHER direction')); // The same rot, one noun over (#10012). The top-level-FILE clause used to // illustrate the unreachable class with `README.md`, which was honest until @@ -5072,7 +5296,7 @@ function selfTest() { // which is the failure class this whole card is about. let refused = false; try { - residueLines({ discovered: 98, matched: 8, undetermined: 35, silent: 54, unfiltered: 80, unreachable: 5, swept: 6000, artifactRosters: 4, invertedRosters: 1 }); + residueLines({ discovered: 98, documentedNoPopulation: 0, matched: 8, undetermined: 35, silent: 54, unfiltered: 80, unreachable: 5, swept: 6000, artifactRosters: 4, invertedRosters: 1 }); } catch { refused = true; } @@ -5082,7 +5306,7 @@ function selfTest() { // a derivation rather than as the absent measurement it is. let refusedUnfiltered = false; try { - residueLines({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unreachable: 5, swept: 6000, artifactRosters: 4, invertedRosters: 1 }); + residueLines({ discovered: 98, documentedNoPopulation: 0, matched: 8, undetermined: 35, silent: 55, unreachable: 5, swept: 6000, artifactRosters: 4, invertedRosters: 1 }); } catch { refusedUnfiltered = true; } @@ -5102,34 +5326,34 @@ function selfTest() { }; t( 'an omitted unreachable count is REFUSED, never printed as undefined', - refusedFor({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, swept: 6000, artifactRosters: 4, invertedRosters: 1 }), + refusedFor({ discovered: 98, documentedNoPopulation: 0, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, swept: 6000, artifactRosters: 4, invertedRosters: 1 }), ); t( 'an unreachable count with NO corpus size is REFUSED — the number is unreadable without it', - refusedFor({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, artifactRosters: 4, invertedRosters: 1 }), + refusedFor({ discovered: 98, documentedNoPopulation: 0, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, artifactRosters: 4, invertedRosters: 1 }), ); t( 'a sweep that swept zero files is REFUSED at the summary too, not printed as a clean repo', - refusedFor({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 0, swept: 0, artifactRosters: 4, invertedRosters: 1 }), + refusedFor({ discovered: 98, documentedNoPopulation: 0, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 0, swept: 0, artifactRosters: 4, invertedRosters: 1 }), ); t( 'zero unreachable over a real corpus is a legitimate answer, not a refusal', - !refusedFor({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 0, swept: 6000, artifactRosters: 4, invertedRosters: 1 }), + !refusedFor({ discovered: 98, documentedNoPopulation: 0, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 0, swept: 6000, artifactRosters: 4, invertedRosters: 1 }), ); // The silence split is held to the same standard as the counts above: it is // a SUBSET count, so both directions of the subsetting are refused rather // than trusted, and an omitted one must not print as `undefined`. t( 'an omitted artifact-roster count is REFUSED, never printed as undefined', - refusedFor({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, swept: 6000, invertedRosters: 0 }), + refusedFor({ discovered: 98, documentedNoPopulation: 0, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, swept: 6000, invertedRosters: 0 }), ); t( 'a roster count larger than the silent bucket it subsets is REFUSED', - refusedFor({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, swept: 6000, artifactRosters: 56, invertedRosters: 0 }), + refusedFor({ discovered: 98, documentedNoPopulation: 0, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, swept: 6000, artifactRosters: 56, invertedRosters: 0 }), ); t( 'and an inverted count larger than the rosters it subsets is REFUSED', - refusedFor({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, swept: 6000, artifactRosters: 4, invertedRosters: 5 }), + refusedFor({ discovered: 98, documentedNoPopulation: 0, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, swept: 6000, artifactRosters: 4, invertedRosters: 5 }), ); // ── The families a changeset will add (#10309) ──────────────────────────── diff --git a/scripts/release-github-releases.mjs b/scripts/release-github-releases.mjs index 7555947d0b..2c44a9ac5e 100644 --- a/scripts/release-github-releases.mjs +++ b/scripts/release-github-releases.mjs @@ -289,6 +289,18 @@ export function buildReleaseBody({ entry, tagName, changelogLabel, changelogHref // ───────────────────────────────────────────────────────────────────────────── // Workspace discovery // ───────────────────────────────────────────────────────────────────────────── +// +// The gate family this script contributes is `check:release-body`, and that +// script is `--self-test` ALONE: lint.yml runs the renderer against fixtures, +// never against the tree. The workspace read below and the CHANGELOG reads +// belong to the RELEASE run, which no pull request schedules. So there is no +// card whose file surface should name this family except one editing this file, +// and identity matching already covers that. The zero hints have a second cause +// worth keeping straight, recorded in `isNonPathNamespace`: the one pathy +// literal this module body used to contribute was a media type, refused as a +// non-filesystem namespace. Both are true, and neither is a missing declaration. +// +// dispatch-gates: no-path-population -- check:release-body runs this renderer's --self-test against fixtures only; the workspace and CHANGELOG reads belong to the release run, which no pull request schedules /** * Minimal pnpm-workspace.yaml `packages:` reader. Same approach as