#!/usr/bin/env node 'use strict'; /** * Prompt-layer drift guard for #3409 — shell guards that cannot observe * their own failure arm. * * Design: .gsd/phase/feat-3409-unreachable-shell-guard-lint/40-design.md * Test matrix: .gsd/phase/feat-3409-unreachable-shell-guard-lint/50-test-matrix.md * * `gsd-tools.cjs`'s `--pick ` extractor coerces a missing/absent * field to the empty string and exits **0** (probe-confirmed in * 40-design.md). So in `$(gsd_run query V --pick F 2>/dev/null || echo D)` * the `|| echo D` arm can fire only on a typo in the verb name — never on * the field absence it was written to handle. Three shipped shell guards * silently relied on that unreachable arm (#3365's Walking Skeleton gate, * `PHASE_REQ_IDS`, and `complete-milestone.md`'s bare `cat `, all * fixed alongside this guard — see `tests/unreachable-shell-guard.test.cjs`, * which this file does not touch). * * TWO detectors, each narrow by design (mirroring the sibling drift guards' * precedent of a small, specific shape rather than a wide heuristic): * * Detector A — a line carrying BOTH a `--pick` token AND a `|| echo` * fallback. `--pick` is the discriminator: a `|| echo` default WITHOUT * `--pick` (e.g. `config-get k 2>/dev/null || echo "default"`, ~132 of the * 141 `$(… || echo …)` lines in the prompt layer) genuinely observes a * nonzero exit code and is left alone — see 40-design.md's Rejected #2 * ("detect on `gsd_run` + `|| echo`" was tried and reverted for exactly * this false-positive volume). * * Detector B — `cat` or `ls` invoked in COMMAND POSITION with an operand * containing an unquoted glob metacharacter (`*` or `?`). Both detectors * are, at bottom, the SAME shape: a fallback/guard arm that a * success-on-empty case silently defeats. Detector A is `--pick … || * echo`; Detector B-ii below is `ls … || echo` — the identical * defect, one level down the stack, with `ls`'s own nullglob-driven * success-on-empty standing in for `--pick`'s absence-coerced-to-''. * SCOPED to exactly three fired shapes, per a full measurement across the * four SCAN_DIRS (measured counts recorded in the PR description; 0 sites * for B-iii today, by design — see KNOWN LIMITS): * * B-i. `cat ` fires UNCONDITIONALLY. This is the stdin-hang * shape (measured rc=137 at 3s under an unmatched glob + * nullglob): `cat` reads from stdin the moment it gets zero * operands, regardless of what — if anything — consumes its own * exit code. There is no fallback arm to inspect; the hang * happens before one could run. * B-ii. `ls ` fires when its exit code feeds a REAL fallback: * `… || ` where `` is not the no-op `true`/`:`. Under * nullglob `ls` SUCCEEDS listing the cwd on an unmatched glob, * so the fallback never runs and the intended message/default is * silently replaced by a directory listing — exactly Detector * A's shape, with `ls`'s exit code standing in for `--pick`'s * stdout. * B-iii. `ls ` fires at the head of an `if`/`elif`/`while` test — * the #3300 "existence guard that is always true under * nullglob" shape the issue names directly: an unmatched glob * makes `ls` list the CWD instead of erroring, so the guard is * always true. Zero sites today (the #3300 fix already removed * them); this arm exists solely so a REINTRODUCED instance of * the shape does not ship silently. * * NOT fired on: * - `ls … || true` / `… || :` — suppressing a failure is not a * guard, and there is no fallback VALUE being defeated (the whole * point of `true`/`:` is "do nothing, either way"). Measured: ~15 * sites in this tree, all this exact defensive idiom. * - an `ls ` whose STDOUT is what's consumed (`ls foo/*.md * 2>/dev/null`, `X=$(ls -d …)`, `ls … | head`) — neither the stdin * hang nor a defeated fallback nor an always-true guard. Measured: 97 * sites, explicitly out of this issue's scope ("Explicit non-goal: * … Only the shapes above move."). * - markdown prose describing either command, INCLUDING the specific * shape `` `Bash(cat << 'EOF')` `` (a heredoc operator immediately * after the command name is never a glob operand — see the heredoc * guard below, and matrix row B8). * * `|| echo ` vs `|| true`/`|| :` is the discriminator for B-ii, * exactly as `--pick` is Detector A's: both distinguish "a fallback VALUE * this shape can silently defeat" from "no fallback value exists to * defeat, so there is nothing here for nullglob's success-on-empty to * break." * * Conservative BY CONSTRUCTION where it still applies (40-design.md's * B10/B11 and "Law of Leaky Abstractions" section): whether a `nullglob` * is in effect is not locally decidable from the line alone, so `cat` * still fires unconditionally (B-i) and `ls`'s two exit-code-consuming * shapes (B-ii, B-iii) still fire regardless of whether a guard already * exists nearby. The remedy (an array expansion, or an existence test * before the read) is correct either way, and array expansions carry no * `*`/`?` character at all so they are never flagged — the detector does * not punish its own fix. * * Regexes are small, bounded, and non-backtracking BY CONSTRUCTION — * `npm run lint:ci` runs CodeQL js/redos over this repo, the same * discipline `lint-planning-prompt-drift.cjs` documents in its own header: * * - PICK_RE / ECHO_FALLBACK_RE carry only a single bounded `\s*` * quantifier each, over a fixed literal — no nesting, nothing to * backtrack. * - CAT_LS_COMMAND_RE's alternation is a FIXED, non-overlapping set (a * handful of literal command-position anchors, then a fixed * `(cat|ls)`), with one `[ \t]*` quantifier between the anchor and the * command name — again no nesting. * - HEREDOC_AFTER_COMMAND_RE and FALLBACK_TOKEN_RE are each a single * bounded quantifier over a fixed/negated class, same shape as above. * - The B-ii/B-iii "does this clause carry a glob, and what terminates * it" question is answered by `scanClauseAfterCommand`, a plain * LINEAR, single left-to-right character walk — not a regex at all, and * therefore not a ReDoS surface by construction rather than by * argument: it inspects each character of the remainder exactly once * and returns at the first clause-terminating token it finds. * - MARKER_RE (the escape-marker parser) is two more `\s*` quantifiers * over fixed literals, then a single trailing `(.*)$` — again one * quantifier, no nesting. * * ESCAPE MARKER. A line carrying `# gsd-scan-ignore: ` is exempt * ONLY when `` names an issue (`#NNN`, N a positive integer) or an * `http(s)://` URL with an actual host after the scheme — the repo's * existing precedent from `tests/commit-files-pathspec.test.cjs` * (CONTRIBUTING.md, "Every `commit` invocation in shipped content must * declare `--files`"), STARTING from that precedent's predicate * (`/#\d+|https?:\/\//`) but DELIBERATELY DIVERGING from it (see * `ISSUE_REF_RE`'s own comment for exactly what changed and why) rather than * copying it verbatim. The sibling file still carries the looser, unpatched * form — this guard's escape hatch is a stricter gate than a commit-message * pathspec check needs to be, since an accepted reason here silently * exempts a real violation from ever being reported. A marker whose reason is free text, empty, or * whitespace-only is reported as a DISTINCT "malformed declaration" error — * never silently exempted (that would defeat the guard) and never folded * into the ordinary violation list (that would tell an author who already * explained themselves that they hadn't, the exact mangle-until-CI-shuts-up * loop the marker exists to prevent). Simplification versus the sibling * predicate this mirrors: that guard's marker parser tokenizes the whole * line to rule out a marker surviving inside quoted argv text (a commit * MESSAGE quoting the token). This guard's two detectors never process * commit-message-shaped free text, so a plain `#\s*gsd-scan-ignore:` literal * match is sufficient here and is not widened to match that guard's * quote-awareness it has no corresponding hazard for. * * RATCHET, not an allowlist. `scripts/baselines/unreachable-guard-drift-baseline.json` * mirrors `lint-planning-prompt-drift.cjs`'s shrink-only, count-aware * baseline exactly (see that module's header for the full "COUNT, not * duplicate rows" rationale) — a recorded `(file, text)` pair acknowledges * `count` byte-identical occurrences; fewer this run is a PARTIAL migration * (stale), more is an unacknowledged new copy (fresh), zero is a fully * migrated pair (stale). Matched on `(file, TRIMMED text)`, never the line * number, for the same reason: a workflow `.md` file's line numbers churn on * every unrelated edit. Malformed declarations are NEVER ratchet-eligible — * they are an authoring mistake in the escape hatch itself, not a * migration-in-progress, and always hard-fail (40-design.md's Goodhart's Law * section names "run `--update` and record the violation as acknowledged * instead of fixing it" as the ratchet's own cheapest gaming path; a * malformed marker is exactly the shape of a half-hearted attempt at that, * and it is refused rather than laundered into the baseline). * * SHARED TREE-WALK. `scanTree` / `sanitizeForReport` are consumed from * `scripts/lib/drift-scan.cjs`, NOT reimplemented — ADR-3180 Decision 4 * explicitly rejected "let the new drift guard copy Phase 1's tree-walk", * and 40-design.md's Greenspun's Tenth Rule section states the binding * consequence plainly: "the 46th guard MUST consume `drift-scan.cjs`, not * copy it." See that module for the `toPosixRel`-equivalent rationale * (below), the symlink-confinement contract, and the ReDoS-avoidance * rationale for its own regex-literal reader (unused by this guard's * regexes, which need no literal tokenizer — shared here only for the walk * and the report sanitizer). * * Surfaces scanned (SCAN_DIRS): `gsd-core/workflows`, `commands`, `agents`, * `skills` — the prompt-layer markdown that ships to every runtime. * SCAN_EXT: `.md` only. * * KNOWN, ACCEPTED limits (same tradeoffs the sibling guards document): * - A cross-line split defeats Detector A (`--pick` on one line, `|| echo` * on the next) — left to code review, per-line textual scan only. * - `|| printf` and other non-`echo` fallbacks are not detected by * Detector A — narrow by design; widening is a one-line change if a * site ever appears. * - Detector B's command-position anchor set (line start; `;`, `&`, `|`, * `(`; the keywords `if`/`then`/`elif`/`while`/`do`) is what lets * `$(cat …)` / `$(ls …)` — the dominant real invocation idiom in this * tree — reach the glob check through the `(` anchor. The heredoc guard * (`HEREDOC_AFTER_COMMAND_RE`) is what keeps that same `(` anchor from * flagging the specific markdown prose shape `` `Bash(cat << 'EOF')` `` * — measured against the real tree, it eliminates every such occurrence * (a heredoc operator immediately after the command name is, by * definition, never a glob operand). A prose sentence that put a real * `*`/`?`-bearing word directly after `cat`/`ls` with NO heredoc * operator between them (unobserved in this tree) would still be a * residual over-flag in the same conservative-by-construction spirit as * B10/B11 — accepted for the same reason: removing the `(` anchor * entirely would blind the guard to most of the real `$(cat …)` sites * it exists to catch, the strictly worse direction (silent false * negative vs. a visible, ratchet-acknowledgeable false positive). * - B-iii's `if`/`elif`/`while` head-position check is per-token, not a * full parse of the conditional's grammar: `if [ -f x ] && ls * glob; then` (a compound condition where `ls` is not literally the * first word after `if`) is not reachable through the keyword anchor * and falls through to B-ii's `||`-fallback check instead, which is the * right outcome only when a `||`-fallback is present that isn't a * no-op. A compound `if` condition ending the `ls` clause with `;`/end * of line and no `||` arm is a genuine, unmeasured (zero observed) * miss — left to code review, matching the design's stated per-line * textual-scan tradeoff throughout. * - The `|| true` / `|| :` no-op carve-out (FALLBACK_TOKEN_RE) inspects * only the FIRST token after `||`; a real fallback dressed up as `|| * (true; echo "surprise")` would read as the no-op and miss — no such * shape exists in this tree today (measured), and widening the * no-op-detection is a one-line change if one ever appears. */ const fs = require('node:fs'); const path = require('node:path'); const driftScan = require('./lib/drift-scan.cjs'); const { sanitizeForReport, scanTree } = driftScan; // ─── Detector A — `--pick` + `|| echo` ──────────────────────────────────── // // `--pick` is the discriminator (see module header); `|| echo` is the // unreachable fallback arm it silently defeats. Both must be present on the // SAME line for the shape to be the exact unreachable-arm defect #3409 // fixes — see the module header for why a bare "`gsd_run` + `|| echo`" rule // was tried and reverted (Rejected #2). const PICK_RE = /--pick\b/; const ECHO_FALLBACK_RE = /\|\|\s*echo\b/; // ─── Detector B — cat (B-i), ls … || (B-ii), // or ls at the head of if/elif/while (B-iii) ──────────────────────── // // Command-position anchor: start of line, a shell separator/opener // (`;`, `&`, `|`, `(`), or one of the keywords that precede a command // (`if`, `then`, `elif`, `while`, `do`) — each followed by optional // horizontal whitespace and then the literal command name. Group 1 captures // WHICH anchor matched (`''` for start-of-line, since `^` itself consumes no // characters; the literal separator char; or the literal keyword) so // detectGlobOperand can tell a true `if`/`elif`/`while` head position (B-iii) // apart from `then`/`do`/a bare separator, which do not themselves test the // following command's exit status. Group 2 captures the command name. A // FIXED alternation with one `[ \t]*` quantifier between the anchor and the // command name; no nesting, nothing to backtrack. const CAT_LS_COMMAND_RE = /(^|[;&|(]|\bif\b|\bthen\b|\belif\b|\bwhile\b|\bdo\b)[ \t]*(cat|ls)\b/; // A heredoc operator immediately after the command name (optional // horizontal whitespace, then `<<`) is never a glob operand — matrix row B8, // and the mechanism that keeps the markdown-prose shape `` `Bash(cat << // 'EOF')` `` (whose surrounding `**bold**` carries literal `*` characters // elsewhere on the line) from ever reaching the glob scan at all. Single // bounded quantifier, no nesting. const HEREDOC_AFTER_COMMAND_RE = /^[ \t]*< the clause ends with no chain at all. * `&&` -> a short-circuit "glob matched, so proceed" chain. * `||` -> a short-circuit fallback chain; `fallback` is * everything after the `||` (for isNoopFallback to * classify). * a lone `|` -> the clause's STDOUT is piped onward (informational, * never a hazard shape this detector fires on). * a lone `&` -> backgrounded; not a chain this detector recognizes. * end of string -> no chain of any kind. * `hasGlob` is tracked across the WHOLE walk regardless of where the scan * stops, since a `*`/`?` can appear anywhere in the operand region before * the terminator. */ function scanClauseAfterCommand(rest) { let hasGlob = false; for (let i = 0; i < rest.length; i++) { const ch = rest[i]; if (ch === '*' || ch === '?') { hasGlob = true; continue; } if (ch === ';') return { hasGlob, terminator: ';', fallback: null }; if (ch === '&' && rest[i + 1] === '&') return { hasGlob, terminator: '&&', fallback: null }; if (ch === '|' && rest[i + 1] === '|') return { hasGlob, terminator: '||', fallback: rest.slice(i + 2) }; if (ch === '|') return { hasGlob, terminator: '|', fallback: null }; if (ch === '&') return { hasGlob, terminator: '&', fallback: null }; } return { hasGlob, terminator: null, fallback: null }; } /** * Pure: does `line` carry one of Detector B's three fired shapes? Returns * `{ command }` (`cat` or `ls`) or `null`. See the module header for the * B-i/B-ii/B-iii scoping and what deliberately does NOT fire. */ function detectGlobOperand(line) { const anchor = CAT_LS_COMMAND_RE.exec(line); if (!anchor) return null; const anchorToken = anchor[1]; const command = anchor[2]; const rest = line.slice(anchor.index + anchor[0].length); if (HEREDOC_AFTER_COMMAND_RE.test(rest)) return null; const { hasGlob, terminator, fallback } = scanClauseAfterCommand(rest); if (!hasGlob) return null; if (command === 'cat') return { command }; // B-i: unconditional. // command === 'ls': B-iii (head of a real conditional test) or B-ii (a // real, non-no-op `||` fallback). Neither a lone `|` (stdout piped // onward) nor `|| true`/`|| :` nor a bare `;`/end-of-line qualifies. if (EXIT_TESTING_KEYWORDS.has(anchorToken)) return { command }; if (terminator === '||' && fallback !== null && !isNoopFallback(fallback)) return { command }; return null; } // ─── Escape marker ───────────────────────────────────────────────────────── // // `# gsd-scan-ignore: `. Two `\s*` quantifiers over fixed literals, // then a single trailing `(.*)$` — one quantifier, no nesting. Lines are // split via `/\r?\n/` (see findUnreachableGuardDrift) before this ever runs, // so `.` never has to reason about a trailing `\r` — the pitfall the CRLF // coverage in the test matrix (P1-P4) exists to catch. const MARKER_RE = /#\s*gsd-scan-ignore:\s*(.*)$/; // DELIBERATE DIVERGENCE from tests/commit-files-pathspec.test.cjs's own // `ISSUE_REF_RE` (`/#\d+|https?:\/\//`), which this predicate started as a // copy of. That sibling form validates FORMAT only, and two shapes satisfy // it while naming nothing real: // - `#0` matches `#\d+` (`\d+` allows a leading zero / an all-zero run), // silently exempting a violation under a reason that names no positive // issue number. // - a bare `http://` / `https://` matches `https?:\/\/` with nothing // after the scheme — no host, so no URL is actually named. // Both are closed here: an issue ref requires a POSITIVE integer // (`#[1-9]\d*` — no leading-zero/all-zero match), and a URL requires at // least one non-whitespace character after the scheme as its host // (`https?:\/\/[^\s]+`). This guard's escape hatch is a stricter gate than // the sibling's commit-message pathspec check needs to be — an accepted // reason here silently exempts a real violation from ever being reported — // so the sibling is intentionally left at its own, looser form (not edited // by this change) rather than tightened to match. // Still one bounded quantifier per alternative, no nesting: `\d*` over a // fixed digit class, `[^\s]+` over a fixed negated class. Non-backtracking, // same as every other regex in this module (see the module header's ReDoS // section). const ISSUE_REF_RE = /#[1-9]\d*|https?:\/\/[^\s]+/; // `scanTree` (scripts/lib/drift-scan.cjs) builds its repo-relative path via // `path.relative()`, which uses NATIVE separators: on Windows that is // `gsd-core\workflows\plan-phase.md`, while the committed baseline stores // POSIX paths. Normalized UNCONDITIONALLY — never gated on // `process.platform` — for the exact reason `lint-planning-prompt-drift.cjs` // documents at its own `toPosixRel`: a platform-conditional normalizer is // itself the bug, since it makes the POSIX path the only tested case // (PR #3223). function toPosixRel(relPath) { return relPath.replace(/\\/g, '/'); } // Prompt-layer markdown that ships to every runtime. const SCAN_DIRS = ['gsd-core/workflows', 'commands', 'agents', 'skills']; const SCAN_EXT = new Set(['.md']); const BASELINE_REL_PATH = path.join('scripts', 'baselines', 'unreachable-guard-drift-baseline.json'); // The tracking issue this guard's own baseline entries are owned by, absent // a more specific site owner named at `--update` time. #3409 is this // guard's own issue: any site it finds that this PR does not convert is a // "one careless line from the same class" per 40-design.md's Postel's Law // section, tracked here until the upstream `--pick` contract fix (#3473) // or a per-site conversion lands. const RATCHET_OWNER_ISSUE = '#3409'; /** * Pure: scan `text` (one file's content) for Detector A / Detector B * violations and malformed escape-marker declarations. `relPath` is the * repo-relative path (native separators or POSIX, either accepted) — * normalized via `toPosixRel` and attached as `file` on every result. * * Returns `{ violations, malformed }`: * - `violations`: `[{ file, line, kind: 'A'|'B', found, text }]` — `text` * is the TRIMMED source line (the baseline key), `found` names the * discriminating token (`--pick` for A, `cat`/`ls` for B). * - `malformed`: `[{ file, line, text, reason }]` — an ATTEMPTED * `# gsd-scan-ignore:` declaration whose reason names no issue and no * URL. Checked on EVERY line independent of whether that line also * matches a detector (a comment-only malformed declaration is still a * malformed declaration) — never ratchet-eligible. * * Lines are split on `/\r?\n/` so CRLF input carries no trailing `\r` into * either the detector regexes or the baseline key (`text.trim()` would * catch most of this anyway, per `String.prototype.trim`'s LineTerminator * handling, but MARKER_RE's trailing `(.*)$` specifically needs the split * to have already happened — `.` excludes `\r` from its own match). */ function findUnreachableGuardDrift(text, relPath) { const file = toPosixRel(relPath); const violations = []; const malformed = []; const lines = text.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const line = lines[i]; const lineNo = i + 1; let exempt = false; const markerMatch = MARKER_RE.exec(line); if (markerMatch) { const reason = markerMatch[1]; if (ISSUE_REF_RE.test(reason)) { exempt = true; } else { malformed.push({ file, line: lineNo, text: line.trim(), reason: reason.trim() }); exempt = true; // malformed declarations are reported on their own terms, never as a plain violation too (design A14 / matrix M3-M5) } } if (exempt) continue; if (PICK_RE.test(line) && ECHO_FALLBACK_RE.test(line)) { violations.push({ file, line: lineNo, kind: 'A', found: '--pick', text: line.trim() }); } const globInfo = detectGlobOperand(line); if (globInfo) { violations.push({ file, line: lineNo, kind: 'B', found: globInfo.command, text: line.trim() }); } } return { violations, malformed }; } /** * Scan the prompt-layer markdown tree and return every violation and * malformed declaration, each annotated with the repo-relative file path * (POSIX-normalized — see `toPosixRel`). */ function scanRepo(root) { const violations = []; const malformed = []; scanTree({ root, scanDirs: SCAN_DIRS, scanExt: SCAN_EXT, onFile(rel, text) { const found = findUnreachableGuardDrift(text, rel); violations.push(...found.violations); malformed.push(...found.malformed); return []; // scanTree's own accumulator is unused; we track both lists ourselves so its single flat list is never asked to carry two shapes. }, }); return { violations, malformed }; } /** * Frozen outcome-reason enum. CONTRIBUTING.md's "Prohibited: Raw Text * Matching on Test Outputs" requires a typed structured surface wherever * this module produces human-readable text — mirrors * `gsd-core/bin/verify-reapply-patches.cjs`'s own `REASON` map exactly: * `main()`'s `--json` mode and every `loadBaseline` per-error object carry * one of these codes instead of free prose, and tests assert on the code, * never on the rendered message. Adding a new reason requires updating this * enum, the `--json` emission/`loadBaseline` call site that produces it, AND * the test that locks `Object.keys(REASON).sort()` — three coordinated * changes that keep the code surface from drifting from the test surface. */ const REASON = Object.freeze({ // main() top-level outcomes (non---update and --update paths). OK_NO_VIOLATIONS: 'ok_no_violations', OK_BASELINE_UPDATED: 'ok_baseline_updated', FAIL_FRESH_VIOLATION: 'fail_fresh_violation', FAIL_STALE_ENTRY: 'fail_stale_entry', FAIL_MALFORMED_MARKER: 'fail_malformed_marker', FAIL_BASELINE_LOAD: 'fail_baseline_load', // loadBaseline per-error outcomes — each a distinct baseline-load failure // class (mirrors lint-planning-prompt-drift.cjs's loadBaseline validation). FAIL_BASELINE_MISSING: 'fail_baseline_missing', FAIL_BASELINE_EMPTY: 'fail_baseline_empty', FAIL_BASELINE_INVALID_JSON: 'fail_baseline_invalid_json', FAIL_BASELINE_NOT_OBJECT: 'fail_baseline_not_object', FAIL_BASELINE_ENTRIES_NOT_ARRAY: 'fail_baseline_entries_not_array', FAIL_BASELINE_ENTRY_NOT_OBJECT: 'fail_baseline_entry_not_object', FAIL_BASELINE_ENTRY_FIELD_INVALID: 'fail_baseline_entry_field_invalid', FAIL_BASELINE_ENTRY_COUNT_INVALID: 'fail_baseline_entry_count_invalid', }); /** * Read and parse the ratchet baseline. Returns `{ entries, errors }` — * `entries` is `[]` and `errors` is an array of STRUCTURED error objects * (`{ reason: REASON.*, message, ... }`) when the file is missing, empty, * invalid JSON, or malformed. Mirrors `lint-planning-prompt-drift.cjs`'s * `loadBaseline` validation exactly (same failure classes: missing, empty, * invalid JSON, non-object JSON — including the `null`/array/scalar cases a * bare `typeof === 'object'` check would miss — a non-array `entries` * field, and per-entry validation of `file`/`text`/`count`). `message` is a * human-readable string for the console formatter only; callers (and * tests) must key off `reason`, never parse `message`. */ function loadBaseline(root) { const baselinePath = path.join(root, BASELINE_REL_PATH); if (!fs.existsSync(baselinePath)) { return { entries: [], errors: [{ reason: REASON.FAIL_BASELINE_MISSING, message: `${BASELINE_REL_PATH} is missing — run \`node scripts/lint-unreachable-guard-drift.cjs --update\` to generate it`, }], }; } const raw = fs.readFileSync(baselinePath, 'utf8'); if (raw.trim() === '') { return { entries: [], errors: [{ reason: REASON.FAIL_BASELINE_EMPTY, message: `${BASELINE_REL_PATH} is present but empty` }], }; } let doc; try { doc = JSON.parse(raw); } catch (err) { return { entries: [], errors: [{ reason: REASON.FAIL_BASELINE_INVALID_JSON, message: `${BASELINE_REL_PATH} is not valid JSON: ${err.message}`, parseError: err.message, }], }; } if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) { const gotType = Array.isArray(doc) ? 'array' : typeof doc; return { entries: [], errors: [{ reason: REASON.FAIL_BASELINE_NOT_OBJECT, message: `${BASELINE_REL_PATH} must be a JSON object, got ${gotType}`, gotType, }], }; } if (!Array.isArray(doc.entries)) { return { entries: [], errors: [{ reason: REASON.FAIL_BASELINE_ENTRIES_NOT_ARRAY, message: `${BASELINE_REL_PATH}: "entries" must be an array, got ${JSON.stringify(doc.entries)}`, entriesValue: doc.entries, }], }; } const errors = []; const entries = []; doc.entries.forEach((entry, i) => { const where = `${BASELINE_REL_PATH}.entries[${i}]`; if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { errors.push({ reason: REASON.FAIL_BASELINE_ENTRY_NOT_OBJECT, message: `${where} must be an object, got ${JSON.stringify(entry)}`, index: i, where, }); return; } if (typeof entry.file !== 'string' || entry.file === '') { errors.push({ reason: REASON.FAIL_BASELINE_ENTRY_FIELD_INVALID, message: `${where}.file must be a non-empty string, got ${JSON.stringify(entry.file)}`, index: i, where, field: 'file', value: entry.file, }); return; } if (typeof entry.text !== 'string' || entry.text === '') { errors.push({ reason: REASON.FAIL_BASELINE_ENTRY_FIELD_INVALID, message: `${where}.text must be a non-empty string, got ${JSON.stringify(entry.text)}`, index: i, where, field: 'text', value: entry.text, }); return; } // `count` is optional on read (diffAgainstBaseline defaults an absent // count to 1) but when present must be a positive integer. if (entry.count !== undefined && !(Number.isInteger(entry.count) && entry.count >= 1)) { errors.push({ reason: REASON.FAIL_BASELINE_ENTRY_COUNT_INVALID, message: `${where}.count must be a positive integer when present, got ${JSON.stringify(entry.count)}`, index: i, where, value: entry.count, }); return; } entries.push(entry); }); return { entries, errors }; } /** * Diff scanned `violations` against baseline `entries`, matched by the pair * (`file`, TRIMMED `text`) — never the line number — and COUNT-aware, same * semantics as `lint-planning-prompt-drift.cjs`'s `diffAgainstBaseline`: * - `fresh`: violations whose `(file, text)` pair is not in the baseline * at all, PLUS any occurrences of a KNOWN pair beyond its acknowledged * `count`. * - `stale`: baseline entries whose actual occurrence count this run is * LESS than their acknowledged `count` (zero is the fully-migrated * case; a positive-but-short count is a PARTIAL migration). */ function diffAgainstBaseline(violations, baseline) { const key = (file, text) => `${file} ${text}`; const actualByKey = new Map(); for (const v of violations) { const k = key(v.file, v.text); let vs = actualByKey.get(k); if (!vs) { vs = []; actualByKey.set(k, vs); } vs.push(v); } const knownKeys = new Set(baseline.map((e) => key(e.file, e.text))); const fresh = []; const stale = []; for (const [k, vs] of actualByKey) { if (!knownKeys.has(k)) fresh.push(...vs); } for (const entry of baseline) { const k = key(entry.file, entry.text); const expected = entry.count ?? 1; const vs = actualByKey.get(k) || []; const actual = vs.length; if (actual < expected) { stale.push({ ...entry, count: expected, actualCount: actual }); } else if (actual > expected) { fresh.push(...vs.slice(expected)); } } return { fresh, stale }; } /** Stable sort: by `file`, then by `text`. */ function sortEntries(entries) { return [...entries].sort((a, b) => { if (a.file !== b.file) return a.file < b.file ? -1 : 1; if (a.text !== b.text) return a.text < b.text ? -1 : 1; return 0; }); } /** * Collapse `violations` into one baseline row per distinct (file, text) * pair, carrying a `count` of how many occurrences that pair has in THIS * run. Pure; no I/O. */ function dedupeViolationsForBaseline(violations) { const order = []; const byKey = new Map(); for (const v of violations) { const k = `${v.file} ${v.text}`; let entry = byKey.get(k); if (!entry) { entry = { file: v.file, text: v.text, kind: v.kind, owner_issue: RATCHET_OWNER_ISSUE, count: 0 }; byKey.set(k, entry); order.push(entry); } entry.count += 1; } return order; } function writeBaseline(root, violations) { const entries = sortEntries(dedupeViolationsForBaseline(violations)); const doc = { $comment: '#3409 unreachable-shell-guard ratchet. See scripts/lint-unreachable-guard-drift.cjs. ' + 'SHRINK-ONLY: entries are removed as sites migrate off the unreachable-arm shape; new or ' + 'changed entries fail lint:ci. `count` is the number of byte-identical (file, text) ' + 'occurrences acknowledged at this site — a run producing fewer fails as a partial migration, ' + 'more fails as an unacknowledged new copy.', entries, }; const baselinePath = path.join(root, BASELINE_REL_PATH); fs.mkdirSync(path.dirname(baselinePath), { recursive: true }); fs.writeFileSync(baselinePath, `${JSON.stringify(doc, null, 2)}\n`, 'utf8'); return entries; } /** * `--json` mode emits ONE structured JSON object to stdout in place of the * human formatter below — the typed IR CONTRIBUTING.md's "Prohibited: Raw * Text Matching on Test Outputs" requires. The human formatter's wording is * untouched (operator console use only); `emitJson` is the only new output * surface, gated on `json` so the two never interleave on the same stream. */ function main() { const root = path.join(__dirname, '..'); const update = process.argv.includes('--update'); const json = process.argv.includes('--json'); const { violations, malformed } = scanRepo(root); function emitJson(report) { if (json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); } if (update) { if (malformed.length > 0) { if (!json) { process.stderr.write('unreachable-guard-drift: malformed `# gsd-scan-ignore:` declaration(s) — fix these before regenerating the baseline (they are never ratchet-eligible):\n'); for (const m of malformed) { process.stderr.write(` ${sanitizeForReport(m.file)}:${m.line} ${sanitizeForReport(m.text)}\n`); } process.stderr.write('\n remedy: the reason after `# gsd-scan-ignore:` must name a tracking issue (#NNN) or an http(s):// URL.\n'); } emitJson({ reason: REASON.FAIL_MALFORMED_MARKER, violations: [], malformed, stale: [], baselineErrors: [] }); process.exitCode = 1; return; } const entries = writeBaseline(root, violations); if (!json) { process.stdout.write(`ok unreachable-guard-drift: baseline regenerated with ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}\n`); } emitJson({ reason: REASON.OK_BASELINE_UPDATED, violations: [], malformed: [], stale: [], baselineErrors: [], updatedEntryCount: entries.length }); return; } const { entries: baseline, errors } = loadBaseline(root); if (errors.length > 0) { if (!json) { process.stderr.write('unreachable-guard-drift: baseline load error(s):\n'); // OUTPUT SEAM: `loadBaseline`'s `message` strings embed // `JSON.stringify(entry.file)` / `JSON.stringify(entry.text)` / // `JSON.stringify(entry)` verbatim, and `JSON.stringify` escapes only // code points below 0x20 — it passes C1 controls (0x7F-0x9F) and the // bidi/zero-width controls (U+202E RTL override, U+2066-U+2069, // U+2028, U+2029) through UNESCAPED. Without `sanitizeForReport` here, a // crafted `entries[].file`/`.text` value in the baseline JSON could // land an active bidi override straight into CI console output — the // exact report-spoofing class every violation/malformed field below is // already routed through `sanitizeForReport` to prevent. for (const e of errors) process.stderr.write(` ${sanitizeForReport(e.message)}\n`); } emitJson({ reason: REASON.FAIL_BASELINE_LOAD, violations: [], malformed: [], stale: [], baselineErrors: errors }); process.exitCode = 1; return; } const { fresh, stale } = diffAgainstBaseline(violations, baseline); if (fresh.length === 0 && stale.length === 0 && malformed.length === 0) { if (!json) { process.stdout.write(`ok unreachable-guard-drift: no unacknowledged unreachable shell-guard shapes in the prompt layer (${baseline.length} known)\n`); } emitJson({ reason: REASON.OK_NO_VIOLATIONS, violations: [], malformed: [], stale: [], baselineErrors: [], knownCount: baseline.length }); return; } if (!json) { if (fresh.length > 0) { process.stderr.write('unreachable-guard-drift: NEW unreachable shell-guard shape(s) found in the prompt layer.\n'); process.stderr.write('Detector A (--pick + || echo): the fallback can never fire on field absence — replace with an\n'); process.stderr.write('explicit -z/empty-string test on the resolved value.\n'); process.stderr.write('Detector B (cat/ls over a glob operand): under a nullglob set elsewhere in the same shell\n'); process.stderr.write('session, an unmatched glob reads from stdin (cat) or lists the cwd (ls) — use an array\n'); process.stderr.write('expansion or an existence test instead.\n'); process.stderr.write(`Or, if this is a deliberate wrong-example, declare it with # gsd-scan-ignore: #NNN, or add an\n`); process.stderr.write(`acknowledged entry to ${BASELINE_REL_PATH} via --update:\n`); for (const v of fresh) { process.stderr.write(` ${sanitizeForReport(v.file)}:${v.line} [${v.kind}] ${sanitizeForReport(v.found)} ${sanitizeForReport(v.text)}\n`); } } if (stale.length > 0) { process.stderr.write('\nunreachable-guard-drift: STALE baseline entr' + (stale.length === 1 ? 'y' : 'ies') + " (fully migrated, or a PARTIAL migration — fewer occurrences found than acknowledged; delete or re-record the row):\n"); for (const e of stale) { process.stderr.write(` ${sanitizeForReport(e.file)} ${sanitizeForReport(e.text)} (found ${e.actualCount}/${e.count} acknowledged occurrence${e.count === 1 ? '' : 's'})\n`); } process.stderr.write(`\n remedy: node scripts/lint-unreachable-guard-drift.cjs --update\n`); } if (malformed.length > 0) { process.stderr.write('\nunreachable-guard-drift: malformed `# gsd-scan-ignore:` declaration(s) — never ratchet-eligible, must be fixed directly:\n'); for (const m of malformed) { process.stderr.write(` ${sanitizeForReport(m.file)}:${m.line} ${sanitizeForReport(m.text)}\n`); } process.stderr.write('\n remedy: the reason after `# gsd-scan-ignore:` must name a tracking issue (#NNN) or an http(s):// URL.\n'); } } const reason = fresh.length > 0 ? REASON.FAIL_FRESH_VIOLATION : stale.length > 0 ? REASON.FAIL_STALE_ENTRY : REASON.FAIL_MALFORMED_MARKER; emitJson({ reason, violations: fresh, malformed, stale, baselineErrors: [] }); process.exitCode = 1; } if (require.main === module) main(); module.exports = { findUnreachableGuardDrift, detectGlobOperand, scanRepo, toPosixRel, loadBaseline, diffAgainstBaseline, dedupeViolationsForBaseline, sortEntries, writeBaseline, PICK_RE, ECHO_FALLBACK_RE, CAT_LS_COMMAND_RE, HEREDOC_AFTER_COMMAND_RE, EXIT_TESTING_KEYWORDS, FALLBACK_TOKEN_RE, isNoopFallback, scanClauseAfterCommand, MARKER_RE, ISSUE_REF_RE, SCAN_DIRS, SCAN_EXT, BASELINE_REL_PATH, RATCHET_OWNER_ISSUE, REASON, };