Skip to content

Commit a4c2dc9

Browse files
claude[bot]os-zhuangclaude
authored
fix(lint): declare check:doc-formula-expressions' real corpus to dispatch-gates (#10687)
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. Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt Co-authored-by: Claude <jack@objectstack.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 86a8ec9 commit a4c2dc9

2 files changed

Lines changed: 220 additions & 1 deletion

File tree

packages/lint/scripts/check-doc-formula-expressions.mjs

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,105 @@ const SKIP_PATHS = new Set([
186186
'content/docs/references',
187187
]);
188188

189+
/**
190+
* ROOTS above, written in the subtree spelling `scripts/pm/dispatch-gates.mjs`
191+
* compares in. Provenance ONLY: nothing in this gate reads this list, and the
192+
* scan behaves exactly as it did without it.
193+
*
194+
* ## The gap this closes (#9964's declaration pattern, seventh instance)
195+
*
196+
* That tool builds every dispatch's gate list by scanning each gate's own
197+
* source for the path literals it operates on, and "looks like a path" there
198+
* means "carries a separator" — or names a top-level DOTTED directory, which is
199+
* the one arm that saved `.claude`. So three of the four ROOTS were bare words
200+
* that never became a hint, while `SKIP_PATHS` above spells its entries with
201+
* separators, and those DID.
202+
*
203+
* Measured on this tree, the gate's whole extracted hint set was:
204+
*
205+
* .claude the one md/mdx root the dotted-dir arm
206+
* admitted — 20 files
207+
* packages/spec/src SPEC_ROOT, surface 2 — already a path
208+
* literal, so already visible: 972 files
209+
* .claude/worktrees, docs/audits, the exemptions, i.e. the subtrees it
210+
* docs/handoff, docs/plans, deliberately does NOT read
211+
* content/docs/references
212+
* @objectstack/formula the import specifier, inert as a path
213+
*
214+
* Of the 1388 files this gate walks, 396 were declared by nothing at all
215+
* (28.5%) — every file under `docs` (156), `skills` (48) and `content` (192).
216+
* The three declarations below are what close that.
217+
*
218+
* Within the `docs` root the shape was inverted rather than merely absent: a
219+
* card touching `docs/plans/` DERIVED this gate (via the SKIP_PATHS literal —
220+
* a subtree the walk returns from immediately), while a card touching
221+
* `docs/qa/platform-checklist/` derived nothing. The exclusions were the
222+
* declaration and the population was not.
223+
*
224+
* That is worse than declaring nothing, and worse in the direction that hides
225+
* it: the residue line still PRINTED gate names, so the row read as "declared,
226+
* just not relevant to you" rather than as a blind spot. A card editing the
227+
* live docs corpus met this REQUIRED gate (lint.yml, `pnpm --filter
228+
* @objectstack/lint run check:doc-formula-expressions`) as red CI instead of as
229+
* a local command.
230+
*
231+
* `.claude/**` is redundant with the bare `.claude` the extractor already takes
232+
* on its dotted-dir arm, and is kept so the declaration is uniform across ROOTS
233+
* rather than depending on which arm happened to admit which root. SPEC_ROOT
234+
* needs no entry for the same reason `.claude` did not strictly need one — it
235+
* already carries a separator — and the self-test pins that it still does, so
236+
* renaming it to a bare word fails here instead of silently unhinting 972
237+
* files.
238+
*
239+
* ## Why the subtree spelling, and not a wider extractor
240+
*
241+
* `hintCovers` refuses a bare single-segment literal (`docs`) as too generic BY
242+
* DESIGN, and that refusal is measured rather than incidental: teaching the
243+
* extractor to accept bare top-level directory words was priced at +139084
244+
* fabricated (gate, file) pairs, because `packages`, `apps` and `examples` are
245+
* path COMPONENTS in dozens of gates that never read those roots. Nor can a
246+
* class-level guard author this for us — flagging any gate that names a bare
247+
* tracked directory none of its hints reach fires on 40 of 123 families, and
248+
* the majority are right as they stand. The distinction between "population
249+
* root" and "path component" is in the author's intent, not the source text,
250+
* which is why the declaration has to be authored, gate by gate.
251+
*
252+
* ## Why the ROOT, and not the live subtrees under it (the SKIP_PATHS question)
253+
*
254+
* `hintCovers` has no way to SUBTRACT: hints are positive containment, so
255+
* "`docs/**` except `docs/plans`" is not expressible. The exempt subtrees are
256+
* therefore claimed by this declaration, and that is a DELIBERATE, bounded
257+
* residual rather than an oversight — pinned as such in the self-test, so it
258+
* cannot silently grow past the exemptions it is accounted for.
259+
*
260+
* The same limit applies one level down, to the extension filter: `collectFiles`
261+
* keeps only `.md`/`.mdx` (and `.ts`/`.tsx` under SPEC_ROOT), which a subtree
262+
* hint cannot express either — so a card touching `content/docs.site.json`
263+
* derives this gate although the walk skips that file. Both residuals point the
264+
* same way: the declaration over-claims INSIDE what it walks, never outside,
265+
* and the negative half of the self-test is what holds that line.
266+
*
267+
* The residual is also not new, and this declaration does not widen it by one
268+
* path: those five subtrees derive this gate TODAY, via the `SKIP_PATHS`
269+
* literals themselves, which stay hints whatever this list says. Removing that
270+
* residual would mean unquoting the most safety-critical constant in this file.
271+
* The declaration subsumes those hints and adds nothing to that side while
272+
* closing all 396 files of the missing side.
273+
*
274+
* What the precedent does draw a line at is claiming a tree the ROOTS do not
275+
* reach at all, and the self-test in `scripts/pm/dispatch-gates.mjs` pins that
276+
* negative half against the real extractor — the load-bearing direction for a
277+
* declaration this broad, since a gate named on EVERY card is the louder
278+
* version of naming none.
279+
*
280+
* ## Provenance, never a lookup key
281+
*
282+
* The glob form appearing in ROOTS would send `walk()` at a directory that does
283+
* not exist — since #4916 a hard refusal rather than a silent skip, but one
284+
* that fails naming the wrong problem. The self-test pins both halves.
285+
*/
286+
const ROOT_WATCH_HINTS = ['.claude/**', 'docs/**', 'skills/**', 'content/**'];
287+
189288
const posix = (p) => p.split(sep).join('/');
190289

