Skip to content

Commit abe4c64

Browse files
claude[bot]os-zhuangclaude
authored
fix(scripts): check-required-contexts reads comments as prose, not as wiring (#10818) (#10878)
`uncommentedYaml` dropped a line only when its first non-space character was `#`, so a TRAILING `# --verify-required-set` on a live line — and a trailing shell comment inside a `run:` block scalar — survived the strip and reddened `Lint & Repo Gates` on prose. A workflow stacks two comment grammars and one line filter was wrong about both. Each now goes to the thing that knows it: `yaml.parse` for the YAML layer, and check-shard-attestation's `shellCommands()` lexer — imported, not re-typed — for the shell inside each `run:`. Both limbs are pinned in `--self-test`: four prose shapes that must NOT read as wiring, and six live shapes that must. The trailing-comment pair differs by the `#` alone. Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt Co-authored-by: claude[bot] <jack@objectstack.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 03d0dec commit abe4c64

1 file changed

Lines changed: 301 additions & 10 deletions

File tree

scripts/check-required-contexts.mjs

Lines changed: 301 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync,
181181
import { tmpdir } from 'node:os';
182182
import { dirname, join, resolve } from 'node:path';
183183
import { fileURLToPath } from 'node:url';
184+
import { shellCommands } from './check-shard-attestation.mjs';
184185
import { isEntrypoint } from './invoked-as.mjs';
185186

186187
/**
@@ -573,6 +574,106 @@ function triggersOf(doc) {
573574
return block && typeof block === 'object' ? block : undefined;
574575
}
575576

577+
/**
578+
* The flag whose WIRING the pin below judges, as one constant rather than four
579+
* regex literals: the recognizer, the two absence assertions and the caller
580+
* sweep all have to be asking about the SAME string.
581+
*/
582+
const LIVE_READ_FLAG = '--verify-required-set';
583+
584+
/**
585+
* The text of a workflow that a RUNNER would ACT ON — every comment gone.
586+
*
587+
* ## Why this is not one regex
588+
*
589+
* It was one, and the regex was wrong: `text.split('\n').filter((l) =>
590+
* !/^\s*#/.test(l))` drops a line only when its FIRST non-space character is
591+
* `#`. A workflow file stacks TWO comment grammars, and a line filter is wrong
592+
* about both of them:
593+
*
594+
* - YAML's `#` opens a comment at line start OR after whitespace, and does
595+
* NOT open one inside a quoted scalar — so a TRAILING `# ...` survived the
596+
* filter whole, carrying its prose into the match;
597+
* - a `run:` block scalar is not YAML at all, it is SHELL. There `#` opens a
598+
* comment at a word boundary, `#` inside quotes is an argument, and a
599+
* backslash-newline continues the command — none of which a per-line test
600+
* can see. A trailing `# ...` on a live command line survived there too.
601+
*
602+
* So a comment WARNING people about `--verify-required-set` read as WIRING.
603+
* That is not a hypothetical shape, it is the ordinary way anyone documents a
604+
* trap, and the neighbouring `ci.yml` block already writes exactly such a
605+
* comment for check-shard-attestation's sibling flag. Documenting the trap
606+
* re-arms it — the #4890 shape, which is why check-shard-attestation's own
607+
* `shellCommands()` drops comments and pins that it does.
608+
*
609+
* ## Each grammar goes to the thing that knows it
610+
*
611+
* `yaml.parse` answers the YAML layer: comments are not part of the parsed
612+
* document, leading or trailing, while a `#` inside a quoted scalar stays in
613+
* the string where it belongs. `shellCommands()` answers the shell layer inside
614+
* each `run:` — check-shard-attestation's lexer, IMPORTED rather than re-typed.
615+
* Two private comment strippers drifting apart in opposite directions is the
616+
* exact history `js-comment-mask.mjs` exists to have ended, and a second
617+
* hand-rolled one here would be the third family.
618+
*
619+
* ## Width is deliberate, and so is where it stops
620+
*
621+
* Outside a `run:` block this keeps the scalar WHOLE instead of judging what it
622+
* would do, so the flag reaching a runner through an `env:` value, a `with:`
623+
* input or a matrix entry still reads as wiring. That width is the safe error
624+
* direction for an ABSENCE pin: a false red names a file and a line and is
625+
* fixed in a minute, while a false green is a gate that quietly stopped
626+
* guarding. It is also why this does NOT adopt check-shard-attestation's
627+
* stricter `invokesScript()` adjacency test. That test earns its keep there
628+
* because `--verify` and `--emit` are flags OTHER programs really take (`git
629+
* rev-parse --verify` is what #6589 misread); `--verify-required-set` is spelled
630+
* nowhere else in this tree, so adjacency would remove a false positive that
631+
* cannot occur while introducing false negatives that can — the flag passed
632+
* through a shell variable, or by a spelling of the invocation this file did not
633+
* think to enumerate.
634+
*
635+
* @param {unknown} doc a parsed workflow document
636+
* @returns {string} every scalar a runner acts on, joined, comments removed
637+
*/
638+
function liveWorkflowText(doc) {
639+
const parts = [];
640+
const visit = (node) => {
641+
if (typeof node === 'string') {
642+
parts.push(node);
643+
return;
644+
}
645+
if (Array.isArray(node)) {
646+
for (const item of node) visit(item);
647+
return;
648+
}
649+
if (!node || typeof node !== 'object') return;
650+
for (const [key, value] of Object.entries(node)) {
651+
// Two different keys are spelled `run`. A step's is a COMMAND string and
652+
// goes through the shell lexer; `defaults.run` is a mapping (`shell:`,
653+
// `working-directory:`) and is ordinary structure. The typeof test is
654+
// what tells them apart.
655+
if (key === 'run' && typeof value === 'string') parts.push(...shellCommands(value));
656+
else visit(value);
657+
}
658+
};
659+
visit(doc);
660+
return parts.join('\n');
661+
}
662+
663+
/**
664+
* Does this workflow WIRE UP the live required-set read?
665+
*
666+
* The question the two absence assertions and the caller sweep in `--self-test`
667+
* all ask. A mention in a comment — YAML or shell, leading or trailing — is
668+
* prose and answers `false`; a mention anywhere a runner would act on answers
669+
* `true`.
670+
*
671+
* @param {unknown} doc a parsed workflow document
672+
*/
673+
function wiresLiveRead(doc) {
674+
return liveWorkflowText(doc).includes(LIVE_READ_FLAG);
675+
}
676+
576677
/**
577678
* Judge a registry against already-parsed workflows.
578679
*
@@ -2132,10 +2233,9 @@ async function selfTest() {
21322233
// the two-step. Wiring it is a maintainer decision, and it goes red HERE
21332234
// first rather than silently in the queue.
21342235
{
2135-
const uncommentedYaml = (text) => text.split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
21362236
for (const [file, text] of Object.entries(sources)) {
21372237
assert(
2138-
!/--verify-required-set/.test(uncommentedYaml(text)),
2238+
!wiresLiveRead(parse(text)),
21392239
`wiring: ${file} must not RUN the live required-set read — report-only, off the required path (#9642)`,
21402240
);
21412241
}
@@ -2156,9 +2256,27 @@ async function selfTest() {
21562256
// carries: a second caller appearing in some third workflow is exactly the
21572257
// thing the absences above are guarding against, and they cannot see it.
21582258
const workflowDir = join(root, '.github', 'workflows');
2259+
// A document this sweep could not READ is not a document with nothing in
2260+
// it (#4690), and the recognizer now needs a parse to answer at all. So an
2261+
// unparseable workflow falls back to the widest possible reading — it can
2262+
// only ever OVER-report a caller, never hide one — and is named as its own
2263+
// failure rather than silently classified.
2264+
const unreadable = [];
21592265
const callers = readdirSync(workflowDir)
21602266
.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))
2161-
.filter((f) => /--verify-required-set/.test(uncommentedYaml(readFileSync(join(workflowDir, f), 'utf8'))));
2267+
.filter((f) => {
2268+
const text = readFileSync(join(workflowDir, f), 'utf8');
2269+
try {
2270+
return wiresLiveRead(parse(text));
2271+
} catch (error) {
2272+
unreadable.push(`${f}: ${error.message}`);
2273+
return text.includes(LIVE_READ_FLAG);
2274+
}
2275+
});
2276+
assert(
2277+
unreadable.length === 0,
2278+
`wiring: every .github/workflows document must PARSE before this sweep can classify it — ${JSON.stringify(unreadable)} did not (#4690)`,
2279+
);
21622280
assert(
21632281
callers.join(',') === PATROL_WORKFLOW,
21642282
`wiring: exactly one workflow runs the live read and it is ${PATROL_WORKFLOW} — found [${callers.join(', ')}] (#9678)`,
@@ -2169,21 +2287,32 @@ async function selfTest() {
21692287
// made this block throw mid-self-test, so the `callers` failure above was
21702288
// recorded and never printed and every later assertion never ran — a stack
21712289
// trace where the gate's own authored verdict belongs (#4690's family). The
2172-
// downstream cases each carry `patrolPresent` for the same reason: on an
2173-
// absent file `!/merge_group/` is vacuously true, which is a false green
2174-
// about a workflow that does not exist.
2290+
// downstream cases each carry `patrolReadable` for the same reason: on an
2291+
// absent or unparseable file "it declares no merge_group" is vacuously
2292+
// true, which is a false green about a workflow that does not exist.
21752293
const patrolPath = join(workflowDir, PATROL_WORKFLOW);
21762294
const patrolPresent = existsSync(patrolPath);
21772295
assert(patrolPresent, `wiring: the standing caller .github/workflows/${PATROL_WORKFLOW} is missing — the live mode is wired nowhere again (#9678)`);
2178-
const patrolYaml = patrolPresent ? uncommentedYaml(readFileSync(patrolPath, 'utf8')) : '';
2296+
let patrolDoc;
2297+
if (patrolPresent) {
2298+
try {
2299+
patrolDoc = parse(readFileSync(patrolPath, 'utf8'));
2300+
} catch {
2301+
// Named by the `unreadable` assertion above — the patrol lives in the
2302+
// swept directory — so this stays quiet and the cases below fail on
2303+
// `patrolReadable` instead of throwing mid-self-test.
2304+
patrolDoc = undefined;
2305+
}
2306+
}
2307+
const patrolReadable = patrolDoc !== undefined;
21792308
// The mechanical proxy for "never required". Assertion 6 of this pin is
21802309
// that every required context's workflow carries `merge_group:` — without
21812310
// one, the queue build never produces the context and the whole queue
21822311
// stalls waiting for it. A patrol that declares no merge_group trigger
21832312
// therefore CANNOT be validly required-ized, and required-izing it anyway
21842313
// wedges the queue rather than deadlocking a rename two-step quietly.
21852314
assert(
2186-
patrolPresent && !/^\s{0,4}merge_group\s*:/m.test(patrolYaml),
2315+
patrolReadable && triggersOf(patrolDoc) !== undefined && !Object.prototype.hasOwnProperty.call(triggersOf(patrolDoc), 'merge_group'),
21872316
`wiring: ${PATROL_WORKFLOW} must declare no merge_group trigger — a workflow without one deadlocks the queue if it is ever required-ized (#9678)`,
21882317
);
21892318
assert(
@@ -2198,9 +2327,170 @@ async function selfTest() {
21982327
// the same character. A rendering change costs the patrol a FALSE ALARM,
21992328
// never a false all-clear — it annotates on the mark's ABSENCE.
22002329
assert(
2201-
patrolPresent && patrolYaml.includes(REQUIRED_SET_CLEAN_MARK),
2330+
patrolReadable && liveWorkflowText(patrolDoc).includes(REQUIRED_SET_CLEAN_MARK),
22022331
`wiring: ${PATROL_WORKFLOW} must key its drift annotation on the clean mark ${REQUIRED_SET_CLEAN_MARK} this file renders (#9678)`,
22032332
);
2333+
2334+
// ── the recognizer itself, in BOTH directions ────────────────────────────
2335+
//
2336+
// The three assertions above are only as good as `wiresLiveRead`, and what
2337+
// it replaced was a `/^\s*#/` line filter that dropped a comment only when
2338+
// its FIRST non-space character was `#`. Two ordinary shapes walked through
2339+
// it and reddened `Lint & Repo Gates` on prose: a TRAILING comment on an
2340+
// otherwise-live line, and a trailing SHELL comment inside a `run:` block
2341+
// scalar. Nothing in the tree spelled either one, so the gate was green and
2342+
// the trigger was someone WRITING a warning about this very flag — which is
2343+
// what the neighbouring `ci.yml` block already does for
2344+
// check-shard-attestation's sibling flag. That script pins its own
2345+
// `--verify`-in-a-comment fixture for exactly this reason; this is the same
2346+
// pin for this flag.
2347+
//
2348+
// ⭐ Both limbs, always. A comment-stripper that also swallowed the GENUINE
2349+
// mention would be a strictly worse defect than the false positive it fixes
2350+
// — the live read wired into a required job, and this gate green about it —
2351+
// so every "prose" case below is paired with the same text made LIVE and
2352+
// asserted as wiring. The trailing-comment pair differs by the `#` alone.
2353+
//
2354+
// The fixtures are workflow SOURCE and `wired()` is the whole pipeline
2355+
// under test — parse, then lex each `run:`, then look for the flag. One
2356+
// seam, so the recognizer can be swapped for the old line filter under
2357+
// reverse verification without touching a single fixture.
2358+
const wired = (source) => wiresLiveRead(parse(source));
2359+
const workflowFixture = (...lines) => lines.join('\n');
2360+
const stepFixture = (...runLines) =>
2361+
workflowFixture(
2362+
'name: fixture',
2363+
'on:',
2364+
' push: {}',
2365+
'jobs:',
2366+
' j:',
2367+
' runs-on: ubuntu-latest',
2368+
' steps:',
2369+
' - name: step',
2370+
' run: |',
2371+
...runLines.map((line) => ` ${line}`),
2372+
);
2373+
2374+
// (a) the reported defect: a trailing YAML comment on a live line.
2375+
assert(
2376+
!wired(
2377+
workflowFixture(
2378+
'name: fixture',
2379+
'on:',
2380+
' push: {}',
2381+
'jobs:',
2382+
' j:',
2383+
' runs-on: ubuntu-latest',
2384+
' steps:',
2385+
` - name: step # never wire ${LIVE_READ_FLAG} into a required job`,
2386+
' run: echo hi',
2387+
),
2388+
),
2389+
`recognizer: a TRAILING YAML comment naming ${LIVE_READ_FLAG} is prose, not wiring — the line filter this replaced kept the whole line`,
2390+
);
2391+
// (b) the one shape the line filter did get right, pinned so it stays right.
2392+
assert(
2393+
!wired(
2394+
workflowFixture(
2395+
'name: fixture',
2396+
`# ${LIVE_READ_FLAG} is report-only and must stay off the required path.`,
2397+
'on:',
2398+
' push: {}',
2399+
'jobs:',
2400+
' j:',
2401+
' runs-on: ubuntu-latest',
2402+
' steps:',
2403+
' - name: step',
2404+
' run: echo hi',
2405+
),
2406+
),
2407+
'recognizer: a whole-line YAML comment is prose',
2408+
);
2409+
// (c)/(c′) the trailing SHELL comment inside a `run:` block scalar, and the
2410+
// same command with the `#` removed. One variable between them.
2411+
assert(!wired(stepFixture(`ls # ${LIVE_READ_FLAG}`)), 'recognizer: a TRAILING shell comment inside a run: block scalar is prose');
2412+
assert(
2413+
wired(stepFixture(`ls ${LIVE_READ_FLAG}`)),
2414+
'recognizer: the SAME command without the `#` is wiring — the pair differs by the comment marker alone',
2415+
);
2416+
// (d) documenting the invocation inside a `run:` — the #4890 shape.
2417+
assert(
2418+
!wired(stepFixture(`# node scripts/check-required-contexts.mjs ${LIVE_READ_FLAG} would wire this job up`, 'echo documented')),
2419+
'recognizer: DOCUMENTING the invocation inside a run: block is not making one (the #4890 shape)',
2420+
);
2421+
// (e) the genuine article, spelled the way the patrol spells it: the flag
2422+
// before a backslash continuation, inside a multi-command block.
2423+
assert(
2424+
wired(stepFixture('set +e', `node scripts/check-required-contexts.mjs ${LIVE_READ_FLAG} \\`, ' > "$RUNNER_TEMP/required-set.md"')),
2425+
'recognizer: the patrol’s own spelling — the flag ahead of a backslash continuation — is wiring',
2426+
);
2427+
// (f) a quoted `#` is an ARGUMENT. Truncating a command there would hide
2428+
// real wiring behind a plausible-looking argument, which is the one outcome
2429+
// worse than the false positive this whole block is about.
2430+
assert(
2431+
wired(stepFixture(`node scripts/check-required-contexts.mjs --label 'a # b' ${LIVE_READ_FLAG}`)),
2432+
'recognizer: a QUOTED `#` inside a run: command is an argument, not a comment',
2433+
);
2434+
// (g) the same property one grammar up: `#` inside a quoted YAML scalar
2435+
// does not open a YAML comment either.
2436+
assert(
2437+
wired(
2438+
workflowFixture(
2439+
'name: fixture',
2440+
'on:',
2441+
' push: {}',
2442+
'jobs:',
2443+
' j:',
2444+
' runs-on: ubuntu-latest',
2445+
' steps:',
2446+
` - name: "a # ${LIVE_READ_FLAG}"`,
2447+
' run: echo hi',
2448+
),
2449+
),
2450+
'recognizer: a `#` inside a QUOTED YAML scalar does not open a comment — the mention is live text',
2451+
);
2452+
// (h) the width outside `run:`, stated as a pin rather than left to the
2453+
// docblock: a flag reaching the runner through an `env:` value is wiring,
2454+
// even though no single command spells it.
2455+
assert(
2456+
wired(
2457+
workflowFixture(
2458+
'name: fixture',
2459+
'on:',
2460+
' push: {}',
2461+
'jobs:',
2462+
' j:',
2463+
' runs-on: ubuntu-latest',
2464+
' env:',
2465+
` FLAG: ${LIVE_READ_FLAG}`,
2466+
' steps:',
2467+
' - name: step',
2468+
' run: node scripts/check-required-contexts.mjs $FLAG',
2469+
),
2470+
),
2471+
'recognizer: the flag reaching a runner through an env: value is wiring — outside a run: block the scalar is kept whole',
2472+
);
2473+
// (i) `defaults.run` is a MAPPING, not a command. Handing it to the shell
2474+
// lexer would drop its keys and could only ever narrow the reading.
2475+
assert(
2476+
wired(
2477+
workflowFixture(
2478+
'name: fixture',
2479+
'on:',
2480+
' push: {}',
2481+
'jobs:',
2482+
' j:',
2483+
' runs-on: ubuntu-latest',
2484+
' defaults:',
2485+
' run:',
2486+
` working-directory: ${LIVE_READ_FLAG}`,
2487+
' steps:',
2488+
' - name: step',
2489+
' run: echo hi',
2490+
),
2491+
),
2492+
'recognizer: `defaults.run` is structure, not a command — its scalars are still read',
2493+
);
22042494
}
22052495

22062496
{
@@ -2263,7 +2553,8 @@ async function selfTest() {
22632553
`✓ check-required-contexts --self-test: ${checked} assertions ` +
22642554
`(rename ablations across both workflows + matrix/continue-on-error/trigger shapes + the shard-name collision + ` +
22652555
`the \`carries\` step-count ban + the instruction-surface stale-name scan (#9491) + ` +
2266-
`the live required-set diff and its off-the-required-path wiring (#9642) + the #4690 pins).`,
2556+
`the live required-set diff and its off-the-required-path wiring (#9642), with the comment-vs-code recognizer ` +
2557+
`pinned in both directions + the #4690 pins).`,
22672558
);
22682559
}
22692560

0 commit comments

Comments
 (0)