Skip to content

Commit c097f71

Browse files
claude[bot]os-zhuangclaude
authored
feat(devx): hold every ROOT_DIR_WATCH_HINTS declaration to a literal spelling (#12855)
`extractWatchHints` reads source text, so a declaration computed from the gate's population constant contributes no hint at all while its runtime value is unchanged. The gate then leaves every dispatch brief and scores a quiet green for every card in the subtree it walks. Fifteen declarations carry the idiom; one had an own-source pin holding it to a literal spelling. This adds the shared guard the class needs: for every declarer, repo-wide, the right-hand side of the declaration statement must be an array of quoted string literals. The search is scoped to the declaration STATEMENT. A whole-file search finds the gate's own hint spelled again in a runtime assertion or in a comment and stays green on the computed form, which is how both earlier per-file pins failed. Claude-Session: https://claude.ai/code/session_01PfaSTikked61BkcsB5Rn69 Co-authored-by: os-zhuang <jack@objectstack.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 3ece130 commit c097f71

3 files changed

Lines changed: 370 additions & 0 deletions

File tree

.github/workflows/lint.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,25 @@ jobs:
529529
- name: PM dispatch-gates self-test
530530
run: pnpm check:pm-dispatch-gates
531531

532+
# Every ROOT_DIR_WATCH_HINTS declaration stays READABLE BY A TEXT SCANNER
533+
# (#12762). The step above proves the extractor still works; this one
534+
# proves it still has something to extract. `extractWatchHints` reads
535+
# source text, so a declaration rewritten from a literal into a mapped
536+
# expression over the gate's population constant contributes NO hint at
537+
# all while its runtime value is unchanged — every local assertion about
538+
# that value stays green, and the gate silently leaves every dispatch
539+
# brief and scores a quiet green for every card in the tree it walks.
540+
# Measured on this tree: the two ledger self-tests above do NOT catch it
541+
# (a computed declaration with the literal kept in a neighbouring comment
542+
# ran all three of them green), and only one of the fifteen declarations
543+
# carried an own-source pin. The search is scoped to the declaration
544+
# STATEMENT: a whole-file search finds the gate's own hint spelled again
545+
# in a runtime assertion or a comment and stays green on the computed
546+
# form, which is exactly how the two earlier per-file pins failed.
547+
# Repo-wide sweep of authored JS/TS, no spawns; ~0.5s.
548+
- name: ROOT_DIR_WATCH_HINTS declarations are literals
549+
run: pnpm check:watch-hint-literal
550+
532551
# PM bare-root worklist self-test (#10840). The step above proves the
533552
# dispatch derivation still WORKS; this one proves the recorded triage of
534553
# the gates that derivation structurally cannot see is still true of the

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"check:pm-skill-id-lint": "node scripts/pm/check-skill-id-lint.mjs --self-test && node scripts/pm/check-skill-id-lint.mjs",
6565
"check:pm-label-desc-cap": "node scripts/pm/check-label-desc-cap.mjs --self-test && node scripts/pm/check-label-desc-cap.mjs",
6666
"check:pm-dispatch-gates": "node scripts/pm/check-dispatch-gates.mjs",
67+
"check:watch-hint-literal": "node scripts/check-watch-hint-literal.mjs --self-test && node scripts/check-watch-hint-literal.mjs",
6768
"check:pm-half-states": "node scripts/pm/check-half-states.mjs --self-test",
6869
"check:pm-governed-merges": "node scripts/pm/check-governed-merges.mjs --self-test",
6970
"check:pm-governed-prose": "node scripts/pm/check-governed-prose.mjs --self-test && node scripts/pm/check-governed-prose.mjs",
Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
#!/usr/bin/env node
2+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
3+
4+
/**
5+
* check-watch-hint-literal -- every ROOT_DIR_WATCH_HINTS declaration in this
6+
* repo is spelled as a LITERAL array, inside the declaration statement itself.
7+
*
8+
* node scripts/check-watch-hint-literal.mjs # scan the tree
9+
* node scripts/check-watch-hint-literal.mjs --list # every declarer and what it declares
10+
* node scripts/check-watch-hint-literal.mjs --self-test # verify the checker itself
11+
*
12+
* ## The mechanism this closes
13+
*
14+
* `extractWatchHints` in `scripts/pm/dispatch-gates.mjs` reads SOURCE TEXT. A
15+
* declaration written as a literal contributes its hints; the same declaration
16+
* computed from the gate's population constant contributes NOTHING -- the
17+
* runtime value is identical, every local assertion about that value stays
18+
* green, and the gate silently drops out of every dispatch brief. Measured
19+
* against the extractor, one declaration, two spellings:
20+
*
21+
* literal -> ["scripts/**"]
22+
* computed from the population constant -> []
23+
*
24+
* A gate that loses its hint is unnameable by the dispatch tool and scores a
25+
* quiet green for every card in the tree it walks. Rewriting the declaration
26+
* that way is a natural-looking tidy-up: in most cases the hint is literally
27+
* the population root plus a subtree glob, so `ROOTS.map((r) => `${r}/**`)`
28+
* reads like an improvement.
29+
*
30+
* ## Why a shared gate rather than a per-file pin
31+
*
32+
* The idiom had one own-source pin holding a declaration to a literal spelling
33+
* (`scripts/check-cli-command-ids.mjs`, statement-scoped since #12759). Every
34+
* other declaration in the tree had nothing local that would redden. Eleven
35+
* copies of a per-file pin is eleven chances to write the SEARCH wrong, and the
36+
* measured way to write it wrong is documented below.
37+
*
38+
* ## The scoping detail that is the whole difficulty
39+
*
40+
* Searching the WHOLE FILE for the hint is not sufficient, and that is measured
41+
* twice on this tree:
42+
*
43+
* - a gate may spell its own hint again in a neighbouring RUNTIME assertion,
44+
* so a whole-file `includes` finds THAT copy and stays green on the
45+
* computed declaration (the defect #12472 was filed for);
46+
* - a gate may spell it a third time in a COMMENT, which the same whole-file
47+
* search also accepts -- the assembled-needle remedy in
48+
* `scripts/check-objectql-double-limit.mjs` was measured green against
49+
* exactly that mutation.
50+
*
51+
* So the search here is scoped to the declaration STATEMENT, and comments are
52+
* masked before the statement is located, through the repo's one comment
53+
* scanner (`scripts/js-comment-mask.mjs`).
54+
*
55+
* ## What is asserted, and what is deliberately NOT
56+
*
57+
* Asserted: the right-hand side of the declaration is an ARRAY OF QUOTED STRING
58+
* LITERALS and nothing else. That is stronger than "each declared hint appears
59+
* quoted inside the statement", and it needs no runtime value, so this gate
60+
* never imports the files it judges -- a gate that imported 14 modules to read
61+
* one constant would run their module bodies to do it.
62+
*
63+
* NOT asserted: that a declaration is CORRECT -- that it names the roots the
64+
* gate really walks, and only those. That claim is local to each gate and each
65+
* one already pins it from its own side, where the walked root is in scope.
66+
* This gate holds the one property none of them can hold about itself: that the
67+
* declaration is still READABLE BY A TEXT SCANNER.
68+
*
69+
* An empty population is REFUSED rather than passed. "Every declaration is a
70+
* literal" is vacuously true over zero declarations, so a sweep that breaks --
71+
* a renamed constant, a walk that stops descending -- would otherwise report
72+
* the healthiest green this gate can print.
73+
*
74+
* ## A note for whoever adds a fixture here
75+
*
76+
* The scan masks comments but NOT string literals, so a fixture in the
77+
* self-test below that spelled the declaration verbatim would be found as a
78+
* second declaration site in this very file and refused. Fixtures therefore
79+
* assemble the constant name from `DECL_NAME`, and a self-test case pins that
80+
* this file still holds exactly one site.
81+
*/
82+
83+
import { readdirSync, readFileSync, statSync } from 'node:fs';
84+
import { dirname, relative, resolve, join } from 'node:path';
85+
import { fileURLToPath } from 'node:url';
86+
87+
import { isEntrypoint } from './invoked-as.mjs';
88+
import { maskComments } from './js-comment-mask.mjs';
89+
90+
const HERE = dirname(fileURLToPath(import.meta.url));
91+
const REPO_ROOT = resolve(HERE, '..');
92+
93+
/** The constant this gate is about, spelled ONCE. */
94+
const DECL_NAME = 'ROOT_DIR_WATCH_HINTS';
95+
96+
/**
97+
* This gate's own declaration, and the reason it is narrower than the
98+
* population it walks.
99+
*
100+
* The population is every tracked source file that declares the constant --
101+
* repo-wide, because the idiom is not confined to `scripts/` (
102+
* `packages/spec/scripts/build-skill-references.ts` carries one). But the
103+
* spellable claim for a repo-wide walk would be a wholesale `packages/**`,
104+
* which is the costlier error: declaring a root a gate does not read wholesale
105+
* pastes it into every card under that root. So the declaration names the
106+
* subtree where the declarations actually live -- 13 of the 14 on this tree --
107+
* and the sweep stays repo-wide so nothing outside it is missed SILENTLY: a
108+
* declarer that appears elsewhere is judged like any other, it just does not
109+
* put this gate on that card's brief.
110+
*/
111+
const ROOT_DIR_WATCH_HINTS = ['scripts/**'];
112+
113+
const SKIP_DIRS = new Set([
114+
'node_modules', 'dist', 'build', 'coverage', '.turbo', '.next', '.cache', '.git', 'out',
115+
]);
116+
117+
/** Anything the repo authors in JS or TS. `.d.ts` is generated. */
118+
const SOURCE_EXT = /\.(?:[cm]?[jt]sx?)$/;
119+
120+
/** Every authored JS/TS file under `dir`, dot-directories included. */
121+
export function walk(dir, out = []) {
122+
for (const name of readdirSync(dir)) {
123+
if (SKIP_DIRS.has(name)) continue;
124+
const p = join(dir, name);
125+
const st = statSync(p);
126+
if (st.isDirectory()) walk(p, out);
127+
else if (SOURCE_EXT.test(name) && !name.endsWith('.d.ts')) out.push(p);
128+
}
129+
return out;
130+
}
131+
132+
/**
133+
* The right-hand side of every declaration statement, comments masked.
134+
*
135+
* `[^;]*` is the statement terminator and also the guard: a right-hand side
136+
* carrying a `;` of its own (a block-bodied arrow, say) truncates here and
137+
* fails the literal test below, which is the safe direction to fail in.
138+
*/
139+
export function declarationSites(source) {
140+
const code = maskComments(source);
141+
const re = new RegExp(
142+
String.raw`\b(?:export\s+)?(?:const|let|var)\s+${DECL_NAME}\s*(?::[^=;]*)?=\s*([^;]*);`,
143+
'g',
144+
);
145+
return [...code.matchAll(re)].map((m) => m[1]);
146+
}
147+
148+
/**
149+
* The hints a right-hand side spells as quoted literals, or `null` when the
150+
* right-hand side is anything other than an array of quoted string literals.
151+
*
152+
* Backticks are refused with the rest: a template is the computed spelling this
153+
* gate exists to catch, and `extractWatchHints` cannot read a value out of one.
154+
*/
155+
export function literalHints(rhs) {
156+
const s = rhs.trim();
157+
if (!s.startsWith('[') || !s.endsWith(']')) return null;
158+
let rest = s.slice(1, -1);
159+
const hints = [];
160+
const element = /^\s*(?:'([^'\\\n]*)'|"([^"\\\n]*)")\s*(,?)/;
161+
while (rest.trim() !== '') {
162+
const m = element.exec(rest);
163+
if (!m) return null;
164+
hints.push(m[1] ?? m[2]);
165+
rest = rest.slice(m[0].length);
166+
if (m[3] !== ',') break;
167+
}
168+
return rest.trim() === '' ? hints : null;
169+
}
170+
171+
/**
172+
* One file's verdict. `null` means the file is not a declarer at all -- it
173+
* mentions the constant in prose, or reads someone else's.
174+
*/
175+
export function auditSource(rel, source) {
176+
if (!source.includes(DECL_NAME)) return null;
177+
const sites = declarationSites(source);
178+
if (sites.length === 0) return null;
179+
if (sites.length > 1) {
180+
return {
181+
rel,
182+
ok: false,
183+
why: `${sites.length} declaration sites -- this gate cannot judge a declaration it cannot locate`,
184+
};
185+
}
186+
const hints = literalHints(sites[0]);
187+
if (hints === null) {
188+
return {
189+
rel,
190+
ok: false,
191+
why: 'the declaration is COMPUTED, not a literal array -- the hint extractor reads source '
192+
+ 'text, so a computed declaration builds no hint at all and the gate leaves every '
193+
+ `dispatch brief: ${sites[0].trim().replace(/\s+/g, ' ').slice(0, 120)}`,
194+
};
195+
}
196+
if (hints.length === 0) {
197+
return { rel, ok: false, why: 'the declaration is EMPTY -- it names no subtree at all' };
198+
}
199+
return { rel, ok: true, hints };
200+
}
201+
202+
/** Every declarer under `files`, judged. */
203+
export function audit(files, read = (abs) => readFileSync(abs, 'utf8')) {
204+
const rows = [];
205+
for (const abs of files) {
206+
const rel = relative(REPO_ROOT, abs).split('\\').join('/');
207+
const row = auditSource(rel, read(abs));
208+
if (row) rows.push(row);
209+
}
210+
rows.sort((a, b) => (a.rel < b.rel ? -1 : 1));
211+
return rows;
212+
}
213+
214+
function list() {
215+
for (const r of audit(walk(REPO_ROOT))) {
216+
console.log(`${r.ok ? ' ' : '✗ '}${r.rel} ${r.ok ? JSON.stringify(r.hints) : r.why}`);
217+
}
218+
return 0;
219+
}
220+
221+
function main() {
222+
const rows = audit(walk(REPO_ROOT));
223+
if (rows.length === 0) {
224+
console.error(
225+
`✗ check-watch-hint-literal: NO ${DECL_NAME} declaration found anywhere in this tree. `
226+
+ 'Refused rather than passed -- "every declaration is a literal" is vacuously true over '
227+
+ 'an empty population, so a broken sweep would print this gate\'s healthiest green.',
228+
);
229+
return 1;
230+
}
231+
const bad = rows.filter((r) => !r.ok);
232+
for (const r of bad) console.error(` ✗ ${r.rel} -- ${r.why}`);
233+
if (bad.length) {
234+
console.error(
235+
`✗ check-watch-hint-literal: ${bad.length} of ${rows.length} ${DECL_NAME} declaration(s) `
236+
+ 'are not readable as literals. Spell the hints inside the declaration statement.',
237+
);
238+
return 1;
239+
}
240+
console.log(
241+
`✓ check-watch-hint-literal: ${rows.length} ${DECL_NAME} declaration(s), every one an array `
242+
+ 'of quoted literals inside its own statement.',
243+
);
244+
return 0;
245+
}
246+
247+
// ---------------------------------------------------------------------------
248+
// Self-test -- fixture sources, plus the live tree
249+
// ---------------------------------------------------------------------------
250+
251+
export function selfTest() {
252+
const cases = [];
253+
const t = (name, ok, detail) => cases.push({ name, ok: Boolean(ok), detail });
254+
/** A fixture that never spells the declaration verbatim in THIS file's text. */
255+
const decl = (rhs, extra = '') => `${extra}const ${DECL_NAME} = ${rhs};\n`;
256+
const verdict = (src) => auditSource('f.mjs', src);
257+
const rejected = (src) => verdict(src) !== null && verdict(src).ok === false;
258+
const accepted = (src) => verdict(src) !== null && verdict(src).ok === true;
259+
260+
// -- the computed spellings this gate exists to reject ---------------------
261+
const COMPUTED = [
262+
'ROOTS.map((r) => `${r}/**`)',
263+
'[`${SCAN_ROOT}/**`]',
264+
"[SCAN_ROOT + '/**']",
265+
"['scripts' + '/' + '*'.repeat(2)]",
266+
'[...OTHER_HINTS]',
267+
"['scripts/**'].map((h) => h)",
268+
'ROOTS.filter((r) => !r.includes(SEP)).map((r) => r + SUFFIX)',
269+
"[join('scripts', '**')]",
270+
];
271+
COMPUTED.forEach((rhs, i) => t(
272+
`computed spelling ${i + 1} of ${COMPUTED.length} is rejected`,
273+
rejected(decl(rhs)),
274+
rhs,
275+
));
276+
277+
// ⭐ The case the whole gate turns on, and the one both earlier remedies got
278+
// wrong: the literal is still in the file -- in a neighbouring RUNTIME
279+
// assertion and again in a COMMENT -- and the declaration is still computed.
280+
// A whole-file search finds those copies and stays green. This must not.
281+
const scoped = decl('[`${SCAN_ROOT}/**`]', `// the declared subtree is 'packages/**'\n`)
282+
+ `assert(${DECL_NAME}.includes('packages/**'));\n`;
283+
t('a computed declaration is rejected THROUGH a runtime copy of the literal beside it',
284+
rejected(scoped), 'whole-file `includes` is what this replaces');
285+
t('...and through a COMMENT copy of the literal beside it',
286+
rejected(decl('[`${SCAN_ROOT}/**`]', `// hint: 'packages/**'\n`)));
287+
288+
// -- shapes that are not a literal ARRAY -----------------------------------
289+
t('an empty declaration is rejected -- it names no subtree', rejected(decl('[]')));
290+
t('a bare string declaration is rejected', rejected(decl("'scripts/**'")));
291+
t('two declaration sites are refused rather than judged',
292+
rejected(decl("['a/**']") + decl("['b/**']")));
293+
294+
// -- the literal spellings that must stay accepted -------------------------
295+
t('the canonical spelling is accepted', accepted(decl("['scripts/**']")));
296+
t('an exported declaration is accepted', accepted(decl("['a/**', \"b/**\"]", 'export ')));
297+
t('a multi-line array with a trailing comma is accepted',
298+
accepted(decl("[\n 'packages/*',\n 'apps/*',\n]")));
299+
t('an array carrying an inline comment is accepted',
300+
accepted(decl("[\n 'packages/*', // the workspace roots\n 'apps/*',\n]")));
301+
t('a TypeScript type annotation does not hide the declaration',
302+
accepted(`const ${DECL_NAME}: string[] = ['skills/**'];\n`));
303+
t('the hints are read back out of the statement',
304+
JSON.stringify(verdict(decl("['a/**', 'b/**']")).hints) === '["a/**","b/**"]');
305+
306+
// -- comments and prose are not declarations -------------------------------
307+
t('a COMMENTED-OUT computed declaration does not shadow the real one',
308+
accepted(`// ${decl('ROOTS.map((r) => r)')}${decl("['skills/**']")}`));
309+
t('a file that only MENTIONS the constant is not a declarer',
310+
verdict(`// the ${DECL_NAME} idiom can only name a whole subtree\n`) === null);
311+
t('a file that reads someone else\'s declaration is not a declarer',
312+
verdict(`const spelled = mod.${DECL_NAME}.slice();\n`) === null);
313+
314+
// -- the empty population is refused, not passed ---------------------------
315+
t('an empty population produces no rows, which main() refuses', audit([]).length === 0);
316+
317+
// -- the live tree ---------------------------------------------------------
318+
const live = audit(walk(REPO_ROOT));
319+
t('the live sweep finds a real population, not a broken one', live.length >= 10,
320+
`${live.length} declarer(s)`);
321+
t('every live declaration is a literal', live.every((r) => r.ok),
322+
live.filter((r) => !r.ok).map((r) => r.rel).join(' · '));
323+
t('this gate judges ITSELF -- its own declaration is in the population',
324+
live.some((r) => r.rel === 'scripts/check-watch-hint-literal.mjs'));
325+
t('and this file holds exactly ONE declaration site, so its fixtures stay out of the scan',
326+
declarationSites(readFileSync(fileURLToPath(import.meta.url), 'utf8')).length === 1);
327+
t('the population reaches OUTSIDE scripts/, which is why the sweep is repo-wide',
328+
live.some((r) => !r.rel.startsWith('scripts/')));
329+
t('the declared subtree is where most declarations live',
330+
ROOT_DIR_WATCH_HINTS.includes('scripts/**')
331+
&& live.filter((r) => r.rel.startsWith('scripts/')).length >= 10);
332+
333+
const failed = cases.filter((c) => !c.ok);
334+
for (const c of failed) console.error(` ✗ ${c.name}${c.detail ? ` -- ${c.detail}` : ''}`);
335+
if (failed.length) {
336+
console.error(`✗ check-watch-hint-literal self-test: ${failed.length} of ${cases.length} case(s) failed.`);
337+
return 1;
338+
}
339+
console.log(
340+
`✓ check-watch-hint-literal self-test: ${cases.length} cases pass -- ${COMPUTED.length} computed `
341+
+ 'spellings rejected, the statement-scoped search proved against runtime and comment copies of '
342+
+ 'the literal beside it, literal spellings accepted, and the live repo-wide population judged.',
343+
);
344+
return 0;
345+
}
346+
347+
if (isEntrypoint(import.meta.url)) {
348+
const argv = process.argv;
349+
process.exit(argv.includes('--self-test') ? selfTest() : argv.includes('--list') ? list() : main());
350+
}

0 commit comments

Comments
 (0)