|
| 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