@@ -181,6 +181,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync,
181181import { tmpdir } from 'node:os' ;
182182import { dirname , join , resolve } from 'node:path' ;
183183import { fileURLToPath } from 'node:url' ;
184+ import { shellCommands } from './check-shard-attestation.mjs' ;
184185import { 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- ! / - - v e r i f y - r e q u i r e d - s e t / . 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 ) => / - - v e r i f y - r e q u i r e d - s e t / . 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 } m e r g e _ g r o u p \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