From 7d4e4cd923711e91296d4099c8b5faada2b154d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 08:42:26 +0000 Subject: [PATCH] fix(lint): declare check:doc-formula-expressions' real corpus to dispatch-gates Its ROOTS are three bare words and one dotted dir, so the hint extractor refused `docs`, `skills` and `content` as too generic while its SKIP_PATHS carried separators and were taken. Measured on this tree: of the 1388 files this REQUIRED gate walks, 396 (28.5%) were declared by nothing, and inside `docs` the shape was inverted -- `docs/plans/` (an exclusion) derived the gate while `docs/qa/` derived nothing. Declares the roots in the subtree spelling the extractor compares in, pinned in both halves: the gate's own self-test couples the declaration to ROOTS and bounds the over-claim to SKIP_PATHS; dispatch-gates' self-test pins that it reaches all three roots and claims nothing outside them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --- .../scripts/check-doc-formula-expressions.mjs | 166 +++++++++++++++++- scripts/pm/dispatch-gates.mjs | 55 ++++++ 2 files changed, 220 insertions(+), 1 deletion(-) diff --git a/packages/lint/scripts/check-doc-formula-expressions.mjs b/packages/lint/scripts/check-doc-formula-expressions.mjs index 316132300c..0cfc5148d8 100644 --- a/packages/lint/scripts/check-doc-formula-expressions.mjs +++ b/packages/lint/scripts/check-doc-formula-expressions.mjs @@ -186,6 +186,105 @@ const SKIP_PATHS = new Set([ 'content/docs/references', ]); +/** + * ROOTS above, written in the subtree spelling `scripts/pm/dispatch-gates.mjs` + * compares in. Provenance ONLY: nothing in this gate reads this list, and the + * scan behaves exactly as it did without it. + * + * ## The gap this closes (#9964's declaration pattern, seventh instance) + * + * That tool builds every dispatch's gate list by scanning each gate's own + * source for the path literals it operates on, and "looks like a path" there + * means "carries a separator" — or names a top-level DOTTED directory, which is + * the one arm that saved `.claude`. So three of the four ROOTS were bare words + * that never became a hint, while `SKIP_PATHS` above spells its entries with + * separators, and those DID. + * + * Measured on this tree, the gate's whole extracted hint set was: + * + * .claude the one md/mdx root the dotted-dir arm + * admitted — 20 files + * packages/spec/src SPEC_ROOT, surface 2 — already a path + * literal, so already visible: 972 files + * .claude/worktrees, docs/audits, the exemptions, i.e. the subtrees it + * docs/handoff, docs/plans, deliberately does NOT read + * content/docs/references + * @objectstack/formula the import specifier, inert as a path + * + * Of the 1388 files this gate walks, 396 were declared by nothing at all + * (28.5%) — every file under `docs` (156), `skills` (48) and `content` (192). + * The three declarations below are what close that. + * + * Within the `docs` root the shape was inverted rather than merely absent: a + * card touching `docs/plans/` DERIVED this gate (via the SKIP_PATHS literal — + * a subtree the walk returns from immediately), while a card touching + * `docs/qa/platform-checklist/` derived nothing. The exclusions were the + * declaration and the population was not. + * + * That is worse than declaring nothing, and worse in the direction that hides + * it: the residue line still PRINTED gate names, so the row read as "declared, + * just not relevant to you" rather than as a blind spot. A card editing the + * live docs corpus met this REQUIRED gate (lint.yml, `pnpm --filter + * @objectstack/lint run check:doc-formula-expressions`) as red CI instead of as + * a local command. + * + * `.claude/**` is redundant with the bare `.claude` the extractor already takes + * on its dotted-dir arm, and is kept so the declaration is uniform across ROOTS + * rather than depending on which arm happened to admit which root. SPEC_ROOT + * needs no entry for the same reason `.claude` did not strictly need one — it + * already carries a separator — and the self-test pins that it still does, so + * renaming it to a bare word fails here instead of silently unhinting 972 + * files. + * + * ## Why the subtree spelling, and not a wider extractor + * + * `hintCovers` refuses a bare single-segment literal (`docs`) as too generic BY + * DESIGN, and that refusal is measured rather than incidental: teaching the + * extractor to accept bare top-level directory words was priced at +139084 + * fabricated (gate, file) pairs, because `packages`, `apps` and `examples` are + * path COMPONENTS in dozens of gates that never read those roots. Nor can a + * class-level guard author this for us — flagging any gate that names a bare + * tracked directory none of its hints reach fires on 40 of 123 families, and + * the majority are right as they stand. The distinction between "population + * root" and "path component" is in the author's intent, not the source text, + * which is why the declaration has to be authored, gate by gate. + * + * ## Why the ROOT, and not the live subtrees under it (the SKIP_PATHS question) + * + * `hintCovers` has no way to SUBTRACT: hints are positive containment, so + * "`docs/**` except `docs/plans`" is not expressible. The exempt subtrees are + * therefore claimed by this declaration, and that is a DELIBERATE, bounded + * residual rather than an oversight — pinned as such in the self-test, so it + * cannot silently grow past the exemptions it is accounted for. + * + * The same limit applies one level down, to the extension filter: `collectFiles` + * keeps only `.md`/`.mdx` (and `.ts`/`.tsx` under SPEC_ROOT), which a subtree + * hint cannot express either — so a card touching `content/docs.site.json` + * derives this gate although the walk skips that file. Both residuals point the + * same way: the declaration over-claims INSIDE what it walks, never outside, + * and the negative half of the self-test is what holds that line. + * + * The residual is also not new, and this declaration does not widen it by one + * path: those five subtrees derive this gate TODAY, via the `SKIP_PATHS` + * literals themselves, which stay hints whatever this list says. Removing that + * residual would mean unquoting the most safety-critical constant in this file. + * The declaration subsumes those hints and adds nothing to that side while + * closing all 396 files of the missing side. + * + * What the precedent does draw a line at is claiming a tree the ROOTS do not + * reach at all, and the self-test in `scripts/pm/dispatch-gates.mjs` pins that + * negative half against the real extractor — the load-bearing direction for a + * declaration this broad, since a gate named on EVERY card is the louder + * version of naming none. + * + * ## Provenance, never a lookup key + * + * The glob form appearing in ROOTS would send `walk()` at a directory that does + * not exist — since #4916 a hard refusal rather than a silent skip, but one + * that fails naming the wrong problem. The self-test pins both halves. + */ +const ROOT_WATCH_HINTS = ['.claude/**', 'docs/**', 'skills/**', 'content/**']; + const posix = (p) => p.split(sep).join('/'); /** @@ -918,6 +1017,66 @@ const EXEMPTION_SELF_TEST_CASES = [ }, ]; +/** + * The `scripts/pm/dispatch-gates.mjs` declaration (#9964's pattern, seventh + * instance), pinned in both directions. + * + * Enforcement cannot hold any of these: the declaration 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 docs card with this REQUIRED gate missing from the + * brief — which is exactly how it stood before this block. Both sides are + * derived from ROOTS rather than re-spelled, so renaming or widening a root + * cannot leave the declaration describing the old population. + */ +const DECLARATION_SELF_TEST_CASES = [ + { + name: 'DECLARATION — every ROOT the hint extractor cannot see is declared as a subtree ' + + '(a root with no path separator is refused as too generic)', + holds: () => ROOTS.filter((r) => !r.includes('/')).every((r) => ROOT_WATCH_HINTS.includes(`${r}/**`)), + }, + { + name: 'DECLARATION — and it declares no root this gate does not walk (a declaration that ' + + 'can drift from the scan is worse than none — it replaces a silent gate with a lying one)', + holds: () => ROOT_WATCH_HINTS.every((h) => ROOTS.includes(h.replace(/\/\*+$/, ''))), + }, + { + // Provenance, never a lookup key: the glob form appearing in ROOTS would + // send `walk()` at a directory that does not exist. Since #4916 that is a + // hard refusal rather than a silent skip, but it fails naming the wrong + // problem. + name: 'DECLARATION — the declared glob form is NOT a ROOTS entry', + holds: () => !ROOT_WATCH_HINTS.some((h) => ROOTS.includes(h)), + }, + { + // The residual, pinned rather than hidden. `hintCovers` is positive + // containment with no way to subtract, so declaring a ROOT necessarily + // claims the exempt subtrees carved out of it. That is accounted for — but + // only for the exemptions themselves: every SKIP_PATHS entry must sit UNDER + // a declared root, so a future exemption somewhere this declaration does not + // reach fails here instead of quietly widening the over-claim. + name: 'DECLARATION — every skipped subtree is one this declaration knowingly over-claims, ' + + 'and none is a surprise from outside the declared roots', + holds: () => [...SKIP_PATHS].every((p) => ROOTS.some((r) => p.startsWith(`${r}/`))), + }, + { + // The exemptions must stay a strict SUBSET of the walked roots: an entry + // that WAS a whole root would mean the gate declares a population it never + // reads. + name: 'DECLARATION — no exemption swallows a declared root whole', + holds: () => ![...SKIP_PATHS].some((p) => ROOTS.includes(p)), + }, + { + // Surface 2 is declared by SPEC_ROOT itself, which the extractor takes + // because it carries a separator — 972 files riding on one property of one + // string. Renaming it to a bare word (`spec`, say) would unhint all of them + // exactly the way `docs` was unhinted, and this is the only place that + // would notice. + name: 'DECLARATION — SPEC_ROOT still carries a path separator, so surface 2 needs no ' + + 'subtree spelling of its own', + holds: () => SPEC_ROOT.includes('/'), + }, +]; + function specSelfTest() { const problemsFor = (c) => { const problems = []; @@ -1004,7 +1163,12 @@ function selfTest() { } } failed += specSelfTest(); - const total = SELF_TEST_CASES.length + SPEC_SELF_TEST_CASES.length + EXEMPTION_SELF_TEST_CASES.length; + for (const c of DECLARATION_SELF_TEST_CASES) { + if (c.holds()) console.log(` ✓ ${c.name}`); + else { failed++; console.error(` ✗ ${c.name}`); } + } + const total = SELF_TEST_CASES.length + SPEC_SELF_TEST_CASES.length + EXEMPTION_SELF_TEST_CASES.length + + DECLARATION_SELF_TEST_CASES.length; if (failed > 0) { console.error(`\n✗ check:doc-formula-expressions self-test: ${failed} case(s) failed`); process.exit(1); diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 756286626b..b8ba238d94 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -3256,6 +3256,61 @@ function selfTest() { t('the bare root word the gate spells in ROOTS is still refused as too generic', !hintCovers('skills', 'skills/objectstack-platform/SKILL.md')); t('while the declared subtree covers that same path', hintCovers('skills/**', 'skills/objectstack-platform/SKILL.md')); + // The seventh instance of the same directory class (#10664), in a + // PACKAGE-scoped gate — `pnpm --filter @objectstack/lint run + // check:doc-formula-expressions`, REQUIRED in lint.yml — so the source this + // reads is resolved through that package's manifest rather than the root one. + // + // Its ROOTS were `['.claude', 'docs', 'skills', 'content']`, three bare words + // and one dotted dir, while its SKIP_PATHS spelled five exclusions WITH + // separators. Measured on this tree: of the 1388 files it walks, 396 (28.5%) + // were declared by nothing — every file under `docs` (156), `skills` (48) and + // `content` (192). Inside the `docs` root the shape was inverted rather than + // merely absent: `docs/plans/` derived the gate (an exclusion, via its own + // SKIP_PATHS literal) while `docs/qa/` derived nothing. + // + // Read from the real gate, not a fixture: what is pinned is that the tree + // still HAS the declaration. + const docFormulaHints = extractWatchHints(readFileSync(join(ROOT, 'packages/lint/scripts/check-doc-formula-expressions.mjs'), 'utf8')); + // One case per declared root, because a single one passes for a declaration + // that dropped the other two. Each path is reachable ONLY through its root's + // subtree spelling, never through a SKIP_PATHS literal. + t('the doc-formula gate reaches the live docs corpus it declares', docFormulaHints.some((h) => hintCovers(h, 'docs/qa/platform-checklist/RUNNER.md'))); + t('and the published skills catalog', docFormulaHints.some((h) => hintCovers(h, 'skills/objectstack-upgrade/SKILL.md'))); + t('and the content tree', docFormulaHints.some((h) => hintCovers(h, 'content/docs/deployment/cli.mdx'))); + // ⚠️ These two do NOT pin the declaration, and say so rather than reading as + // though they do. `.claude` is a top-level DOTTED dir, which `looksPathy` + // admits and `hintCovers` does not refuse; `packages/spec/src` (the gate's + // SPEC_ROOT, its second surface — 972 files) already carries a separator. + // Both reach their paths on the bare literal alone — measured: deleting the + // declaration outright leaves both green. What they pin is that those two + // surfaces stay reachable AT ALL; the declaration itself is pinned in the + // gate's own self-test, which requires a subtree spelling for every + // separator-less ROOT and a separator in SPEC_ROOT. + t('and the agent operating manual it walks for the same reason', docFormulaHints.some((h) => hintCovers(h, '.claude/agents/os-dev.md'))); + t('and its second surface, the spec TSDoc population', docFormulaHints.some((h) => hintCovers(h, 'packages/spec/src/index.ts'))); + // The negative half, load-bearing for a declaration spanning four roots: a + // gate named on EVERY card is the louder version of naming none. `packages/` + // must be probed OUTSIDE `packages/spec/src`, which the gate really does read + // — a case using a spec path would pass on SPEC_ROOT and pin nothing. + t('and claims nothing elsewhere under packages/', !docFormulaHints.some((h) => hintCovers(h, 'packages/core/src/index.ts'))); + t('nor under apps/', !docFormulaHints.some((h) => hintCovers(h, 'apps/console/src/main.tsx'))); + t('nor under examples/', !docFormulaHints.some((h) => hintCovers(h, 'examples/crm/objects/account.object.ts'))); + // The bounded residual, pinned as pre-existing rather than as a cost of this + // declaration. `hintCovers` cannot subtract, so `docs/**` necessarily claims + // the exempt `docs/plans` — but that subtree ALREADY derived the gate through + // its own SKIP_PATHS literal, so the declaration adds nothing on that side. + // Asserted with the declaration removed from the hint set, which is what makes + // it a measurement instead of a restatement. + const withoutDeclaration = docFormulaHints.filter((h) => !['docs/**', 'skills/**', 'content/**', '.claude/**'].includes(h)); + t('the exempt subtree derived the gate before this declaration, and still does — the over-claim is bounded, not new', withoutDeclaration.some((h) => hintCovers(h, 'docs/plans/x.md'))); + t('while the live corpus derived nothing without it — the gap this closes', !withoutDeclaration.some((h) => hintCovers(h, 'docs/qa/platform-checklist/RUNNER.md'))); + // The pair that makes the declaration worth having: the bare words this gate + // spells in its ROOTS array stay refused, so the coverage above is bought by + // the declaration and by nothing else. + t('the bare root words the gate spells in ROOTS are still refused as too generic', !hintCovers('docs', 'docs/qa/platform-checklist/RUNNER.md') && !hintCovers('content', 'content/docs/deployment/cli.mdx')); + t('while the declared subtrees cover those same paths', hintCovers('docs/**', 'docs/qa/platform-checklist/RUNNER.md') && hintCovers('content/**', 'content/docs/deployment/cli.mdx')); + // ── A trailing sentence period is not part of the path (#8534, half two) ── // // Coupled to the rule above: the raw-prefix comparison reached the real file