191290
/**
@@ -918,6 +1017,66 @@ const EXEMPTION_SELF_TEST_CASES = [
9181017
},
9191018
];
9201019

1020+
/**
1021+
* The `scripts/pm/dispatch-gates.mjs` declaration (#9964's pattern, seventh
1022+
* instance), pinned in both directions.
1023+
*
1024+
* Enforcement cannot hold any of these: the declaration is read by another tool
1025+
* entirely, so a wrong or stale one runs green here forever and pays itself out
1026+
* as a dev dispatched on a docs card with this REQUIRED gate missing from the
1027+
* brief — which is exactly how it stood before this block. Both sides are
1028+
* derived from ROOTS rather than re-spelled, so renaming or widening a root
1029+
* cannot leave the declaration describing the old population.
1030+
*/
1031+
const DECLARATION_SELF_TEST_CASES = [
1032+
{
1033+
name: 'DECLARATION — every ROOT the hint extractor cannot see is declared as a subtree '
1034+
+ '(a root with no path separator is refused as too generic)',
1035+
holds: () => ROOTS.filter((r) => !r.includes('/')).every((r) => ROOT_WATCH_HINTS.includes(`${r}/**`)),
1036+
},
1037+
{
1038+
name: 'DECLARATION — and it declares no root this gate does not walk (a declaration that '
1039+
+ 'can drift from the scan is worse than none — it replaces a silent gate with a lying one)',
1040+
holds: () => ROOT_WATCH_HINTS.every((h) => ROOTS.includes(h.replace(/\/\*+$/, ''))),
1041+
},
1042+
{
1043+
// Provenance, never a lookup key: the glob form appearing in ROOTS would
1044+
// send `walk()` at a directory that does not exist. Since #4916 that is a
1045+
// hard refusal rather than a silent skip, but it fails naming the wrong
1046+
// problem.
1047+
name: 'DECLARATION — the declared glob form is NOT a ROOTS entry',
1048+
holds: () => !ROOT_WATCH_HINTS.some((h) => ROOTS.includes(h)),
1049+
},
1050+
{
1051+
// The residual, pinned rather than hidden. `hintCovers` is positive
1052+
// containment with no way to subtract, so declaring a ROOT necessarily
1053+
// claims the exempt subtrees carved out of it. That is accounted for — but
1054+
// only for the exemptions themselves: every SKIP_PATHS entry must sit UNDER
1055+
// a declared root, so a future exemption somewhere this declaration does not
1056+
// reach fails here instead of quietly widening the over-claim.
1057+
name: 'DECLARATION — every skipped subtree is one this declaration knowingly over-claims, '
1058+
+ 'and none is a surprise from outside the declared roots',
1059+
holds: () => [...SKIP_PATHS].every((p) => ROOTS.some((r) => p.startsWith(`${r}/`))),
1060+
},
1061+
{
1062+
// The exemptions must stay a strict SUBSET of the walked roots: an entry
1063+
// that WAS a whole root would mean the gate declares a population it never
1064+
// reads.
1065+
name: 'DECLARATION — no exemption swallows a declared root whole',
1066+
holds: () => ![...SKIP_PATHS].some((p) => ROOTS.includes(p)),
1067+
},
1068+
{
1069+
// Surface 2 is declared by SPEC_ROOT itself, which the extractor takes
1070+
// because it carries a separator — 972 files riding on one property of one
1071+
// string. Renaming it to a bare word (`spec`, say) would unhint all of them
1072+
// exactly the way `docs` was unhinted, and this is the only place that
1073+
// would notice.
1074+
name: 'DECLARATION — SPEC_ROOT still carries a path separator, so surface 2 needs no '
1075+
+ 'subtree spelling of its own',
1076+
holds: () => SPEC_ROOT.includes('/'),
1077+
},
1078+
];
1079+
9211080
function specSelfTest() {
9221081
const problemsFor = (c) => {
9231082
const problems = [];
@@ -1004,7 +1163,12 @@ function selfTest() {
10041163
}
10051164
}
10061165
failed += specSelfTest();
1007-
const total = SELF_TEST_CASES.length + SPEC_SELF_TEST_CASES.length + EXEMPTION_SELF_TEST_CASES.length;
1166+
for (const c of DECLARATION_SELF_TEST_CASES) {
1167+
if (c.holds()) console.log(` ✓ ${c.name}`);
1168+
else { failed++; console.error(` ✗ ${c.name}`); }
1169+
}
1170+
const total = SELF_TEST_CASES.length + SPEC_SELF_TEST_CASES.length + EXEMPTION_SELF_TEST_CASES.length
1171+
+ DECLARATION_SELF_TEST_CASES.length;
10081172
if (failed > 0) {
10091173
console.error(`\n✗ check:doc-formula-expressions self-test: ${failed} case(s) failed`);
10101174
process.exit(1);

scripts/pm/dispatch-gates.mjs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3289,6 +3289,61 @@ function selfTest() {
32893289
t('the bare root word the gate spells in ROOTS is still refused as too generic', !hintCovers('skills', 'skills/objectstack-platform/SKILL.md'));
32903290
t('while the declared subtree covers that same path', hintCovers('skills/**', 'skills/objectstack-platform/SKILL.md'));
32913291

3292+
// The seventh instance of the same directory class (#10664), in a
3293+
// PACKAGE-scoped gate — `pnpm --filter @objectstack/lint run
3294+
// check:doc-formula-expressions`, REQUIRED in lint.yml — so the source this
3295+
// reads is resolved through that package's manifest rather than the root one.
3296+
//
3297+
// Its ROOTS were `['.claude', 'docs', 'skills', 'content']`, three bare words
3298+
// and one dotted dir, while its SKIP_PATHS spelled five exclusions WITH
3299+
// separators. Measured on this tree: of the 1388 files it walks, 396 (28.5%)
3300+
// were declared by nothing — every file under `docs` (156), `skills` (48) and
3301+
// `content` (192). Inside the `docs` root the shape was inverted rather than
3302+
// merely absent: `docs/plans/` derived the gate (an exclusion, via its own
3303+
// SKIP_PATHS literal) while `docs/qa/` derived nothing.
3304+
//
3305+
// Read from the real gate, not a fixture: what is pinned is that the tree
3306+
// still HAS the declaration.
3307+
const docFormulaHints = extractWatchHints(readFileSync(join(ROOT, 'packages/lint/scripts/check-doc-formula-expressions.mjs'), 'utf8'));
3308+
// One case per declared root, because a single one passes for a declaration
3309+
// that dropped the other two. Each path is reachable ONLY through its root's
3310+
// subtree spelling, never through a SKIP_PATHS literal.
3311+
t('the doc-formula gate reaches the live docs corpus it declares', docFormulaHints.some((h) => hintCovers(h, 'docs/qa/platform-checklist/RUNNER.md')));
3312+
t('and the published skills catalog', docFormulaHints.some((h) => hintCovers(h, 'skills/objectstack-upgrade/SKILL.md')));
3313+
t('and the content tree', docFormulaHints.some((h) => hintCovers(h, 'content/docs/deployment/cli.mdx')));
3314+
// ⚠️ These two do NOT pin the declaration, and say so rather than reading as
3315+
// though they do. `.claude` is a top-level DOTTED dir, which `looksPathy`
3316+
// admits and `hintCovers` does not refuse; `packages/spec/src` (the gate's
3317+
// SPEC_ROOT, its second surface — 972 files) already carries a separator.
3318+
// Both reach their paths on the bare literal alone — measured: deleting the
3319+
// declaration outright leaves both green. What they pin is that those two
3320+
// surfaces stay reachable AT ALL; the declaration itself is pinned in the
3321+
// gate's own self-test, which requires a subtree spelling for every
3322+
// separator-less ROOT and a separator in SPEC_ROOT.
3323+
t('and the agent operating manual it walks for the same reason', docFormulaHints.some((h) => hintCovers(h, '.claude/agents/os-dev.md')));
3324+
t('and its second surface, the spec TSDoc population', docFormulaHints.some((h) => hintCovers(h, 'packages/spec/src/index.ts')));
3325+
// The negative half, load-bearing for a declaration spanning four roots: a
3326+
// gate named on EVERY card is the louder version of naming none. `packages/`
3327+
// must be probed OUTSIDE `packages/spec/src`, which the gate really does read
3328+
// — a case using a spec path would pass on SPEC_ROOT and pin nothing.
3329+
t('and claims nothing elsewhere under packages/', !docFormulaHints.some((h) => hintCovers(h, 'packages/core/src/index.ts')));
3330+
t('nor under apps/', !docFormulaHints.some((h) => hintCovers(h, 'apps/console/src/main.tsx')));
3331+
t('nor under examples/', !docFormulaHints.some((h) => hintCovers(h, 'examples/crm/objects/account.object.ts')));
3332+
// The bounded residual, pinned as pre-existing rather than as a cost of this
3333+
// declaration. `hintCovers` cannot subtract, so `docs/**` necessarily claims
3334+
// the exempt `docs/plans` — but that subtree ALREADY derived the gate through
3335+
// its own SKIP_PATHS literal, so the declaration adds nothing on that side.
3336+
// Asserted with the declaration removed from the hint set, which is what makes
3337+
// it a measurement instead of a restatement.
3338+
const withoutDeclaration = docFormulaHints.filter((h) => !['docs/**', 'skills/**', 'content/**', '.claude/**'].includes(h));
3339+
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')));
3340+
t('while the live corpus derived nothing without it — the gap this closes', !withoutDeclaration.some((h) => hintCovers(h, 'docs/qa/platform-checklist/RUNNER.md')));
3341+
// The pair that makes the declaration worth having: the bare words this gate
3342+
// spells in its ROOTS array stay refused, so the coverage above is bought by
3343+
// the declaration and by nothing else.
3344+
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'));
3345+
t('while the declared subtrees cover those same paths', hintCovers('docs/**', 'docs/qa/platform-checklist/RUNNER.md') && hintCovers('content/**', 'content/docs/deployment/cli.mdx'));
3346+
32923347
// ── A trailing sentence period is not part of the path (#8534, half two) ──
32933348
//
32943349
// Coupled to the rule above: the raw-prefix comparison reached the real file

0 commit comments

Comments
 (0)