Files
msd-core/tests/unreachable-guard-drift.test.cjs
Tom Boucher 1591454357 feat(#3409): reject shell guards that cannot observe their own failure arm (#3558)
* test(#3409): failing-first regression tests for unreachable shell guard arms

Drives the three live defects fail-first, executing the shipped workflow
snippets rather than a re-typed copy:

- G1/G2 plan-phase.md Walking Skeleton gate reads `--pick summaries_total`,
  a field that does not exist, so PRIOR_SUMMARIES is always "" and the gate
  has never fired (#3365). G2 is the load-bearing negative-space case: it
  rejects a fix that treats "no answer" as "zero" and fires unconditionally.
- G3 plan-phase.md PHASE_REQ_IDS resolves "" instead of the TBD sentinel on
  a phase with zero requirements.
- G4 complete-milestone.md's bare `cat <glob>` blocks on stdin under a
  nullglob left set by an earlier block (measured hang).

Skipped on Windows for G4 only: the FIFO-blocked-stdin mechanism is POSIX
only, and a weakened assertion there would pass vacuously.

Refs #3409

* fix(#3409): make nine shell guards observe their own failure arm

`--pick` coerces a missing field to empty string and exits 0, so the
`|| echo <default>` fallback after it fires only on a verb typo, never on
the field absence it was written for. Nine sites relied on that arm.

- plan-phase.md walking-skeleton gate: `--pick summaries_total` names a
  field that does not exist under any flag combination, so the gate has
  never fired on any project (#3365). Repointed at the existing single
  owner, `phases.list --type summaries --pick count`, which returns a real
  integer in every case including a project with no `.planning` directory.
  No new counter is added: a second one would duplicate the ownership
  ADR-3180 Decision 1 forbids. The gate now fires only on a literal "0",
  so an unanswerable query fails safe instead of entering skeleton mode.
- plan-phase.md phase_req_ids: now falls back to the documented TBD.
- The remaining seven convert to an explicit empty test.
- complete-milestone.md read all phase summaries through a bare
  `cat <glob>`; under a nullglob left set by an earlier block that is zero
  operands, so cat blocks on stdin. Guarded with the array shape the
  #3300 fix already established in review.md.

Refs #3409

* fix(#3409): guard eleven more globs that defeat their own fallback arm

The nullglob audit this issue asks for turned up the same class in files
#3300 never touched.

- Eight bare `cat <glob>` reads (transition, complete-milestone, planner x4,
  verifier, phase-researcher). With nullglob set that is zero operands, so
  cat reads stdin and blocks; measured rc=137 at 3s.
- Three `ls <glob> || echo "<message>"` sites (session-report,
  review-backlog and its generated skill). nullglob makes ls succeed
  listing the cwd, so the message never prints and the user gets a
  directory listing instead.

Guarded with `[ -e "${_ARR[0]}" ]` rather than `[ ${#_ARR[@]} -gt 0 ]`.
The count form is correct only when nullglob is set, and six of these
seven files never set it: without it the array holds the unmatched literal
pattern, so the count is 1 and the guard passes wrongly. `-e` is correct
in both worlds. review.md keeps its count guards — that block sets
nullglob two lines above them.

skills/gsd-review-backlog regenerated from commands/, never hand-edited.

Refs #3409

* feat(#3409): add the unreachable-shell-guard drift lint

A sibling of lint-planning-prompt-drift.cjs, consuming the shared
scripts/lib/drift-scan.cjs rather than copying it, wired into lint:ci.

Both detectors are one shape — a fallback arm defeated by a legitimate
success-on-empty:

- Detector A: `--pick` and `|| echo` on one line. `--pick` is the
  discriminator because "missing field renders empty at exit 0" is a
  documented CLI contract, not a heuristic. A rule keyed on gsd_run
  matched 111 lines, ~132 of them legitimate, and was rejected.
- Detector B: `cat <glob>` in command position, and `ls <glob>` whose
  exit code feeds a real fallback or an if/while head. Informational
  `ls <glob>` whose stdout is consumed (97 sites) and `|| true` failure
  suppression (~15) are not guards and never fire.

Shrink-only ratchet keyed on (file, trimmed text) with a per-pair count,
POSIX-normalized unconditionally so Windows CI cannot report everything
fresh and stale at once. Ships with a ZERO-entry baseline: every site it
can find is fixed. Exemption is the per-line `# gsd-scan-ignore: #NNN`
marker whose reason must name an issue or URL; a malformed reason reports
a distinct error rather than silently exempting. No file allowlists.

ADR-3409 records the invariant, the measurements behind both detectors,
and why the upstream `--pick` contract fix belongs to #3473.

Refs #3409

* fix(#3409): resolve review findings — typed surface, sanitized reports, tighter marker

Standards axis (blocker): the guard's tests asserted on human-readable
stdout/stderr and on free-form baseline-load prose, which CONTRIBUTING
prohibits by name. Added the typed surface it prescribes instead of
weakening the tests: a frozen REASON enum, a --json report mode,
structured loadBaseline errors, and a test locking Object.keys(REASON)
so a new reason stays three coordinated changes.

Security axis: sanitizeForReport covered every violation field but not
the baseline-load error path, which embeds raw JSON.stringify output --
that escapes nothing above 0x1f, so bidi and C1 controls reached CI logs
unfiltered. Routed through the sanitizer at the output seam.

Security axis: the scan-ignore marker accepted `#0` and a bare
`http://`. Tightened to a positive issue number and a URL with a host.
This diverges deliberately from the sibling in
tests/commit-files-pathspec.test.cjs, whose looser form was copied
verbatim; the header now records the divergence.

Security axis: G4 built its FIFO with `mktemp -u`, reserving a name
without creating it. Now created inside a `mktemp -d` directory.

Spec axis: ADR-3409 claimed a ninth site landed after the issue was
filed. git blame disproves it -- all nine predate it; the issue's hand
count missed one. Corrected. The design and test matrix still specified
B9 as a FLAG after implementation reversed it to PASS; both now record
the reversal and why.

Refs #3409

* docs(#3409): add the how-to for resolving unreachable-guard findings

Reference and Explanation are carried by ADR-3409; this is the
task-oriented quadrant CI cannot check for.

The page exists mainly for one thing the lint structurally cannot catch:
both `[ -e "${_ARR[0]}" ]` and `[ ${#_ARR[@]} -gt 0 ]` remove the glob
from the command and therefore both pass, but the count form is correct
only when nullglob is set — and nullglob is usually set in a different
block of the same file. A reference table cannot carry that; a how-to can.

Also documents the reason codes, so a reader can tell "nothing to report"
from "could not look".

No tutorial: this is a gate inside an existing CI loop, not a new entry
point a newcomer starts from.

Refs #3409

* fix(#3409): bring the touched prompt files back under their size gates

The remote run was red on 14 tests, all size/attribution, none of them
the regression suite.

- agents/gsd-planner.md was 194 chars over a 49152 cap enforced by four
  separate tests, each of which says the remedy is extraction, not a bump.
  It had 41 chars of headroom before this branch. Its `## Checkpoint
  Types` section was an unlinked, condensed duplicate of
  references/checkpoints.md, which already carries all three types and
  their XML shapes; the section now points there and keeps the three
  names and percentages inline. Net -969, margin 1010.
- gsd-core/workflows/execute-phase.md sat 2 chars under a comfortable
  margin assertion. Dropped the AUTO_MODE default: the `|| echo "false"`
  it replaced was unreachable, so the value was already sometimes empty
  on next, and its only consumer compares against `true`. Net -16.
  Left plan-phase.md's AUTO_CHAIN default alone -- that file names an
  explicit `false` branch, so empty would match neither branch.
- Acknowledged the seven prompt files that genuinely grew, one specific
  reason each. Five of those paths were already claimed by spent
  fragments identical to next, which blocks a second source naming the
  same path; removed just the colliding key from each, deleting the two
  that this emptied.

Refs #3409

* test(#3409): extract the whole PHASE_REQ_IDS block, not just its first line

G3 failed on the remote runner with '' !== 'TBD'. The test was wrong, not
the workflow.

The shipped contract is now two consecutive lines -- the capture and the
`${PHASE_REQ_IDS:-TBD}` default -- but the helper's `^PREFIX=.*$` regex
returns only the first match, so the test executed half the contract and
correctly observed the empty string. Renamed to extractAssignmentBlockFor
and taught it to consume the contiguous run of lines sharing the prefix.

The assertion is untouched: TBD is the right expectation, and weakening
it to accept the empty string would have reinstated exactly the class
this suite exists to catch -- a check that cannot observe the thing it
is checking.

extractFencedBashAfterAnchor is unaffected: it is fence-delimited rather
than line-anchored, so G1/G2/G4 still capture their full blocks.

Refs #3409

* chore(#3409): drop a spent ack fragment that collided on complete-milestone.md

#3458 landed on next while this branch was in flight and its fragment
claims complete-milestone.md, which this branch also grows. Two ack
sources may never name the same path.

Its entry is spent: the +9163 it explains is already absorbed at base, so
it can no longer clear anything, and the checker's own guidance for spent
entries is to delete them. Removing the key emptied the fragment, so the
file goes too -- an empty one signals nothing.

Refs #3409

* chore(#3409): backfill changeset pr number 3558

* test(#3409): hoist a regex subject out of exec() to clear the injection scan

CI's prompt-injection scan flagged `MARKER_RE.exec('# gsd-scan-ignore: ...')`.
The pattern `exec[[:space:]]*\(["']` is receiver-blind on purpose, so it
catches `require('child_process').exec('...')` -- and the scanner's own
header records that RegExp.prototype.exec is collateral, to be handled by
its allowlist.

Allowlisting the file would blind it to the real exec vector permanently,
so the subject is hoisted into a const instead: same assertion, scanner
left at full strength, no security surface widened.

Refs #3409

---------

Co-authored-by: sim <sim@local>
2026-08-15 21:09:05 -04:00

1116 lines
52 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// gsd-scan-ignore: #3409 — this test file's fixture strings are constructed
// via concatenation (never written as literal detector-matching source
// lines) so scripts/lint-unreachable-guard-drift.cjs, whose SCAN_DIRS do not
// include tests/, never has occasion to see this comment as an attempted
// declaration either.
'use strict';
/**
* Tests for the unreachable-shell-guard prompt-layer drift guard (#3409) —
* scripts/lint-unreachable-guard-drift.cjs.
*
* 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
*
* Covers every row of the matrix EXCEPT the "Regression — the three defects
* this PR fixes" section (G1-G4), which lives in
* tests/unreachable-shell-guard.test.cjs and is not touched here.
*
* FIXTURE STRINGS ARE BUILT, NOT WRITTEN LITERALLY, where a literal would
* itself be a detector-matching source line living under this repo's tree —
* this file is not under any of the guard's SCAN_DIRS
* (gsd-core/workflows, commands, agents, skills), so nothing here is ever
* scanned by the guard under test, but the concatenation habit is kept
* anyway as the sanctioned way to avoid an incidental self-match noted in
* the dispatch brief, rather than a file allowlist (40-design.md's
* Rejected #5 forbids allowlists for exactly this shape of problem).
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fc = require('fast-check');
const fs = require('node:fs');
const path = require('node:path');
const drift = require('../scripts/lint-unreachable-guard-drift.cjs');
const {
findUnreachableGuardDrift,
detectGlobOperand,
scanRepo,
loadBaseline,
diffAgainstBaseline,
dedupeViolationsForBaseline,
writeBaseline,
toPosixRel,
PICK_RE,
ECHO_FALLBACK_RE,
CAT_LS_COMMAND_RE,
HEREDOC_AFTER_COMMAND_RE,
isNoopFallback,
scanClauseAfterCommand,
MARKER_RE,
ISSUE_REF_RE,
BASELINE_REL_PATH,
REASON,
} = drift;
const { createTempDir, cleanup } = require('./helpers.cjs');
const { runNode } = require('./helpers/process-seam.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const { sanitizeForReport } = require('../scripts/lib/drift-scan.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const DRIFT_SCRIPT = path.join(REPO_ROOT, 'scripts', 'lint-unreachable-guard-drift.cjs');
const FAKE_FILE = 'gsd-core/workflows/fake.md';
// Build a fenced-shell "gsd_run … --pick … || echo …" style line without
// ever writing the literal pipe-pipe/echo shape as adjacent source tokens in
// THIS file. `parts.join('')` concatenates at runtime.
function pickEchoLine({ pick = 'summaries_total', stderr = true, fallback = '"0"' } = {}) {
const parts = [
'X=$(gsd_run query phases.list --pick ', pick,
stderr ? ' 2>/dev/null' : '',
' ', '|', '|', ' echo ', fallback, ')',
];
return parts.join('');
}
function catLine(operand, { cmd = 'cat', prefix = '', suffix = '' } = {}) {
return `${prefix}${cmd} ${operand}${suffix}`;
}
function markerComment(reason) {
return `# gsd-scan-ignore: ${reason}`;
}
// ─── Detector A — PICK_RE / ECHO_FALLBACK_RE ──────────────────────────────
describe('Detector A — --pick + || echo fallback', () => {
test('A1: canonical shape with 2>/dev/null is flagged, found names --pick', () => {
const line = pickEchoLine({ stderr: true, fallback: '"d"' });
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.strictEqual(violations.length, 1);
assert.strictEqual(violations[0].kind, 'A');
assert.strictEqual(violations[0].found, '--pick');
assert.strictEqual(violations[0].text, line.trim());
});
test('A2: without a stderr redirect is still flagged', () => {
const line = pickEchoLine({ stderr: false, fallback: '"d"' });
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.strictEqual(violations.length, 1);
assert.strictEqual(violations[0].kind, 'A');
});
test('A3: an empty-string default is still flagged (unreachable AND a no-op)', () => {
const line = pickEchoLine({ fallback: '""' });
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.strictEqual(violations.length, 1);
});
test('A4: a --pick-less config-get fallback is NOT detected', () => {
const line = ['X=$(gsd_run query config-get k 2>/dev/null ', '|', '|', ' echo "false")'].join('');
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('A5: a pick with no fallback is NOT detected', () => {
const line = 'X=$(gsd_run query phases.list --pick summaries_total)';
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('A6: a git fallback is NOT detected', () => {
const line = ['X=$(git rev-list --count HEAD ', '|', '|', ' echo 0)'].join('');
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('A7: a grep fallback is NOT detected', () => {
const line = ["Y=$(grep -cE '^' file.md ", '|', '|', ' echo "0")'].join('');
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('A8: the and-or ternary idiom is NOT detected', () => {
const line = ['$([ -n "$X" ] && echo "a" ', '|', '|', ' echo "")'].join('');
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('A9 (known limit): a cross-line split is NOT detected', () => {
const text = [
'X=$(gsd_run query phases.list --pick summaries_total 2>/dev/null)',
['Y=$(echo "$X" ', '|', '|', ' echo "0")'].join(''),
].join('\n');
const { violations } = findUnreachableGuardDrift(text, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('A10 (known limit): a printf fallback is NOT detected', () => {
const line = ["X=$(gsd_run query phases.list --pick f ", '|', '|', " printf 'd')"].join('');
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('A11: a fenced block is not an exemption — the same line inside ```bash still flags', () => {
const line = pickEchoLine();
const text = ['```bash', line, '```'].join('\n');
const { violations } = findUnreachableGuardDrift(text, FAKE_FILE);
assert.strictEqual(violations.length, 1);
assert.strictEqual(violations[0].line, 2);
});
test('A12: reports each violating line separately, with correct line numbers', () => {
const l1 = pickEchoLine({ pick: 'a' });
const l2 = pickEchoLine({ pick: 'b' });
const text = ['no-op', l1, 'middle', l2].join('\n');
const { violations } = findUnreachableGuardDrift(text, FAKE_FILE);
assert.strictEqual(violations.length, 2);
assert.strictEqual(violations[0].line, 2);
assert.strictEqual(violations[1].line, 4);
});
test('A13: byte-identical duplicates count as 2 occurrences', () => {
const line = pickEchoLine();
const text = [line, line].join('\n');
const { violations } = findUnreachableGuardDrift(text, FAKE_FILE);
assert.strictEqual(violations.length, 2);
assert.strictEqual(violations[0].text, violations[1].text);
});
test('A15: CRLF line endings yield the identical verdict to LF', () => {
const line = pickEchoLine();
const lf = findUnreachableGuardDrift(line, FAKE_FILE);
const crlf = findUnreachableGuardDrift(`${line}\r\n`, FAKE_FILE);
assert.strictEqual(lf.violations.length, 1);
assert.strictEqual(crlf.violations.length, 1);
assert.strictEqual(crlf.violations[0].text, lf.violations[0].text);
});
});
// ─── Detector B — B-i cat <glob> / B-ii ls <glob> … || <real fallback> /
// B-iii ls <glob> at the head of if/elif/while ─────────────────────────────
//
// Scope, per the coordinator's measured correction: `ls <glob>` fires ONLY
// when its exit code feeds either a genuine `||` fallback (not the no-op
// `true`/`:`) or gates an `if`/`elif`/`while` test — never when its STDOUT
// is what's consumed (piped, captured via `$(...)`), and never on the
// `|| true`/`|| :` defensive-suppression idiom, which carries no fallback
// value for nullglob's success-on-empty to defeat.
describe('Detector B — cat <glob> (B-i), ls <glob> || <real fallback> (B-ii), ls <glob> at if/elif/while (B-iii)', () => {
test('B1: detects a bare cat over an unmatched-capable glob (B-i, unconditional)', () => {
const line = catLine('.planning/phases/*-*/*-SUMMARY.md');
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.strictEqual(violations.length, 1);
assert.strictEqual(violations[0].kind, 'B');
assert.strictEqual(violations[0].found, 'cat');
});
test('B2: detects ls at the head of an if test (B-iii — still FIRES, exit code gates control flow)', () => {
const line = 'if ls "${DIR}/"*-CONTEXT.md; then true; fi';
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.strictEqual(violations.length, 1);
assert.strictEqual(violations[0].found, 'ls');
});
test('B3: a stderr redirect does not cure the cat stdin hazard (B-i)', () => {
const line = catLine('dir/*.md', { suffix: ' 2>/dev/null' });
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.strictEqual(violations.length, 1);
});
test('B4: an array expansion is NOT detected (it is the remedy)', () => {
const line = catLine('"${_CTX[@]}"');
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('B5: a for-list glob is NOT detected', () => {
const line = 'for f in dir/*.md; do echo "$f"; done';
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('B6: a literal operand is NOT detected', () => {
const a = findUnreachableGuardDrift(catLine('"$FILE"'), FAKE_FILE);
const b = findUnreachableGuardDrift(catLine('f1 f2'), FAKE_FILE);
assert.deepStrictEqual(a.violations, []);
assert.deepStrictEqual(b.violations, []);
});
test('B7: ls with no operand is NOT detected', () => {
const { violations } = findUnreachableGuardDrift('ls', FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('B8: a heredoc is NOT detected', () => {
const line = "cat <<'EOF'";
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('B9: a counting ls whose STDOUT is piped onward is NOT detected (informational, out of scope)', () => {
// Corrected semantics: `ls`'s exit code is not what's being consumed
// here — its STDOUT is piped to `wc -l`. This is the class of ~97
// measured "informational ls" sites the issue explicitly leaves alone
// (it is neither the stdin-hang hazard nor a defeated fallback nor an
// always-true guard); overlap with lint-planning-prompt-drift on the
// COUNTING shape is that guard's concern, not this one's.
const line = 'ls -1 dir/*-PLAN.md | wc -l';
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('B10: the word cat in prose (not command position) is NOT detected', () => {
const line = 'The output looks like a cat chasing dir/*.md files around.';
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('B-ii positive: ls <glob> || echo "<default>" fires — a real fallback nullglob silently defeats', () => {
// Real site shape (commands/gsd/review-backlog.md, pre-fix): under
// nullglob an unmatched glob makes `ls` list the CWD and succeed, so
// the intended "no backlog items" message never prints — exactly
// Detector A's shape one level down, with `ls`'s own exit code standing
// in for `--pick`'s coerced-to-'' absence.
const line = 'ls -d .planning/phases/999* 2>/dev/null || echo "No backlog items found"';
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.strictEqual(violations.length, 1);
assert.strictEqual(violations[0].kind, 'B');
assert.strictEqual(violations[0].found, 'ls');
});
test('ls <glob> ... || true does NOT fire — suppressing a failure is not a guard', () => {
// No fallback VALUE exists here for nullglob's success-on-empty to
// defeat — `true` runs unconditionally either way. Measured: ~15 sites
// in this tree, all this exact defensive idiom (`set -e` failure
// suppression), none of them the #3300/#3409 hazard shape.
const line = 'ls -d .planning/milestones/v*-phases 2>/dev/null || true';
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('ls <glob> ... || : does NOT fire — the : no-op is the same idiom as || true', () => {
const line = 'ls -d .planning/phases/*/ 2>/dev/null || :';
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('an informational ls whose stdout is captured via $(...) does NOT fire (out of scope)', () => {
// Real site shape (commands/gsd/quick.md, and many like it): the exit
// code is never consulted at all — only the captured stdout is used —
// so this is neither the stdin-hang hazard nor a defeated fallback nor
// an always-true guard. Measured: 97 such sites, explicitly out of
// this issue's scope.
const line = 'dir=$(ls -d .planning/quick/*-{SLUG}/ 2>/dev/null | head -1)';
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('the Bash(cat << \'EOF\') prose line does NOT fire — a heredoc operator is never a glob operand', () => {
// Real site shape (agents/gsd-executor.md and six siblings): the
// surrounding markdown **bold** carries literal `*` characters
// elsewhere on the line, and `(` (from `Bash(`) is a valid command-
// position anchor for `$(cat ...)` — but the heredoc guard
// (HEREDOC_AFTER_COMMAND_RE) refuses to treat anything immediately
// after `cat` as an operand at all once it sees `<<`, so this never
// reaches the glob check regardless of what markdown emphasis follows.
const line = "3. **Do NOT use `Bash(cat << 'EOF')` or heredoc** for file creation. Use the `Write` tool.";
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
});
test('detectGlobOperand: pure predicate returns null on no command match', () => {
assert.strictEqual(detectGlobOperand('echo dir/*.md'), null);
});
test('detectGlobOperand: pure predicate returns the command name on a cat match (B-i)', () => {
assert.deepStrictEqual(detectGlobOperand(catLine('dir/*.md')), { command: 'cat' });
});
test('detectGlobOperand: returns null for an ls glob with no exit-code-consuming shape', () => {
assert.strictEqual(detectGlobOperand('ls dir/*.md 2>/dev/null'), null);
});
test('detectGlobOperand: returns the command name for an ls glob with a real || fallback (B-ii)', () => {
assert.deepStrictEqual(detectGlobOperand('ls dir/*.md || echo missing'), { command: 'ls' });
});
test('isNoopFallback recognizes true and : as no-ops and anything else as real', () => {
assert.strictEqual(isNoopFallback(' true'), true);
assert.strictEqual(isNoopFallback(' true) | sort'), true);
assert.strictEqual(isNoopFallback(' :'), true);
assert.strictEqual(isNoopFallback(' echo "message"'), false);
assert.strictEqual(isNoopFallback(''), true);
});
test('scanClauseAfterCommand finds the glob and the correct terminator across chain shapes', () => {
assert.deepStrictEqual(scanClauseAfterCommand(' dir/*.md | wc -l'), { hasGlob: true, terminator: '|', fallback: null });
assert.deepStrictEqual(scanClauseAfterCommand(' dir/*.md && next'), { hasGlob: true, terminator: '&&', fallback: null });
const orResult = scanClauseAfterCommand(' dir/*.md || echo x');
assert.strictEqual(orResult.hasGlob, true);
assert.strictEqual(orResult.terminator, '||');
assert.strictEqual(orResult.fallback, ' echo x');
});
});
// ─── Escape marker ─────────────────────────────────────────────────────────
describe('Escape marker — # gsd-scan-ignore:', () => {
test('M1: a marker naming an issue exempts the line', () => {
const line = `${pickEchoLine()} ${markerComment('#3409')}`;
const { violations, malformed } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
assert.deepStrictEqual(malformed, []);
});
test('M2: a marker naming a URL exempts the line', () => {
const line = `${catLine('dir/*.md')} ${markerComment('https://example.com/issue/1')}`;
const { violations, malformed } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
assert.deepStrictEqual(malformed, []);
});
test('M3: a free-text reason reports a malformed declaration, not a plain violation', () => {
const line = `${pickEchoLine()} ${markerComment('because I said so')}`;
const { violations, malformed } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
assert.strictEqual(malformed.length, 1);
assert.strictEqual(malformed[0].reason, 'because I said so');
});
test('M4: an empty reason is not an audit trail — malformed', () => {
const line = `${pickEchoLine()} # gsd-scan-ignore:`;
const { violations, malformed } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
assert.strictEqual(malformed.length, 1);
assert.strictEqual(malformed[0].reason, '');
});
test('M5: a whitespace-only reason is rejected — malformed', () => {
const line = `${pickEchoLine()} # gsd-scan-ignore: `;
const { violations, malformed } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.deepStrictEqual(violations, []);
assert.strictEqual(malformed.length, 1);
});
test('M6: the marker binds only to its own line — a marker above a violation does not exempt it', () => {
const text = [markerComment('#3409'), pickEchoLine()].join('\n');
const { violations, malformed } = findUnreachableGuardDrift(text, FAKE_FILE);
assert.strictEqual(violations.length, 1, 'the violation on line 2 must still fire');
assert.deepStrictEqual(malformed, []);
});
test('a well-formed marker on a non-violating line produces neither a violation nor a malformed entry', () => {
const { violations, malformed } = findUnreachableGuardDrift(markerComment('#100'), FAKE_FILE);
assert.deepStrictEqual(violations, []);
assert.deepStrictEqual(malformed, []);
});
test('ISSUE_REF_RE accepts a bare #NNN and an http(s) URL, rejects free text', () => {
assert.ok(ISSUE_REF_RE.test('#3409'));
assert.ok(ISSUE_REF_RE.test('https://example.com/x'));
assert.ok(ISSUE_REF_RE.test('http://example.com/x'));
assert.ok(!ISSUE_REF_RE.test('no issue here'));
});
// Tightened predicate (deliberate divergence from
// tests/commit-files-pathspec.test.cjs's own looser ISSUE_REF_RE — see this
// module's ISSUE_REF_RE comment): `#0` and a bare scheme-only URL both
// satisfied the copied form's FORMAT-only check without naming anything
// real. Regex-level checks first (mirrors the existing convention just
// above), then the same two shapes driven through the CLI so the outcome
// is pinned on the structured REASON.* code, never on rendered text.
test('ISSUE_REF_RE rejects #0 (not a positive integer) and a bare http:// with no host', () => {
assert.ok(!ISSUE_REF_RE.test('#0'));
assert.ok(ISSUE_REF_RE.test('#123'));
assert.ok(!ISSUE_REF_RE.test('http://'));
assert.ok(!ISSUE_REF_RE.test('https://'));
assert.ok(ISSUE_REF_RE.test('https://example.com/x'));
});
function runIsolatedMarkerCase(t, reason) {
const root = createTempDir('gsd-3409-issueref-');
t.after(() => cleanup(root));
const isolatedScript = buildIsolatedGuard(root);
const wfDir = path.join(root, 'gsd-core', 'workflows');
fs.mkdirSync(wfDir, { recursive: true });
fs.writeFileSync(path.join(wfDir, 'fake.md'), `${pickEchoLine()} ${markerComment(reason)}\n`);
const baselinePath = path.join(root, BASELINE_REL_PATH);
fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
fs.writeFileSync(baselinePath, JSON.stringify({ entries: [] }), 'utf8');
const jsonResult = runNode([isolatedScript, '--json'], { timeoutMs: PROBE_TIMEOUT_MS });
return JSON.parse(jsonResult.stdout);
}
test('#0 as a marker reason is rejected — reported as malformed, not exempted', (t) => {
const report = runIsolatedMarkerCase(t, '#0');
assert.strictEqual(report.reason, REASON.FAIL_MALFORMED_MARKER);
assert.strictEqual(report.malformed.length, 1);
});
test('#123 as a marker reason is accepted — the line is exempted', (t) => {
const report = runIsolatedMarkerCase(t, '#123');
assert.strictEqual(report.reason, REASON.OK_NO_VIOLATIONS);
});
test('a bare http:// as a marker reason is rejected — reported as malformed, not exempted', (t) => {
const report = runIsolatedMarkerCase(t, 'http://');
assert.strictEqual(report.reason, REASON.FAIL_MALFORMED_MARKER);
assert.strictEqual(report.malformed.length, 1);
});
test('https://example.com/x as a marker reason is accepted — the line is exempted', (t) => {
const report = runIsolatedMarkerCase(t, 'https://example.com/x');
assert.strictEqual(report.reason, REASON.OK_NO_VIOLATIONS);
});
});
// ─── Ratchet baseline — diffAgainstBaseline ───────────────────────────────
describe('diffAgainstBaseline — ratchet invariants', () => {
test('R1: an acknowledged pair at its exact count passes (neither fresh nor stale)', () => {
const baseline = [{ file: 'a.md', text: 'X', count: 1 }];
const violations = [{ file: 'a.md', line: 1, kind: 'A', found: '--pick', text: 'X' }];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(fresh, []);
assert.deepStrictEqual(stale, []);
});
test('R2: a partial migration (count:2, actual 1) reports stale', () => {
const baseline = [{ file: 'a.md', text: 'X', count: 2 }];
const violations = [{ file: 'a.md', line: 1, kind: 'A', found: '--pick', text: 'X' }];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(fresh, []);
assert.strictEqual(stale.length, 1);
assert.strictEqual(stale[0].count, 2);
assert.strictEqual(stale[0].actualCount, 1);
});
test('R3: the exact acknowledged count (count:2, actual 2) passes', () => {
const baseline = [{ file: 'a.md', text: 'X', count: 2 }];
const violations = [
{ file: 'a.md', line: 1, kind: 'A', found: '--pick', text: 'X' },
{ file: 'a.md', line: 9, kind: 'A', found: '--pick', text: 'X' },
];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(fresh, []);
assert.deepStrictEqual(stale, []);
});
test('R4: an unacknowledged extra copy (count:2, actual 3) reports one fresh', () => {
const baseline = [{ file: 'a.md', text: 'X', count: 2 }];
const violations = [
{ file: 'a.md', line: 1, kind: 'A', found: '--pick', text: 'X' },
{ file: 'a.md', line: 9, kind: 'A', found: '--pick', text: 'X' },
{ file: 'a.md', line: 20, kind: 'A', found: '--pick', text: 'X' },
];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(stale, []);
assert.strictEqual(fresh.length, 1);
assert.strictEqual(fresh[0].line, 20);
});
test('R5: a fully migrated pair (actual 0) reports stale', () => {
const baseline = [{ file: 'a.md', text: 'X', count: 2 }];
const { fresh, stale } = diffAgainstBaseline([], baseline);
assert.deepStrictEqual(fresh, []);
assert.strictEqual(stale.length, 1);
assert.strictEqual(stale[0].actualCount, 0);
});
test('R6: an unrecorded pair reports fresh', () => {
const violations = [{ file: 'a.md', line: 1, kind: 'B', found: 'cat', text: 'Y' }];
const { fresh, stale } = diffAgainstBaseline(violations, []);
assert.strictEqual(fresh.length, 1);
assert.deepStrictEqual(stale, []);
});
test('R7: an entry with no count defaults to acknowledging one occurrence', () => {
const baseline = [{ file: 'a.md', text: 'X' }];
const violations = [
{ file: 'a.md', line: 1, kind: 'A', found: '--pick', text: 'X' },
{ file: 'a.md', line: 9, kind: 'A', found: '--pick', text: 'X' },
];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(stale, []);
assert.strictEqual(fresh.length, 1);
assert.strictEqual(fresh[0].line, 9);
});
test('R8: the key includes the file path — same text, different file is fresh', () => {
const baseline = [{ file: 'a.md', text: 'X', count: 1 }];
const violations = [{ file: 'b.md', line: 1, kind: 'A', found: '--pick', text: 'X' }];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.strictEqual(fresh.length, 1);
assert.strictEqual(stale.length, 1, 'the a.md entry now has zero actual occurrences and is stale');
});
test('R9: line-number churn does not disturb the baseline (keyed on text, not line)', () => {
const baseline = [{ file: 'a.md', text: 'X', count: 1 }];
const violations = [{ file: 'a.md', line: 999, kind: 'A', found: '--pick', text: 'X' }];
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
assert.deepStrictEqual(fresh, []);
assert.deepStrictEqual(stale, []);
});
});
// ─── Baseline loading — malformed input ───────────────────────────────────
describe('loadBaseline — malformed input', () => {
function writeBaselineFile(root, content) {
const p = path.join(root, BASELINE_REL_PATH);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content, 'utf8');
}
test('L1: a missing baseline errors with its own remedy', (t) => {
const root = createTempDir('gsd-3409-baseline-');
t.after(() => cleanup(root));
const { entries, errors } = loadBaseline(root);
assert.deepStrictEqual(entries, []);
assert.strictEqual(errors.length, 1);
assert.strictEqual(errors[0].reason, REASON.FAIL_BASELINE_MISSING);
});
test('L2: an empty baseline errors', (t) => {
const root = createTempDir('gsd-3409-baseline-');
t.after(() => cleanup(root));
writeBaselineFile(root, ' \n');
const { errors } = loadBaseline(root);
assert.strictEqual(errors.length, 1);
assert.strictEqual(errors[0].reason, REASON.FAIL_BASELINE_EMPTY);
});
test('L3: invalid JSON errors, naming the parse failure', (t) => {
const root = createTempDir('gsd-3409-baseline-');
t.after(() => cleanup(root));
writeBaselineFile(root, '{ not json');
const { errors } = loadBaseline(root);
assert.strictEqual(errors.length, 1);
assert.strictEqual(errors[0].reason, REASON.FAIL_BASELINE_INVALID_JSON);
assert.strictEqual(typeof errors[0].parseError, 'string');
});
test('L4: non-object JSON scalars each error, naming the actual type', (t) => {
const root = createTempDir('gsd-3409-baseline-');
t.after(() => cleanup(root));
for (const [literal, expectedType] of [['0', 'number'], ['"str"', 'string'], ['[]', 'array'], ['null', 'object'], ['true', 'boolean']]) {
writeBaselineFile(root, literal);
const { errors } = loadBaseline(root);
assert.strictEqual(errors.length, 1, `literal=${literal}`);
assert.strictEqual(errors[0].reason, REASON.FAIL_BASELINE_NOT_OBJECT, `literal=${literal}`);
assert.strictEqual(errors[0].gotType, expectedType, `literal=${literal}`);
}
});
test('L5: a non-array entries field errors', (t) => {
const root = createTempDir('gsd-3409-baseline-');
t.after(() => cleanup(root));
writeBaselineFile(root, JSON.stringify({ entries: 'nope' }));
const { errors } = loadBaseline(root);
assert.strictEqual(errors.length, 1);
assert.strictEqual(errors[0].reason, REASON.FAIL_BASELINE_ENTRIES_NOT_ARRAY);
});
test('L6: count must be a positive integer — 0, -1, 1.5, "2" each error', (t) => {
const root = createTempDir('gsd-3409-baseline-');
t.after(() => cleanup(root));
for (const badCount of [0, -1, 1.5, '2']) {
writeBaselineFile(root, JSON.stringify({ entries: [{ file: 'a.md', text: 'X', count: badCount }] }));
const { entries, errors } = loadBaseline(root);
assert.strictEqual(entries.length, 0, `count=${JSON.stringify(badCount)}`);
assert.strictEqual(errors.length, 1, `count=${JSON.stringify(badCount)}`);
assert.strictEqual(errors[0].reason, REASON.FAIL_BASELINE_ENTRY_COUNT_INVALID, `count=${JSON.stringify(badCount)}`);
}
});
test('L7: empty key fields (file or text) error', (t) => {
const root = createTempDir('gsd-3409-baseline-');
t.after(() => cleanup(root));
writeBaselineFile(root, JSON.stringify({ entries: [{ file: '', text: 'X' }] }));
let result = loadBaseline(root);
assert.strictEqual(result.errors.length, 1);
assert.strictEqual(result.errors[0].reason, REASON.FAIL_BASELINE_ENTRY_FIELD_INVALID);
assert.strictEqual(result.errors[0].field, 'file');
writeBaselineFile(root, JSON.stringify({ entries: [{ file: 'a.md', text: '' }] }));
result = loadBaseline(root);
assert.strictEqual(result.errors.length, 1);
assert.strictEqual(result.errors[0].reason, REASON.FAIL_BASELINE_ENTRY_FIELD_INVALID);
assert.strictEqual(result.errors[0].field, 'text');
});
test('a valid baseline with a count field loads cleanly', (t) => {
const root = createTempDir('gsd-3409-baseline-');
t.after(() => cleanup(root));
writeBaselineFile(root, JSON.stringify({ entries: [{ file: 'a.md', text: 'X', count: 3 }] }));
const { entries, errors } = loadBaseline(root);
assert.deepStrictEqual(errors, []);
assert.strictEqual(entries.length, 1);
assert.strictEqual(entries[0].count, 3);
});
});
// ─── Cross-platform & encoding ─────────────────────────────────────────────
describe('Cross-platform & encoding', () => {
test('P1: a backslash relpath normalizes to POSIX in the key', () => {
const winRel = 'gsd-core\\workflows\\fake.md';
const posixRel = 'gsd-core/workflows/fake.md';
assert.strictEqual(toPosixRel(winRel), posixRel);
assert.strictEqual(toPosixRel(posixRel), posixRel);
const { violations } = findUnreachableGuardDrift(pickEchoLine(), winRel);
assert.strictEqual(violations[0].file, posixRel);
assert.ok(!violations[0].file.includes('\\'));
});
test('P2: CRLF input yields the same violations as LF (Detector A)', () => {
const line = pickEchoLine();
const lf = findUnreachableGuardDrift(line, FAKE_FILE).violations;
const crlf = findUnreachableGuardDrift(line.replace(/\n/g, '\r\n') + '\r\n', FAKE_FILE).violations;
assert.strictEqual(lf.length, 1);
assert.strictEqual(crlf.length, 1);
assert.strictEqual(lf[0].text, crlf[0].text);
});
test('P3: CRLF does not defeat the glob detector (Detector B)', () => {
const line = catLine('dir/*.md');
const lf = findUnreachableGuardDrift(line, FAKE_FILE).violations;
const crlf = findUnreachableGuardDrift(`${line}\r\n`, FAKE_FILE).violations;
assert.strictEqual(lf.length, 1);
assert.strictEqual(crlf.length, 1);
assert.strictEqual(lf[0].text, crlf[0].text);
});
test('P4: the baseline key is CR-free under CRLF — matches an LF-recorded baseline entry', () => {
const line = pickEchoLine();
const baseline = [{ file: FAKE_FILE, text: line.trim(), count: 1 }];
const crlfViolations = findUnreachableGuardDrift(`${line}\r\n`, FAKE_FILE).violations;
assert.ok(!crlfViolations[0].text.includes('\r'), 'the baseline-key text must carry no trailing \\r');
const { fresh, stale } = diffAgainstBaseline(crlfViolations, baseline);
assert.deepStrictEqual(fresh, []);
assert.deepStrictEqual(stale, []);
});
});
// ─── Hostile input ─────────────────────────────────────────────────────────
describe('Hostile input', () => {
test('X1: sanitizeForReport (the human console formatter\'s own typed IR) strips a raw ESC byte to a visible \\xNN escape', () => {
// sanitizeForReport (scripts/lib/drift-scan.cjs) is the structured surface
// the human formatter consumes before writing to stderr — asserted on its
// own return value directly, never on the CLI's rendered stderr text.
const esc = String.fromCharCode(0x1b);
const sanitized = sanitizeForReport(`${esc}[31mred${esc}[0m`);
assert.ok(!sanitized.includes(esc), 'sanitizeForReport must strip the raw ESC byte');
assert.match(sanitized, /\\x1b/);
});
test('X1b: a fresh violation carrying a hostile ANSI/control byte does not crash the CLI and classifies correctly', (t) => {
const root = createTempDir('gsd-3409-hostile-');
t.after(() => cleanup(root));
const isolatedScript = buildIsolatedGuard(root);
const wfDir = path.join(root, 'gsd-core', 'workflows');
fs.mkdirSync(wfDir, { recursive: true });
const esc = String.fromCharCode(0x1b);
const line = pickEchoLine() + ` # ${esc}[31mred${esc}[0m`;
fs.writeFileSync(path.join(wfDir, 'fake.md'), `${line}\n`);
writeBaselineFakeEmpty(root);
// Human-mode run: only the structural facts (process outcome, exit code)
// are asserted — the rendered stderr content is never inspected.
const humanResult = runNode([isolatedScript], { timeoutMs: PROBE_TIMEOUT_MS });
assert.strictEqual(humanResult.outcome, 'exited');
assert.strictEqual(humanResult.exitCode, 1);
// --json run: the structured report is the typed IR under test. Strict
// JSON forbids a literal unescaped C0 control byte inside a string, so
// `JSON.parse` succeeding is itself proof no raw ESC byte reached the
// stdout stream unescaped.
const jsonResult = runNode([isolatedScript, '--json'], { timeoutMs: PROBE_TIMEOUT_MS });
assert.strictEqual(jsonResult.exitCode, 1);
const report = JSON.parse(jsonResult.stdout);
assert.strictEqual(report.reason, REASON.FAIL_FRESH_VIOLATION);
assert.strictEqual(report.violations.length, 1);
});
test('X2: a very long line does not hang the scanner', () => {
const start = Date.now();
const longOperand = 'a'.repeat(100 * 1024) + '*.md';
const line = catLine(longOperand);
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.strictEqual(violations.length, 1);
assert.ok(Date.now() - start < 2000, 'a 100KB line must scan in well under 2s');
});
test('X3: null bytes do not crash the scanner', () => {
const line = catLine('dir/*.md\0trailing');
assert.doesNotThrow(() => findUnreachableGuardDrift(line, FAKE_FILE));
const { violations } = findUnreachableGuardDrift(line, FAKE_FILE);
assert.strictEqual(violations.length, 1);
});
test('X4a: sanitizeForReport strips a raw RTL-override codepoint to a visible \\uNNNN escape', () => {
const rlo = '‮';
const sanitized = sanitizeForReport(`dir/*.md${rlo}gnp.evil`);
assert.ok(!sanitized.includes(rlo), 'sanitizeForReport must strip the raw RLO codepoint');
assert.match(sanitized, /\\u202e/);
});
test('X4b: unicode / RTL-override in the violating text is handled without crashing and classifies correctly', (t) => {
const root = createTempDir('gsd-3409-hostile-');
t.after(() => cleanup(root));
const isolatedScript = buildIsolatedGuard(root);
const wfDir = path.join(root, 'gsd-core', 'workflows');
fs.mkdirSync(wfDir, { recursive: true });
const rlo = '‮';
const line = catLine(`dir/*.md${rlo}gnp.evil`);
fs.writeFileSync(path.join(wfDir, 'fake.md'), `${line}\n`);
writeBaselineFakeEmpty(root);
const humanResult = runNode([isolatedScript], { timeoutMs: PROBE_TIMEOUT_MS });
assert.strictEqual(humanResult.outcome, 'exited');
assert.strictEqual(humanResult.exitCode, 1);
const jsonResult = runNode([isolatedScript, '--json'], { timeoutMs: PROBE_TIMEOUT_MS });
assert.strictEqual(jsonResult.exitCode, 1);
const report = JSON.parse(jsonResult.stdout);
assert.strictEqual(report.reason, REASON.FAIL_FRESH_VIOLATION);
assert.strictEqual(report.violations.length, 1);
});
test('X5: the walk stays inside the root — a symlink escaping the scan tree is not followed', (t) => {
if (process.platform === 'win32') { t.skip('symlink creation requires elevated privileges on Windows CI'); return; }
const root = createTempDir('gsd-3409-symlink-');
t.after(() => cleanup(root));
const outside = createTempDir('gsd-3409-outside-');
t.after(() => cleanup(outside));
fs.mkdirSync(path.join(outside, 'secret'), { recursive: true });
fs.writeFileSync(path.join(outside, 'secret', 'leak.md'), catLine('dir/*.md') + '\n');
const wfDir = path.join(root, 'gsd-core', 'workflows');
fs.mkdirSync(wfDir, { recursive: true });
try {
fs.symlinkSync(path.join(outside, 'secret'), path.join(wfDir, 'escape'), 'dir');
} catch {
t.skip('symlink creation not permitted in this environment');
return;
}
const { violations } = scanRepo(root);
assert.deepStrictEqual(violations, [], 'a directory symlink pointing outside the scan-dir root must not be followed');
});
function writeBaselineFakeEmpty(root) {
const p = path.join(root, BASELINE_REL_PATH);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify({ entries: [] }), 'utf8');
}
});
// ─── Property tests (fast-check, pinned seed, bounded runs) ──────────────
describe('Property tests', () => {
// DOCUMENT-SHAPED, not writer-seeded (CONTRIBUTING.md's Fixture provenance
// #2371): tokens are drawn from a shell-ish alphabet independent of
// PICK_RE/ECHO_FALLBACK_RE's own literals, not generated from the
// detector's regex source.
const wordArb = fc.constantFrom(
'gsd_run', 'query', 'phases.list', 'config-get', 'k', 'v', 'f', '2>/dev/null',
'echo', 'printf', '"0"', '"d"', '$(', ')', 'X=', '&&', ';', 'if', 'then', 'fi',
'cat', 'ls', 'dir/*.md', '"$FILE"', '--type', 'summaries', '--pick', 'count',
);
const lineArb = fc.array(wordArb, { minLength: 1, maxLength: 12 }).map((ws) => ws.join(' '));
test('F1: detector A never fires on a document-shaped line lacking --pick or lacking || echo', () => {
fc.assert(
fc.property(lineArb, fc.boolean(), (line, injectPipe) => {
// Build a line that deliberately lacks at least one of the two
// required tokens, without deriving the construction from
// PICK_RE/ECHO_FALLBACK_RE themselves.
const hasPick = line.includes('--pick');
const rawFallback = injectPipe ? `${line} ${'|'}${'|'} echo done` : line;
const hasEcho = /\|\|\s*echo\b/.test(rawFallback);
fc.pre(!(hasPick && hasEcho));
const { violations } = findUnreachableGuardDrift(rawFallback, FAKE_FILE);
const aViolations = violations.filter((v) => v.kind === 'A');
assert.deepStrictEqual(aViolations, []);
}),
{ numRuns: 200, seed: 3409 },
);
});
// Baseline/violation record generators, independent of any production
// dedupe/diff code path.
const fileArb = fc.constantFrom('a.md', 'b.md', 'c.md');
const textArb = fc.constantFrom('LINE_ONE', 'LINE_TWO', 'LINE_THREE');
const countArb = fc.integer({ min: 1, max: 4 });
const baselineEntryArb = fc.record({ file: fileArb, text: textArb, count: countArb });
const baselineArb = fc.uniqueArray(baselineEntryArb, {
maxLength: 5,
selector: (e) => `${e.file} ${e.text}`,
});
const violationArb = fc.record({
file: fileArb,
text: textArb,
line: fc.integer({ min: 1, max: 500 }),
kind: fc.constantFrom('A', 'B'),
found: fc.constantFrom('--pick', 'cat', 'ls'),
});
const violationsArb = fc.array(violationArb, { maxLength: 10 });
test('F2: diffAgainstBaseline is a partition — no violation is both fresh and stale, and an exact-count pair is neither', () => {
fc.assert(
fc.property(violationsArb, baselineArb, (violations, baseline) => {
const { fresh, stale } = diffAgainstBaseline(violations, baseline);
// `fresh` items are always drawn from `violations` (they carry
// `line`/`kind`/`found`) and `stale` items are always drawn from
// `baseline` entries (they carry `actualCount`) — the two shapes
// are structurally disjoint, so no single object can satisfy both;
// asserted directly rather than by reference-equality (which two
// differently-shaped objects could never satisfy anyway).
for (const f of fresh) assert.ok('line' in f && 'actualCount' in f === false);
for (const s of stale) assert.ok('actualCount' in s && 'line' in s === false);
// Every fresh violation's (file,text) pair is either unknown to the
// baseline, or known but this is one of the excess occurrences.
for (const f of fresh) {
const entry = baseline.find((e) => e.file === f.file && e.text === f.text);
if (entry) {
const actualCountForPair = violations.filter((v) => v.file === f.file && v.text === f.text).length;
assert.ok(actualCountForPair > entry.count, 'a fresh violation from a known pair must exceed its acknowledged count');
}
}
// A baseline entry whose actual count exactly matches its
// acknowledged count must not appear in stale.
for (const entry of baseline) {
const actualCountForPair = violations.filter((v) => v.file === entry.file && v.text === entry.text).length;
const inStale = stale.some((s) => s.file === entry.file && s.text === entry.text);
if (actualCountForPair === entry.count) {
assert.ok(!inStale, 'an exactly-matched baseline entry must not be reported stale');
} else if (actualCountForPair < entry.count) {
assert.ok(inStale, 'an under-matched baseline entry must be reported stale');
}
}
}),
{ numRuns: 200, seed: 3410 },
);
});
});
// ─── Integration — the real CLI and the real tree ─────────────────────────
//
// `main()` hardcodes its scan root to `path.join(__dirname, '..')` (see the
// sibling `lint-planning-prompt-drift.cjs`'s own E5 test, which writes its
// fixture straight into the real `gsd-core/workflows/` tree for exactly
// this reason) — an invoked CLI's root can never be redirected via `cwd`.
// A `--update` spawn of the REAL script would therefore overwrite the
// repo's own committed baseline, which "No test may mutate the repo's
// committed baseline file" forbids outright. `buildIsolatedGuard` copies
// the script AND its `./lib/drift-scan.cjs` dependency into a throwaway
// root, so the copy's own `__dirname` resolves inside the temp tree and
// every one of its filesystem effects (reads AND `--update`'s write) stay
// fully isolated — this is still the real CLI end-to-end, not a pure-
// function call, just running from a location that makes isolation
// possible.
function buildIsolatedGuard(root) {
const scriptsDir = path.join(root, 'scripts');
fs.mkdirSync(path.join(scriptsDir, 'lib'), { recursive: true });
fs.copyFileSync(DRIFT_SCRIPT, path.join(scriptsDir, 'lint-unreachable-guard-drift.cjs'));
fs.copyFileSync(
path.join(REPO_ROOT, 'scripts', 'lib', 'drift-scan.cjs'),
path.join(scriptsDir, 'lib', 'drift-scan.cjs'),
);
return path.join(scriptsDir, 'lint-unreachable-guard-drift.cjs');
}
describe('Integration — CLI end-to-end', () => {
test('C1a: the guard is green on the real committed tree (real CLI, real baseline, no isolation needed for a read-only run)', () => {
// A bare (no --update) run only READS the repo — no committed-baseline
// mutation risk — so this is the one CLI-level test that can safely
// target the real script directly, and is the matrix's literal claim:
// "run against the committed repo with the committed baseline -> exit 0".
const result = runNode([DRIFT_SCRIPT, '--json'], { timeoutMs: PROBE_TIMEOUT_MS });
assert.strictEqual(result.outcome, 'exited');
assert.strictEqual(result.exitCode, 0, result.stderr);
const report = JSON.parse(result.stdout);
assert.strictEqual(report.reason, REASON.OK_NO_VIOLATIONS);
});
test('C1b: scanRepo(REPO_ROOT) carries no malformed gsd-scan-ignore declarations', () => {
const { malformed } = scanRepo(REPO_ROOT);
assert.deepStrictEqual(malformed, []);
});
test('C2: a fresh violation exits non-zero and the message names the remedy', (t) => {
const root = createTempDir('gsd-3409-c2-');
t.after(() => cleanup(root));
const isolatedScript = buildIsolatedGuard(root);
const wfDir = path.join(root, 'gsd-core', 'workflows');
fs.mkdirSync(wfDir, { recursive: true });
fs.writeFileSync(path.join(wfDir, 'fake.md'), `${pickEchoLine()}\n`);
const baselinePath = path.join(root, BASELINE_REL_PATH);
fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
fs.writeFileSync(baselinePath, JSON.stringify({ entries: [] }), 'utf8');
const result = runNode([isolatedScript, '--json'], { timeoutMs: PROBE_TIMEOUT_MS });
assert.strictEqual(result.outcome, 'exited');
assert.strictEqual(result.exitCode, 1);
const report = JSON.parse(result.stdout);
assert.strictEqual(report.reason, REASON.FAIL_FRESH_VIOLATION);
assert.strictEqual(report.violations.length, 1);
assert.strictEqual(report.violations[0].file, FAKE_FILE);
assert.strictEqual(report.violations[0].kind, 'A');
});
test('C3: --update regenerates a baseline that then passes', (t) => {
const root = createTempDir('gsd-3409-c3-');
t.after(() => cleanup(root));
const isolatedScript = buildIsolatedGuard(root);
const wfDir = path.join(root, 'gsd-core', 'workflows');
fs.mkdirSync(wfDir, { recursive: true });
fs.writeFileSync(path.join(wfDir, 'fake.md'), `${catLine('dir/*.md')}\n`);
const first = runNode([isolatedScript, '--update'], { timeoutMs: PROBE_TIMEOUT_MS });
assert.strictEqual(first.exitCode, 0, first.stderr);
const second = runNode([isolatedScript, '--json'], { timeoutMs: PROBE_TIMEOUT_MS });
assert.strictEqual(second.exitCode, 0, second.stderr);
const report = JSON.parse(second.stdout);
assert.strictEqual(report.reason, REASON.OK_NO_VIOLATIONS);
});
test('C4: --update output is deterministic across two runs', (t) => {
const root = createTempDir('gsd-3409-c4-');
t.after(() => cleanup(root));
const isolatedScript = buildIsolatedGuard(root);
const wfDir = path.join(root, 'gsd-core', 'workflows');
fs.mkdirSync(wfDir, { recursive: true });
fs.writeFileSync(path.join(wfDir, 'a.md'), `${catLine('dir/*.md')}\n`);
fs.writeFileSync(path.join(wfDir, 'b.md'), `${pickEchoLine()}\n`);
runNode([isolatedScript, '--update'], { timeoutMs: PROBE_TIMEOUT_MS });
const firstBaseline = fs.readFileSync(path.join(root, BASELINE_REL_PATH), 'utf8');
runNode([isolatedScript, '--update'], { timeoutMs: PROBE_TIMEOUT_MS });
const secondBaseline = fs.readFileSync(path.join(root, BASELINE_REL_PATH), 'utf8');
assert.strictEqual(firstBaseline, secondBaseline);
});
test('C5: baseline entries are sorted by (file, text)', (t) => {
const root = createTempDir('gsd-3409-c5-');
t.after(() => cleanup(root));
const violations = [
{ file: 'z.md', line: 1, kind: 'B', found: 'cat', text: 'zzz' },
{ file: 'a.md', line: 1, kind: 'B', found: 'cat', text: 'zzz' },
{ file: 'a.md', line: 2, kind: 'B', found: 'cat', text: 'aaa' },
];
const entries = writeBaseline(root, violations);
const keys = entries.map((e) => `${e.file} ${e.text}`);
const sortedKeys = [...keys].sort();
assert.deepStrictEqual(keys, sortedKeys);
});
});
// ─── dedupeViolationsForBaseline — count aggregation ──────────────────────
describe('dedupeViolationsForBaseline', () => {
test('collapses byte-identical (file, text) pairs into one entry with a count', () => {
const violations = [
{ file: 'a.md', line: 1, kind: 'A', found: '--pick', text: 'X' },
{ file: 'a.md', line: 5, kind: 'A', found: '--pick', text: 'X' },
{ file: 'a.md', line: 9, kind: 'B', found: 'cat', text: 'Y' },
];
const entries = dedupeViolationsForBaseline(violations);
assert.strictEqual(entries.length, 2);
const xEntry = entries.find((e) => e.text === 'X');
assert.strictEqual(xEntry.count, 2);
const yEntry = entries.find((e) => e.text === 'Y');
assert.strictEqual(yEntry.count, 1);
});
});
// ─── Regex-level sanity (documents the two regexes' shapes directly) ─────
describe('Regex shape sanity', () => {
test('PICK_RE matches only the literal --pick token', () => {
assert.ok(PICK_RE.test('--pick foo'));
assert.ok(!PICK_RE.test('--picky foo'));
});
test('ECHO_FALLBACK_RE matches || echo with optional interior whitespace', () => {
assert.ok(ECHO_FALLBACK_RE.test(['a ', '|', '|', ' echo b'].join('')));
assert.ok(ECHO_FALLBACK_RE.test(['a ', '|', '|', ' echo b'].join('')));
assert.ok(!ECHO_FALLBACK_RE.test(['a ', '|', '|', ' printf b'].join('')));
});
test('CAT_LS_COMMAND_RE requires cat/ls immediately at a command-position anchor', () => {
assert.ok(CAT_LS_COMMAND_RE.test('cat x'));
assert.ok(CAT_LS_COMMAND_RE.test('$(cat x)'));
assert.ok(!CAT_LS_COMMAND_RE.test('concatenate x'));
assert.ok(!CAT_LS_COMMAND_RE.test('a cat b'));
});
test('HEREDOC_AFTER_COMMAND_RE matches a heredoc operator immediately after the command, with or without a leading space', () => {
assert.ok(HEREDOC_AFTER_COMMAND_RE.test(" <<'EOF'"));
assert.ok(HEREDOC_AFTER_COMMAND_RE.test('<<EOF'));
assert.ok(!HEREDOC_AFTER_COMMAND_RE.test(' dir/*.md'));
});
test('MARKER_RE captures the reason after the colon, trimming leading whitespace', () => {
// Subject hoisted to a named const rather than passed as a string
// literal directly to the .exec call below — scripts/prompt-injection-scan.sh's
// receiver-blind scan pattern (deliberately kept wide to catch the
// child_process module's exec function invoked with a string) flags a
// quote immediately following an open paren after the token `exec`, with
// no way to distinguish RegExp#exec from that shell-spawning call by
// pattern alone. Do not simplify this back.
const subject = '# gsd-scan-ignore: #3409 rationale';
const m = MARKER_RE.exec(subject);
assert.ok(m);
assert.strictEqual(m[1], '#3409 rationale');
});
});
// ─── REASON enum — locks the typed outcome surface ────────────────────────
//
// CONTRIBUTING.md's "Prohibited: Raw Text Matching on Test Outputs": adding a
// new reason requires updating the REASON enum, the --json emission /
// loadBaseline call site that produces it, AND this test — three coordinated
// changes that keep the code surface from drifting from the test surface.
describe('REASON enum', () => {
test('the frozen enum exposes exactly the documented set of outcome codes', () => {
assert.deepStrictEqual(Object.keys(REASON).sort(), [
'FAIL_BASELINE_EMPTY',
'FAIL_BASELINE_ENTRIES_NOT_ARRAY',
'FAIL_BASELINE_ENTRY_COUNT_INVALID',
'FAIL_BASELINE_ENTRY_FIELD_INVALID',
'FAIL_BASELINE_ENTRY_NOT_OBJECT',
'FAIL_BASELINE_INVALID_JSON',
'FAIL_BASELINE_LOAD',
'FAIL_BASELINE_MISSING',
'FAIL_BASELINE_NOT_OBJECT',
'FAIL_FRESH_VIOLATION',
'FAIL_MALFORMED_MARKER',
'FAIL_STALE_ENTRY',
'OK_BASELINE_UPDATED',
'OK_NO_VIOLATIONS',
]);
});
test('the enum is frozen — an attempted mutation is a no-op (non-strict) / throws (strict)', () => {
assert.throws(() => {
'use strict';
REASON.FAIL_FRESH_VIOLATION = 'tampered';
}, TypeError);
assert.strictEqual(REASON.FAIL_FRESH_VIOLATION, 'fail_fresh_violation');
});
});