From 9daf9ea163176da495e633c3d51559bdb853e7eb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 10:31:10 +0000 Subject: [PATCH 1/3] fix(devx): declare the populations four under-matching gates really read, and refuse a dead declaration Fixes #13519 --- .github/workflows/lint.yml | 18 ++ package.json | 1 + packages/spec/scripts/build-skill-docs.ts | 56 +++++ scripts/check-declared-population-live.mjs | 279 +++++++++++++++++++++ scripts/check-service-providers.mjs | 71 +++++- scripts/check-turbo-task-graph.mjs | 107 +++++++- scripts/measure-stall-guard-headroom.mjs | 73 ++++++ scripts/pm/check-clause2-carriers.mjs | 14 +- 8 files changed, 610 insertions(+), 9 deletions(-) create mode 100644 scripts/check-declared-population-live.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 41da27a48d..1c4b487f33 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -688,6 +688,24 @@ jobs: - name: ROOT_DIR_WATCH_HINTS declarations are literals run: pnpm check:watch-hint-literal + # The other half of the same failure (#13519). The step above holds that a + # declaration is still READABLE BY A TEXT SCANNER; this one holds that + # what it reads still names something. A family whose declared literals + # all reach zero tracked files prints as an ORDINARY SILENCE — byte for + # byte the output of a gate that declared nothing — so the derivation and + # the gate disagree about whether a population was declared and neither + # says so. Measured on the tree this landed against: two families, one of + # them a gate that sweeps every workflow file and appeared on no card that + # edited one. The rule is per-FAMILY, not per-literal: a gate may spell a + # slug or a sentinel path beside a live declaration, and only a whole + # declaration that reaches nothing is refused. No allowlist and no gate + # names — the stronger rule ("a gate that enumerates a directory must + # declare one") was implemented, measured at 86 findings over 114 + # enumerating gate files, and refused as an allowlist with a verdict + # attached. Reads the derivation once over the tracked corpus; ~5s. + - name: A declared gate population reaches the tree + run: pnpm check:declared-population-live + # PM bare-root worklist self-test (#10840). The step above proves the # dispatch derivation still WORKS; this one proves the recorded triage of # the gates that derivation structurally cannot see is still true of the diff --git a/package.json b/package.json index c96be9345f..b2f7666356 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "check:pm-label-desc-cap": "node scripts/pm/check-label-desc-cap.mjs --self-test && node scripts/pm/check-label-desc-cap.mjs", "check:pm-dispatch-gates": "node scripts/pm/check-dispatch-gates.mjs", "check:watch-hint-literal": "node scripts/check-watch-hint-literal.mjs --self-test && node scripts/check-watch-hint-literal.mjs", + "check:declared-population-live": "node scripts/check-declared-population-live.mjs --self-test && node scripts/check-declared-population-live.mjs", "check:pm-half-states": "node scripts/pm/check-half-states.mjs --self-test", "check:pm-clause2-carriers": "node scripts/pm/check-clause2-carriers.mjs --self-test", "check:pm-governed-merges": "node scripts/pm/check-governed-merges.mjs --self-test", diff --git a/packages/spec/scripts/build-skill-docs.ts b/packages/spec/scripts/build-skill-docs.ts index 297b83b027..feb15f7950 100644 --- a/packages/spec/scripts/build-skill-docs.ts +++ b/packages/spec/scripts/build-skill-docs.ts @@ -29,6 +29,60 @@ const SKILLS_DIR = path.resolve(REPO_ROOT, 'skills'); const README = path.resolve(SKILLS_DIR, 'README.md'); const GUIDE = path.resolve(REPO_ROOT, 'content/docs/ai/skills-reference.mdx'); +/** + * The population this script READS, declared for `scripts/pm/dispatch-gates.mjs` + * — the `ROOT_DIR_WATCH_HINTS` idiom, spelled as a LITERAL array because the + * hint extractor reads source TEXT (a declaration computed from `SKILLS_DIR` + * would build no hint at all; `scripts/check-watch-hint-literal.mjs` holds that + * spelling for every declarer in the tree). + * + * ## What was declared before, and why it under-matched + * + * The only path literal this module body spelled was the GUIDE path, + * `content/docs/ai/skills-reference.mdx` — an OUTPUT. So `check:skill-docs` + * declared its generated artifact and not one of its inputs: a card editing + * `skills//SKILL.md` — the file whose frontmatter is the entire catalog — + * was never told that this gate reads it, and `--check` reds in CI on drift the + * derivation could have predicted. + * + * The inputs were unspellable rather than forgotten: `SKILLS_DIR` is built with + * `path.resolve(REPO_ROOT, 'skills')`, and a single-segment literal is refused + * by the extractor as too generic to be a path population. The subtree spelling + * is the escape, and it is what the idiom exists for. + * + * `skills/README.md` is an output too, but it sits INSIDE the declared subtree, + * so no separate literal claims it — the population is the directory this + * script both reads and rewrites. + */ +const ROOT_DIR_WATCH_HINTS = ['skills/**']; + +/** + * The declaration above, held against the constant this script really reads + * from. A hand-written path that agreed with the read only on the day it was + * typed is the drift this idiom replaces, so the check compares the declared + * root to `SKILLS_DIR` rather than to a second copy of the string — move the + * directory and this throws here, in this file, instead of going quiet in a + * dispatch brief. + */ +function assertWatchHintsDeclareTheReadSurface(): void { + const declaredRoots = ROOT_DIR_WATCH_HINTS.map((h) => h.replace(/\/\*+$/, '')); + const readRoot = path.relative(REPO_ROOT, SKILLS_DIR).split(path.sep).join('/'); + if (declaredRoots.length !== 1 || declaredRoots[0] !== readRoot) { + throw new Error( + `build-skill-docs: the declared watch-hint population ${JSON.stringify(ROOT_DIR_WATCH_HINTS)} no longer ` + + `names the directory this script reads (${readRoot}) — update the declaration, as a LITERAL array.`, + ); + } + // …and the declared form is the SUBTREE spelling, not the bare directory: + // that is what the idiom's other declarers spell and what + // `check-watch-hint-literal.mjs` reads them as. + if (!ROOT_DIR_WATCH_HINTS.every((h) => h.endsWith('/**'))) { + throw new Error( + `build-skill-docs: the watch-hint declaration must use the subtree spelling: ${JSON.stringify(ROOT_DIR_WATCH_HINTS)}`, + ); + } +} + // Marker comments delimit the generated region. MDX does not support HTML // comments (``) — it needs `{/* */}` — so the syntax is per file type. type CommentStyle = 'html' | 'mdx'; @@ -216,6 +270,8 @@ function spliceBlock(file: string, block: string, style: CommentStyle): string { function main() { const check = process.argv.includes('--check'); + assertWatchHintsDeclareTheReadSurface(); + // Catalog ⇄ DISPLAY must be in lockstep. const onDisk = fs .readdirSync(SKILLS_DIR) diff --git a/scripts/check-declared-population-live.mjs b/scripts/check-declared-population-live.mjs new file mode 100644 index 0000000000..610591f681 --- /dev/null +++ b/scripts/check-declared-population-live.mjs @@ -0,0 +1,279 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-declared-population-live (#13519) -- a gate family that DECLARES a path + * population must declare at least one path that reaches the tree. + * + * node scripts/check-declared-population-live.mjs # the sweep (the gate) + * node scripts/check-declared-population-live.mjs --list # every family and its verdict + * node scripts/check-declared-population-live.mjs --self-test # the rule itself + * + * ## The failure this exists for, and why nothing could see it + * + * The dispatch derivation scans a gate script's module body for path-shaped + * string literals and treats them as the population that gate reads. The + * admission test is "quoted, and looks like a repo path" -- which a repo SLUG + * passes. So a gate whose only such literal was `/` declared a + * population of exactly one path, and that path names no tracked file at all. + * + * The result is not an error and not an empty answer. It is a family whose + * declared population is DEAD, printed as an ordinary silence -- byte for byte + * the output a gate with no literals produces, and indistinguishable from it by + * anyone reading the brief. The gate in question sweeps every workflow file in + * the tree; it appeared on no card that edited one, for as long as it existed. + * + * ⛔ The repair for that gate is NOT this gate's name in a table, and neither is + * this file. This lane's triage has ruled three times that "add a gate name to + * the derivation" is the repair that keeps shipping the same red under the next + * gate's name. Each under-declaring gate was repaired where the read lives -- a + * literal population declaration in its own module body, held against the very + * constant it reads from, so a moved read reds in that gate's own self-test. + * What is added HERE is the property none of them can hold about itself: that a + * declaration which reaches nothing is refused OUT LOUD instead of passing for + * a gate that declared nothing. It names no gate, and it holds for the gate + * written next year. + * + * ## The rule, and why it is per-FAMILY rather than per-literal + * + * A family is judged only on the literals it declares ITSELF -- an inherited + * population belongs to the module it was declared in and is judged there. + * + * - declares nothing -> nothing to be wrong about. Not a finding: + * "this gate has no path population" is a + * legitimate and separately declarable fact. + * - declares n, >= 1 reaches -> live. + * - declares n, none reaches -> REFUSED. + * + * Per-literal would be the stricter rule and it is WRONG here, measured rather + * than assumed: real gates carry path-shaped literals that are not populations + * -- a repo slug for an API call, a sentinel path that must not exist, a + * remedy sentence's example. Refusing each of those individually would turn a + * gate red for spelling a string, and the remedy would be to hide the string, + * which teaches exactly the wrong lesson. What cannot be defended is a family + * whose WHOLE declaration is dead: there, the gate is telling the derivation it + * reads a population and the derivation is reading none. + * + * ## Why this is a gate and not a line in the derivation's own self-test + * + * The tool's self-test is the right home for claims about the DERIVATION. This + * is a claim about the TREE -- about what gate authors have written -- and it + * moves with files this tool does not own. It also has to be able to red + * without the derivation being wrong, which is the opposite of what a tool + * self-test asserts. + * + * ## What was measured before choosing this rule (at 45b9051248) + * + * The rule that suggests itself first is stronger and was implemented and + * REFUSED: "a gate that enumerates a directory must carry a watch-hint + * declaration". Over the 193 discovered gate files, 114 enumerate a directory + * and 86 of those carry no declaration under any spelling of the idiom. A gate + * shipping with 86 findings is an allowlist with a verdict attached, and an + * allowlist of gate names is the repair this lane has already ruled against. + * Most of those 86 are not defects either: they declare their population as + * ordinary path literals, which is the normal way and invisible to a scan for + * the idiom. + * + * The rule this file ships found exactly 2 families in the same fleet, both of + * them real: the lead gate above, and one whose only path-shaped literal is the + * `/` placeholder inside a refusal message -- a gate that has no + * path population and can now say so. No allowlist, no ratchet, no names. + */ + +import process from 'node:process'; + +import { discoverFamilies, hintCovers, trackedFiles, watchHintTree } from './pm/dispatch-gates.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; + +export const EXIT_FINDINGS = 1; +export const EXIT_REFUSED = 2; + +/** + * The verdict for ONE family, from its own declared literals and a reachability + * oracle. Pure: the sweep passes the live corpus, the self-test passes a + * fixture, and neither can be right about a rule the other is wrong about. + * + * @param {string[]} declared literals the family spells ITSELF (not inherited) + * @param {(hint: string) => boolean} reaches + * @returns {'no-declaration' | 'live' | 'dead'} + */ +export function declarationVerdict(declared, reaches) { + const literals = [...new Set(declared ?? [])]; + if (literals.length === 0) return 'no-declaration'; + return literals.some((h) => reaches(h)) ? 'live' : 'dead'; +} + +/** + * The literals a family declares in its OWN files, in declaration order. + * + * An inherited hint carries a `hintOrigin` recording the module it came from, + * and that module is judged as its own family (or is a gate file, whose + * population the follow deliberately leaves to it). Judging an inheritor for + * its source's declaration would report the same fact once per importer and + * point every copy at a file the importer's author does not own. + */ +export function ownDeclaredHints(entry) { + return (entry.hints ?? []).filter((h) => !entry.hintOrigin?.has(h)); +} + +/** Every family's verdict over the live tree, in discovery order. */ +export function sweep() { + const files = trackedFiles(); + const tree = watchHintTree(files); + const { byCheck } = discoverFamilies({ tree }); + // ONE reachability answer per distinct literal, memoised: the corpus sweep is + // the expensive half and several families legitimately declare the same path. + const cache = new Map(); + const reaches = (hint) => { + if (!cache.has(hint)) cache.set(hint, files.some((f) => hintCovers(hint, f))); + return cache.get(hint); + }; + const rows = []; + for (const [check, entry] of byCheck) { + const declared = ownDeclaredHints(entry); + rows.push({ check, declared, verdict: declarationVerdict(declared, reaches), files: entry.files ?? [] }); + } + return { rows, families: byCheck.size, corpus: files.length }; +} + +/** + * The remedy, spelled per finding rather than once in prose: the two ways out + * are different edits in different places, and which one applies is a fact + * about the gate that only its author can settle. + */ +function remedyFor(row) { + return [ + ` ${row.check}`, + ` declares: ${row.declared.join(', ')}`, + ` files: ${row.files.join(', ') || '(none resolved)'}`, + ' None of those literals names a tracked path, so the derivation reads this family as', + ' declaring NO population -- the same output a gate with no literals produces. Either:', + ' (a) the gate DOES read a population: declare it in that gate\'s own module body as a', + ' literal array (the ROOT_DIR_WATCH_HINTS idiom), and hold it against the constant', + ' the gate reads from, in that gate\'s own --self-test; or', + ' (b) the gate reads no population: say so with a', + ' `dispatch-gates: no-path-population -- ` marker, and stop spelling the', + ' path-shaped literal that is being read as a declaration (a slug, a sentinel, an', + ' example inside a message) so the marker does not contradict the derivation.', + ].join('\n'); +} + +function main(argv) { + const { rows, families, corpus } = sweep(); + if (argv.includes('--list')) { + for (const row of rows) { + console.log(`${row.verdict.padEnd(15)} ${row.check}${row.declared.length ? ` -- ${row.declared.join(', ')}` : ''}`); + } + } + // An empty population is the one answer this gate must never print a green + // over: "no family declares a dead population" is vacuously true of a + // derivation that discovered nothing, and a discovery that silently returns + // nothing is a live failure mode of the tool this gate reads through. + const declaring = rows.filter((r) => r.verdict !== 'no-declaration'); + if (families === 0 || declaring.length === 0 || corpus === 0) { + console.error( + '✗ check:declared-population-live REFUSES: nothing was judged ' + + `(${families} famil(ies), ${declaring.length} declaring one, ${corpus} tracked file(s)). ` + + 'A run that judged no declaration says nothing about the tree.', + ); + return EXIT_REFUSED; + } + const dead = rows.filter((r) => r.verdict === 'dead'); + if (dead.length > 0) { + console.error( + `✗ check:declared-population-live: ${dead.length} of ${declaring.length} declaring famil(ies) declare a ` + + 'population that reaches NOTHING in this tree.\n', + ); + for (const row of dead) console.error(`${remedyFor(row)}\n`); + return EXIT_FINDINGS; + } + console.log( + `✓ check:declared-population-live — ${declaring.length} of ${families} famil(ies) declare a path population, ` + + `and every one of them reaches this tree's ${corpus} tracked file(s).`, + ); + return 0; +} + +function selfTest() { + const failures = []; + let checked = 0; + const t = (name, ok, detail) => { + checked += 1; + if (!ok) failures.push(detail ? `${name} -- ${detail}` : name); + }; + + // ── The rule, on fixtures. Every case names the VERDICT, never "it returned + // something": the defect this gate exists for produces a coherent, + // plausible answer, and a case that only checked for an answer is green + // against it. + const live = new Set(['a/b.mjs', 'c/d']); + const reaches = (h) => live.has(h); + t('a family that declares nothing is not a finding', declarationVerdict([], reaches) === 'no-declaration'); + t('…and neither is one whose hint list is absent', declarationVerdict(undefined, reaches) === 'no-declaration'); + t('one live literal is enough', declarationVerdict(['a/b.mjs'], reaches) === 'live'); + t( + '…including alongside dead ones, which is the whole reason the rule is per-FAMILY', + declarationVerdict(['owner/name', 'a/b.mjs'], reaches) === 'live', + ); + t('a lone dead literal is the finding', declarationVerdict(['owner/name'], reaches) === 'dead'); + t('so is a whole declaration of dead ones', declarationVerdict(['owner/name', 'x/y'], reaches) === 'dead'); + t( + 'duplicates do not turn a dead declaration live', + declarationVerdict(['owner/name', 'owner/name'], reaches) === 'dead', + ); + + // Inheritance: an inherited hint belongs to the module that declared it, and + // must not rescue -- or condemn -- the family that inherited it. + const inherited = { hints: ['owner/name', 'a/b.mjs'], hintOrigin: new Map([['a/b.mjs', 'scripts/shared.mjs']]) }; + t('an inherited hint is not part of what a family declares', ownDeclaredHints(inherited).join(',') === 'owner/name'); + t( + '…so a family whose only LIVE hint is inherited is still declaring a dead population', + declarationVerdict(ownDeclaredHints(inherited), reaches) === 'dead', + ); + const noOrigin = { hints: ['a/b.mjs'] }; + t('a family with no origin map declares everything it spells', ownDeclaredHints(noOrigin).join(',') === 'a/b.mjs'); + + // ── The live half: the fleet, and the two non-vacuity claims a count cannot + // make on its own. + const { rows, families, corpus } = sweep(); + t(`the sweep discovers families to judge (${families})`, families > 0); + t(`and a corpus to judge them against (${corpus} tracked files)`, corpus > 0); + const declaring = rows.filter((r) => r.verdict !== 'no-declaration'); + t( + `the fleet really carries declared populations, so a green is not vacuous (${declaring.length} declaring)`, + declaring.length > 0, + ); + t( + 'and it carries families that declare nothing, so the no-declaration branch is exercised too', + rows.some((r) => r.verdict === 'no-declaration'), + ); + // The oracle is not trivially true: a literal this tree cannot have must come + // back dead through the SAME path the sweep uses. + const files = trackedFiles(); + t( + 'the reachability oracle can answer NO for a path this tree does not have', + !files.some((f) => hintCovers('no-such-dir-13519/no-such-file.mjs', f)), + ); + // The YES side names the module this gate IMPORTS rather than this file: + // untracked is exactly what a file looks like while it is being written, and + // an oracle case that reds for its own newness proves nothing about the + // oracle. This literal is inside the self-test body, which the hint extractor + // masks, so it declares no population for this gate. + t( + '…and YES for one it does, so the oracle is not answering NO to everything', + files.some((f) => hintCovers('scripts/pm/dispatch-gates.mjs', f)), + ); + + if (failures.length) { + console.error(`check-declared-population-live --self-test: ${failures.length} of ${checked} assertion(s) FAILED\n`); + for (const f of failures) console.error(` - ${f}`); + return 1; + } + console.log(`check-declared-population-live --self-test: ${checked} assertion(s) passed.`); + return 0; +} + +if (isEntrypoint(import.meta.url)) { + const argv = process.argv.slice(2); + process.exit(argv.includes('--self-test') ? selfTest() : main(argv)); +} diff --git a/scripts/check-service-providers.mjs b/scripts/check-service-providers.mjs index 60474b18a0..d13fbcae00 100644 --- a/scripts/check-service-providers.mjs +++ b/scripts/check-service-providers.mjs @@ -32,15 +32,82 @@ // be read here (or reported) as "nothing exists". import { readFileSync, readdirSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, sep } from 'node:path'; const ROOT = process.cwd(); const TABLE_FILE = 'packages/spec/src/system/core-services.zod.ts'; +/** + * The parent directories whose immediate children's MANIFESTS this gate opens. + * Hoisted out of `workspacePackageNames` so the declaration below can be held + * against it rather than against a second copy of the same list. + * + * Each element is built with `join` on single-segment pieces deliberately: a + * `'packages/plugins'` literal in this module body would be read by + * `extractWatchHints` as a declared DIRECTORY population and would put this gate + * on every card touching any of the 691 files under it — where this gate opens + * only the `package.json` at each child's root. The narrow claim is spelled in + * `DECLARED_WATCH_HINTS` instead. + */ +const MANIFEST_PARENTS = ['packages', join('packages', 'plugins'), join('packages', 'services')]; + +/** + * The population this gate READS, declared for `scripts/pm/dispatch-gates.mjs` + * — spelled as a LITERAL array because the hint extractor reads source TEXT + * (`scripts/check-watch-hint-literal.mjs` holds that spelling for every + * declarer in the tree). + * + * ## What was declared before, and why it under-matched + * + * The only path literal this module body spelled was `TABLE_FILE` — the table + * this gate JUDGES. Its other input, the set of workspace package names, is + * read by opening `//package.json` for each parent above, and + * none of those paths existed as a literal: the parents are assembled with + * `join` and `'packages'` alone is refused by the extractor as too generic. So + * a card renaming a package — the exact change that invalidates a row of the + * table — derived no lead to this gate. + * + * ## Why PATTERNS and not the parent directories + * + * `'packages/plugins'` as a hint is a claim on the whole subtree, and + * `scripts/workspace-enumerator.mjs`'s header carries the measurement that + * settles this shape: handing a workspace-wide population to importing gates + * priced at +41725 (gate, file) pairs, and its conclusion is that each gate + * declares its OWN population in its OWN module body. The manifests are that + * own population here — 53 files on this tree rather than the ~5400 the subtree + * spelling would claim. + */ +const DECLARED_WATCH_HINTS = [ + 'packages/*/package.json', + 'packages/plugins/*/package.json', + 'packages/services/*/package.json', +]; + +// The declaration held against the read, at every invocation — the pin the +// idiom asks each declarer to carry from its own side, where the walked root is +// in scope. Both directions: a parent with no pattern is an UNDECLARED read +// (the defect being repaired), and a pattern with no parent is a fabricated +// lead pasted into every card under it. Add a fourth parent above and this +// throws here rather than going quiet in a dispatch brief. +{ + const declaredParents = DECLARED_WATCH_HINTS.map((h) => h.replace(/\/\*\/package\.json$/, '')); + const readParents = MANIFEST_PARENTS.map((p) => p.split(sep).join('/')); + const missing = readParents.filter((p) => !declaredParents.includes(p)); + const invented = declaredParents.filter((p) => !readParents.includes(p)); + if (missing.length || invented.length) { + throw new Error( + 'check-service-providers: DECLARED_WATCH_HINTS no longer matches the directories this gate reads manifests from' + + `${missing.length ? ` — undeclared: ${missing.join(', ')}` : ''}` + + `${invented.length ? ` — declared but not read: ${invented.join(', ')}` : ''}` + + '. Update the declaration, as a LITERAL array of `/*/package.json` patterns.', + ); + } +} + /** Every package name declared anywhere in the workspace. */ function workspacePackageNames() { const names = new Set(); - const roots = ['packages', join('packages', 'plugins'), join('packages', 'services')]; + const roots = MANIFEST_PARENTS; for (const dir of roots) { const abs = join(ROOT, dir); if (!existsSync(abs)) continue; diff --git a/scripts/check-turbo-task-graph.mjs b/scripts/check-turbo-task-graph.mjs index f2d4b9a601..c52ec16ea4 100644 --- a/scripts/check-turbo-task-graph.mjs +++ b/scripts/check-turbo-task-graph.mjs @@ -177,13 +177,53 @@ const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); * * `package.json/**` names the ROOT manifest and only it -- measured: * `hintCovers('package.json/**', 'packages/spec/package.json')` is false. The - * MEMBER manifests stay undeclared on purpose (the enumerator owns them and - * declares none); this hint is here because the `//#` arm opens the root - * manifest DIRECTLY, from this file, and a bare `'package.json'` literal builds - * no hint at all -- the same trap documented above for turbo.json. + * MEMBER manifests are declared separately, in `DECLARED_WATCH_HINTS` below; + * this hint is here because the `//#` arm opens the root manifest DIRECTLY, + * from this file, and a bare `'package.json'` literal builds no hint at all -- + * the same trap documented above for turbo.json. */ export const ROOT_FILE_WATCH_HINTS = ['turbo.json/**', 'package.json/**']; +/** + * The MEMBER manifests this gate opens, one per workspace package, as patterns. + * + * ## Why they used to be undeclared, and why that reading was wrong + * + * This block used to say the member manifests "stay undeclared on purpose (the + * enumerator owns them and declares none)". The enumerator really does declare + * none -- but its header states WHY, and the reason is the opposite of a + * licence for its callers to declare nothing: + * + * "each gate keeps declaring its OWN population in its OWN module body [...] + * What is consolidated here is the PARSE, never the DECLARATION." + * + * The refusal there is priced against the SUBTREE claim: a `'packages/*'`-shaped + * literal in the enumerator would hand the whole workspace population -- ~5400 + * files -- to all nine callers at once, measured at +41725 (gate, file) pairs. + * That argument is about the enumerator and about subtrees. It says nothing + * against the narrow claim this gate can make from its own side, which is the + * manifests and only the manifests: 79 files on this tree, every one of which + * `readWorkspaceScripts` really opens and reads a `scripts` table out of. + * + * The cost of leaving them undeclared was the ordinary one: a card adding or + * renaming a script in `packages//package.json` -- the change that makes a + * `#` key inert, which is precisely what this gate judges -- + * derived no lead to this gate at all. + * + * ## Why three patterns and not eleven + * + * `pnpm-workspace.yaml` lists eleven member globs, nine of them under + * `packages/`. `packages/**` + one glob for each of the two roots outside it + * covers every member manifest and nothing else; `--self-test` holds that + * against the enumerator's live answer in BOTH directions, so a twelfth glob in + * the workspace file reds here rather than going quiet. + */ +export const DECLARED_WATCH_HINTS = [ + 'packages/**/package.json', + 'apps/*/package.json', + 'examples/*/package.json', +]; + /** The file this gate judges, as the reader spells it on disk. */ const TURBO_CONFIG_FILE = 'turbo.json'; @@ -740,6 +780,65 @@ export function selfTest() { `${TURBO_CONFIG_FILE},${ROOT_MANIFEST_FILE}`, ); + // ── The MEMBER manifests, held against the enumerator's live answer ── + // + // Both directions, because either alone is satisfied by the defect this + // declaration repairs. A pattern that covers nothing is a fabricated lead + // pasted into every card it happens to brush; a member manifest no pattern + // covers is the undeclared read the gate shipped with. Stated over + // `workspacePackages(ROOT)` -- the same call `readWorkspaceScripts` makes -- + // so a twelfth glob in `pnpm-workspace.yaml` reds HERE, in the gate that + // opens the file, rather than going quiet in a dispatch brief. + // + // The matcher is local and deliberately narrower than `hintCovers`: `*` stops + // at a separator, `**` crosses them, and nothing else is special. It can only + // refuse MORE than the real covering rule, so it fails loudly for a pattern + // the derivation would have accepted and never passes one it would refuse. + const patternMatches = (pattern, path) => { + const rx = pattern + .split('/') + .map((seg) => (seg === '**' ? '' : seg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*'))) + .join('/') + .replace(/\//g, '(?:[^/]+/)*') + .replace(/\/?/g, '(?:/.*)?'); + return new RegExp(`^${rx}$`).test(path); + }; + const memberManifests = workspacePackages(ROOT).map((p) => `${p.dir}/${ROOT_MANIFEST_FILE}`); + t( + `the enumerator finds member manifests to declare (${memberManifests.length})`, + memberManifests.length > 0, + ); + const undeclaredManifests = memberManifests.filter( + (m) => !DECLARED_WATCH_HINTS.some((h) => patternMatches(h, m)), + ); + t( + `every member manifest this gate opens is covered by a declared pattern (uncovered: ${undeclaredManifests.join(', ') || 'none'})`, + undeclaredManifests.length === 0, + ); + const emptyPatterns = DECLARED_WATCH_HINTS.filter( + (h) => !memberManifests.some((m) => patternMatches(h, m)), + ); + t( + `and no declared pattern covers zero of them (empty: ${emptyPatterns.join(', ') || 'none'})`, + emptyPatterns.length === 0, + ); + // The matcher itself, pinned in both directions on this tree's own shapes -- + // a nested member, a top-level one, and the ROOT manifest, which must NOT be + // swept up by the member patterns (it is `ROOT_FILE_WATCH_HINTS`' claim, and + // a member pattern that also matched it would make the two declarations + // disagree about who owns it). + t( + 'the pattern matcher crosses separators for `**` and stops at them for `*`', + patternMatches('packages/**/package.json', 'packages/plugins/knowledge-memory/package.json') && + patternMatches('packages/**/package.json', 'packages/spec/package.json') && + patternMatches('apps/*/package.json', 'apps/docs/package.json') && + !patternMatches('apps/*/package.json', 'apps/docs/nested/package.json'), + ); + t( + 'and the member patterns do not claim the ROOT manifest', + !DECLARED_WATCH_HINTS.some((h) => patternMatches(h, ROOT_MANIFEST_FILE)), + ); + // ── Refusals must refuse (#4690) ── const refuses = (label, fn) => { try { diff --git a/scripts/measure-stall-guard-headroom.mjs b/scripts/measure-stall-guard-headroom.mjs index 73d8e93d05..18e5cd8fea 100644 --- a/scripts/measure-stall-guard-headroom.mjs +++ b/scripts/measure-stall-guard-headroom.mjs @@ -161,6 +161,45 @@ export const EXIT_REFUSED = 2; export const DEFAULT_REPO = 'objectstack-ai/objectstack'; +/** + * The population this tool READS, declared for `scripts/pm/dispatch-gates.mjs` + * -- the `ROOT_DIR_WATCH_HINTS` idiom, spelled as a literal array because the + * hint extractor reads SOURCE TEXT (`scripts/check-watch-hint-literal.mjs` + * holds that spelling for every declarer in the tree). + * + * ## Why this file declared NOTHING, and why that is not a missing gate name + * + * `extractWatchHints` scans the module body for path-shaped literals. The only + * one this file spelled was `DEFAULT_REPO` above -- `objectstack-ai/objectstack` + * -- which contains a slash and is therefore admitted, and which names no + * tracked path at all. So the family's ENTIRE declared population reached zero + * files, and the derivation printed it as an ordinary silence: the same output + * a gate with no literals produces, indistinguishable from it in the brief. + * + * The real population is not missing from this file, it is spelled in a file + * this one IMPORTS. `WORKFLOW_DIR` and `scan` come from + * `check-stall-guard-budget.mjs`, which declares `.github/workflows` in its own + * module body -- but the import follow REFUSES a target that is itself a + * discovered gate file (deliberately: a gate script is left to its own family, + * rather than having its population inherited twice under weaker provenance). + * The refusal is right; what was missing is this end of it. A gate that reads a + * population through a sibling GATE has to declare that population itself. + * + * ## Why the declaration cannot drift from the read + * + * The literal is held against `WORKFLOW_DIR` -- the very constant the sweep + * joins its paths from -- by the self-test below. A hand-written path that + * agreed with the read only on the day it was typed is the shape this idiom + * exists to replace, so the pin compares the declaration to the imported + * constant rather than to a second copy of the string. + * + * The guard script `guardDefaults` reads is deliberately NOT declared here: it + * is `check-stall-guard-budget.mjs`'s own declared literal, and a card touching + * it already derives that family, which reads it for the same defaults. What + * this file adds is the surface nothing else covers on its behalf. + */ +const ROOT_DIR_WATCH_HINTS = ['.github/workflows/**']; + function repoRoot() { return resolve(dirname(fileURLToPath(import.meta.url)), '..'); } @@ -680,6 +719,40 @@ export async function selfTest() { }; const dirs = []; + // ── The declared population, held against the constant the sweep READS ──── + // + // Both halves, because either alone passes against the defect. "The + // declaration names a directory" is true of any string with a slash in it -- + // it was true of `objectstack-ai/objectstack`, which is what this family + // declared and what reached nothing. "The declaration is not empty" is true + // of a stale path. What is asserted is that the declared root IS + // `WORKFLOW_DIR`, the constant `scan` joins every workflow path from: move the + // read and this reds, in this file, rather than in a dispatch brief nobody + // reads as evidence. + assert( + 'the declared watch-hint population is exactly the workflow directory this tool sweeps', + ROOT_DIR_WATCH_HINTS.length === 1 && ROOT_DIR_WATCH_HINTS[0] === `${WORKFLOW_DIR}/**`, + JSON.stringify({ ROOT_DIR_WATCH_HINTS, WORKFLOW_DIR }), + ); + // …and that the declared form is the SUBTREE spelling, not the bare + // directory. The two behave alike in the covering rule today; the glob is + // what the idiom's other declarers spell, and `check-watch-hint-literal.mjs` + // reads them as one family. + assert( + 'the declared form is the subtree glob, not the walk root itself', + !ROOT_DIR_WATCH_HINTS.includes(WORKFLOW_DIR) && ROOT_DIR_WATCH_HINTS.every((h) => h.endsWith('/**')), + JSON.stringify(ROOT_DIR_WATCH_HINTS), + ); + // Non-vacuous: the directory really is where this tool's own identity read + // goes, not just where the imported sweep looks. `workflowIdentities` readdirs + // it directly, and a declaration that described only the imported half would + // stop being true the moment that read moved. + assert( + 'and the tool really reads that directory in this checkout', + existsSync(join(repoRoot(), WORKFLOW_DIR)), + join(repoRoot(), WORKFLOW_DIR), + ); + const { parse } = await requireDependency('yaml', () => import('yaml'), import.meta.url); const identities = workflowIdentities(repoRoot(), parse); diff --git a/scripts/pm/check-clause2-carriers.mjs b/scripts/pm/check-clause2-carriers.mjs index 14f4dce5d6..4601d75107 100644 --- a/scripts/pm/check-clause2-carriers.mjs +++ b/scripts/pm/check-clause2-carriers.mjs @@ -145,6 +145,8 @@ import { resolveSweepRepo, } from './check-half-states.mjs'; +// dispatch-gates: no-path-population -- this gate reads no file in the tree at all; its whole input is the GitHub API (PRs, their labels, and the claim comments on their cards), so no card's file surface can predict it and the honest derivation is a repo-wide undetermined one (#13519) + const API = 'https://api.github.com'; const TOKEN = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''; const SELF_PATH = fileURLToPath(import.meta.url); @@ -860,9 +862,15 @@ async function main(argv) { const repoRes = resolveSweepRepo(process.env); if (!repoRes.valid) { console.error( - `check-clause2-carriers: ${repoRes.source}=${JSON.stringify(repoRes.repo)} is not an ` + - '`owner/name` repository. Refusing to fall back to a different board — a report about the ' + - 'wrong repo reads exactly like a report about this one.', + // The example is spelled `owner`/`name` rather than as one backticked + // span deliberately: the dispatch derivation reads any quoted path-shaped + // literal in a module body as a declared population, and the one-span + // spelling was this family's ONLY such literal — a slug that names no + // tracked file, so the family declared a population that reached nothing + // and printed as an ordinary silence (#13519). Split, it is not a path. + `check-clause2-carriers: ${repoRes.source}=${JSON.stringify(repoRes.repo)} is not a ` + + 'repository in `owner`/`name` form. Refusing to fall back to a different board — a report ' + + 'about the wrong repo reads exactly like a report about this one.', ); return EXIT_USAGE; } From 9b440c732221ef05fa1fd563c776e692800b01a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 10:34:53 +0000 Subject: [PATCH 2/3] fix(devx): build the manifest matcher without a placeholder byte --- scripts/check-turbo-task-graph.mjs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/scripts/check-turbo-task-graph.mjs b/scripts/check-turbo-task-graph.mjs index c52ec16ea4..496dab4db3 100644 --- a/scripts/check-turbo-task-graph.mjs +++ b/scripts/check-turbo-task-graph.mjs @@ -795,12 +795,21 @@ export function selfTest() { // refuse MORE than the real covering rule, so it fails loudly for a pattern // the derivation would have accepted and never passes one it would refuse. const patternMatches = (pattern, path) => { - const rx = pattern - .split('/') - .map((seg) => (seg === '**' ? '' : seg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*'))) - .join('/') - .replace(/\//g, '(?:[^/]+/)*') - .replace(/\/?/g, '(?:/.*)?'); + // Built segment by segment, with no sentinel character standing in for + // `**` at any point: a placeholder byte spliced into a string and replaced + // later is how a raw control byte gets into a source file + // (`scripts/check-nul-bytes.mjs` carries the argument), and the segment + // walk needs no placeholder anyway. + const segs = pattern.split('/'); + let rx = ''; + for (let i = 0; i < segs.length; i++) { + if (segs[i] === '**') { + rx += '(?:[^/]+/)*'; // zero or more WHOLE segments -- `**` crosses separators + continue; + } + rx += segs[i].replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*'); + if (i < segs.length - 1) rx += '/'; + } return new RegExp(`^${rx}$`).test(path); }; const memberManifests = workspacePackages(ROOT).map((p) => `${p.dir}/${ROOT_MANIFEST_FILE}`); From 04b7f7690a19e2c28155983b8ff47cd604c3bc96 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 10:43:09 +0000 Subject: [PATCH 3/3] fix(devx): declare check:skill-docs at 12-of-12 precision, and re-point its worklist row --- packages/spec/scripts/build-skill-docs.ts | 74 +++++++++++++---------- scripts/pm/bare-root-worklist.mjs | 30 +++++---- 2 files changed, 59 insertions(+), 45 deletions(-) diff --git a/packages/spec/scripts/build-skill-docs.ts b/packages/spec/scripts/build-skill-docs.ts index feb15f7950..de1eba7de6 100644 --- a/packages/spec/scripts/build-skill-docs.ts +++ b/packages/spec/scripts/build-skill-docs.ts @@ -26,59 +26,69 @@ import path from 'path'; const REPO_ROOT = path.resolve(__dirname, '../../..'); const SKILLS_DIR = path.resolve(REPO_ROOT, 'skills'); +/** The file a child of the skills root must CARRY to be a skill (`:main` admits on it). */ +const ENTRYPOINT_FILE = 'SKILL.md'; const README = path.resolve(SKILLS_DIR, 'README.md'); const GUIDE = path.resolve(REPO_ROOT, 'content/docs/ai/skills-reference.mdx'); /** * The population this script READS, declared for `scripts/pm/dispatch-gates.mjs` - * — the `ROOT_DIR_WATCH_HINTS` idiom, spelled as a LITERAL array because the - * hint extractor reads source TEXT (a declaration computed from `SKILLS_DIR` - * would build no hint at all; `scripts/check-watch-hint-literal.mjs` holds that - * spelling for every declarer in the tree). + * — spelled as a LITERAL array because the hint extractor reads source TEXT (a + * declaration computed from `SKILLS_DIR` would build no hint at all; + * `scripts/check-watch-hint-literal.mjs` holds that spelling for every declarer + * in the tree). * * ## What was declared before, and why it under-matched * * The only path literal this module body spelled was the GUIDE path, * `content/docs/ai/skills-reference.mdx` — an OUTPUT. So `check:skill-docs` - * declared its generated artifact and not one of its inputs: a card editing - * `skills//SKILL.md` — the file whose frontmatter is the entire catalog — - * was never told that this gate reads it, and `--check` reds in CI on drift the + * declared its generated artifact and not one of its inputs: a card editing a + * skill entrypoint — the file whose frontmatter is the entire catalog — was + * never told that this gate reads it, and `--check` reds in CI on drift the * derivation could have predicted. * - * The inputs were unspellable rather than forgotten: `SKILLS_DIR` is built with - * `path.resolve(REPO_ROOT, 'skills')`, and a single-segment literal is refused - * by the extractor as too generic to be a path population. The subtree spelling - * is the escape, and it is what the idiom exists for. + * The inputs were not forgotten, they were unspellable AS ONE HINT: `SKILLS_DIR` + * is built with `path.resolve(REPO_ROOT, 'skills')`, and a single-segment + * literal is refused by the extractor as too generic to be a path population. * - * `skills/README.md` is an output too, but it sits INSIDE the declared subtree, - * so no separate literal claims it — the population is the directory this - * script both reads and rewrites. + * ## Why NOT the subtree spelling, which is the idiom's usual escape + * + * `scripts/pm/bare-root-worklist.mjs` carries the measured triage for exactly + * this root and refuses the wholesale hint: this script's population is 12 of + * the 50 files tracked under it (24%), so `skills/**` would name this gate for + * 38 files it never opens. That is the REFUSE-WIDE trade, and the worklist + * prices a false wholesale hint as the costlier error. + * + * What the recorded refusal turned on is that no SINGLE spelling reaches both + * halves: `:221` readdirs the root and admits a child only if it carries a + * SKILL.md, while the twelfth file is the root README this script WRITES, which + * sits outside every skill directory. Two literals reach both, and reach + * nothing else — 12 of 12, precise AND complete — which is the declaration + * below and why the row's verdict is withdrawn rather than the declaration. */ -const ROOT_DIR_WATCH_HINTS = ['skills/**']; +const DECLARED_WATCH_HINTS = ['skills/*/SKILL.md', 'skills/README.md']; /** - * The declaration above, held against the constant this script really reads + * The declaration above, held against the constants this script really reads * from. A hand-written path that agreed with the read only on the day it was - * typed is the drift this idiom replaces, so the check compares the declared - * root to `SKILLS_DIR` rather than to a second copy of the string — move the - * directory and this throws here, in this file, instead of going quiet in a - * dispatch brief. + * typed is the drift this idiom replaces, so the check derives what it compares + * against from `SKILLS_DIR` and `README` — move either and this throws here, in + * this file, instead of going quiet in a dispatch brief. */ function assertWatchHintsDeclareTheReadSurface(): void { - const declaredRoots = ROOT_DIR_WATCH_HINTS.map((h) => h.replace(/\/\*+$/, '')); - const readRoot = path.relative(REPO_ROOT, SKILLS_DIR).split(path.sep).join('/'); - if (declaredRoots.length !== 1 || declaredRoots[0] !== readRoot) { + const rel = (abs: string) => path.relative(REPO_ROOT, abs).split(path.sep).join('/'); + const expected = [`${rel(SKILLS_DIR)}/*/${ENTRYPOINT_FILE}`, rel(README)]; + if (DECLARED_WATCH_HINTS.join('|') !== expected.join('|')) { throw new Error( - `build-skill-docs: the declared watch-hint population ${JSON.stringify(ROOT_DIR_WATCH_HINTS)} no longer ` + - `names the directory this script reads (${readRoot}) — update the declaration, as a LITERAL array.`, + `build-skill-docs: the declared watch-hint population ${JSON.stringify(DECLARED_WATCH_HINTS)} no longer ` + + `names what this script reads (${JSON.stringify(expected)}) — update the declaration, as a LITERAL array.`, ); } - // …and the declared form is the SUBTREE spelling, not the bare directory: - // that is what the idiom's other declarers spell and what - // `check-watch-hint-literal.mjs` reads them as. - if (!ROOT_DIR_WATCH_HINTS.every((h) => h.endsWith('/**'))) { + // …and it stays NARROW. A hint that reached the bare root would be the + // wholesale claim the worklist refused, arriving through a reword. + if (DECLARED_WATCH_HINTS.some((h) => h.endsWith('/**'))) { throw new Error( - `build-skill-docs: the watch-hint declaration must use the subtree spelling: ${JSON.stringify(ROOT_DIR_WATCH_HINTS)}`, + `build-skill-docs: the declaration must stay narrower than the root: ${JSON.stringify(DECLARED_WATCH_HINTS)}`, ); } } @@ -132,7 +142,7 @@ interface Skill { } function parseFrontmatter(name: string, label: string): Skill { - const file = path.resolve(SKILLS_DIR, name, 'SKILL.md'); + const file = path.resolve(SKILLS_DIR, name, ENTRYPOINT_FILE); const raw = fs.readFileSync(file, 'utf-8'); const parts = raw.split(/^---\s*$/m); if (parts.length < 3) throw new Error(`${name}: no YAML frontmatter`); @@ -275,7 +285,7 @@ function main() { // Catalog ⇄ DISPLAY must be in lockstep. const onDisk = fs .readdirSync(SKILLS_DIR) - .filter((d) => d.startsWith('objectstack-') && fs.existsSync(path.resolve(SKILLS_DIR, d, 'SKILL.md'))); + .filter((d) => d.startsWith('objectstack-') && fs.existsSync(path.resolve(SKILLS_DIR, d, ENTRYPOINT_FILE))); const configured = new Set(DISPLAY.map((d) => d.name)); const missing = onDisk.filter((d) => !configured.has(d)); const extra = DISPLAY.filter((d) => !onDisk.includes(d.name)).map((d) => d.name); diff --git a/scripts/pm/bare-root-worklist.mjs b/scripts/pm/bare-root-worklist.mjs index 562827aeb3..bb8caab86e 100644 --- a/scripts/pm/bare-root-worklist.mjs +++ b/scripts/pm/bare-root-worklist.mjs @@ -629,20 +629,24 @@ const TRIAGE = new Map([ + 'also already reaches its own cards through the artifact roster it names file by file', }], ['check:skill-docs SKILLS_DIR skills', { - verdict: 'SPELLABLE-UNDECLARED', + verdict: 'DECLARED-NARROWER', spelling: 'skill entrypoints', - why: 'one named file per child directory plus the root README — 12 of 50 (24%), re-measured ' - + '2026-08-26. ⚠️ The recorded spelling is 100% PRECISE and deliberately INCOMPLETE: it ' - + 'reaches 11 of the 12, and the twelfth is skills/README.md, a file this generator WRITES ' - + 'and which sits outside any skill directory, so no single spelling of this idiom reaches ' - + 'both. That is the honest record and the reason this row is not declared on the strength ' - + 'of a precise-looking hint. build-skill-docs.ts:221 readdirSync-es the bare root and ' - + 'reconciles the listing against DISPLAY in both directions (:227-228), so a new or removed ' - + 'objectstack-*/SKILL.md moves the verdict and no fixed list can name it; :222 admits a ' - + 'child only if it CARRIES SKILL.md, which is what the recorded spelling states. ⛔ It is ' - + 'NOT recorded to match its check-skills-token-ratchet neighbour any more: that gate went ' - + 'recursive in #12392 and the two now read the SAME root at DIFFERENT scales, 49 files ' - + 'against 12', + why: 'RE-POINTED 2026-09-01 (#13519) from SPELLABLE-UNDECLARED, and the deferral it replaces ' + + 'is not merely overruled — its own premise stopped holding. That record read: the ' + + 'population is 12 of 50 (24%), the recorded spelling is 100% PRECISE and deliberately ' + + 'INCOMPLETE at 11 of the 12, "and the twelfth is skills/README.md, a file this generator ' + + 'WRITES and which sits outside any skill directory, so no single spelling of this idiom ' + + 'reaches both". That is a fact about ONE spelling. The gate now declares TWO literals ' + + 'beside its constants — the recorded spelling plus the root README it writes — reaching ' + + '12 of 12: 100% precise AND complete. ⛔ The row STAYS in the sweep, which is what this ' + + 'verdict commits to and not outstanding debt: the declaration is strictly narrower than ' + + 'the bare word, so the root remains uncovered at 12 of 50 and a card touching the other ' + + '38 files still derives nothing from it — correctly, because this gate opens none of ' + + 'them. ⛔ The wholesale spelling was measured and REFUSED on the way here: it names this ' + + 'gate for 38 files it never reads, which is the REFUSE-WIDE trade this file prices as ' + + 'the costlier error. ⛔ NOT re-pointed with its check-skills-token-ratchet neighbour ' + + 'above: that gate walks the same root RECURSIVELY at 49 of 50, where the precise ' + + 'spelling buys one file of discrimination and its deferral is untouched by this row', }], ['check:changeset-gate-self-tests PACKAGE_ROOTS packages', { verdict: 'SPELLABLE-UNDECLARED',