vigiles 25.0.0 → 26.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,6 +23,12 @@ export type AdoptTier = "structured" | "raw";
23
23
  export interface AdoptResult {
24
24
  /** Generated `.spec.ts` source (compiles back to ~the original file). */
25
25
  source: string;
26
+ /**
27
+ * Backticked paths that RESOLVED at adoption time and were emitted as verified
28
+ * `file()` refs. Empty when no `exists` predicate was supplied (the faithful,
29
+ * extract-nothing behaviour) or when nothing resolved.
30
+ */
31
+ adoptedRefs?: readonly string[];
26
32
  /**
27
33
  * `structured` = a clean `##`-headed file mapped 1:1 to sections (the diff is
28
34
  * just the canonical h1 + whitespace). `raw` = a heading-less or
@@ -48,6 +54,47 @@ export interface AdoptedSpec {
48
54
  maxSectionLines?: number;
49
55
  tier: AdoptTier;
50
56
  }
57
+ /**
58
+ * Render a section as a template, turning every backticked path that RESOLVES
59
+ * TODAY into a verified `${file("…")}` reference.
60
+ *
61
+ * WHY THIS EXISTS. Adoption used to transcribe faithfully and extract NOTHING —
62
+ * `rules: {}`, every reference left as inert prose — on the stated grounds that
63
+ * cross-referencing is `strengthen`'s later job. An adopter measured what that
64
+ * trade actually costs (2026-08-28, a 51-skill monorepo): running
65
+ * `vigiles init --target=CLAUDE.md` turned the file into one opaque template with
66
+ * zero refs extracted, so "the price is paid immediately — the file becomes a
67
+ * build artifact, hand edits are blocked by a hook, every backtick is escaped —
68
+ * while the benefit is deferred until a human rewrites every reference by hand."
69
+ * The checker was never the problem: he hand-wrote two `file()` calls and compile
70
+ * correctly reported `[stale-file]`, exit 1. The ADOPTION PATH did not populate it,
71
+ * so nobody reached the value and the second step never happened.
72
+ *
73
+ * WHY RESOLVE-NOW IS THE RIGHT FILTER, and not a heuristic. The undecidable
74
+ * question is "is this OUR path or a path inside the third-party repo this
75
+ * document DESCRIBES?" — the same wall that made `doc-refs` default to off after
76
+ * scoring 0 true positives, and that the adopter hit independently (1560
77
+ * path-shaped strings in his corpus, 907 unresolvable, single-digit true
78
+ * positives after filtering; almost all the rest were paths in repos his skills
79
+ * merely describe). Asking instead "does it resolve HERE, right now?" sidesteps
80
+ * it: a described repo's path does not exist locally, so it is never emitted.
81
+ *
82
+ * The consequences are the ones adoption needs:
83
+ * - ZERO new failures at adoption time — only already-green refs are emitted, so
84
+ * `compile` cannot start red on a file that was fine a second earlier;
85
+ * - value from the FIRST compile rather than after a manual pass — the ref goes
86
+ * red exactly when the file moves, which is the entire point of marking it;
87
+ * - a false emit is benign (an extra verified ref), while a false SKIP costs
88
+ * only what the old behaviour already cost.
89
+ *
90
+ * Fence-awareness goes through the shared `fencedLineFlags` oracle, never a
91
+ * private toggle: a path inside a fenced block is example code, and hand-rolled
92
+ * fence state is the exact defect that oracle exists to make unrepresentable.
93
+ */
94
+ export declare function refInterpolatedTemplate(content: string, exists: (p: string) => boolean): {
95
+ template: string;
96
+ refs: string[];
97
+ };
51
98
  /**
52
99
  * Parse an instruction file's markdown into the faithful `instructionFile()` spec FIELDS.
53
100
  * The shared core of {@link adoptMarkdown} and the round-trip tests.
@@ -62,7 +109,9 @@ export declare function adoptToSpec(markdown: string, target: string): AdoptedSp
62
109
  * Convert an instruction file's markdown into a faithful `instructionFile()` spec source
63
110
  * (the deliverable `init` writes).
64
111
  */
65
- export declare function adoptMarkdown(markdown: string, target: string): AdoptResult;
112
+ export declare function adoptMarkdown(markdown: string, target: string, opts?: {
113
+ readonly exists?: (p: string) => boolean;
114
+ }): AdoptResult;
66
115
  export interface AdoptSurfaceResult {
67
116
  /** Generated `.spec.ts` source. */
68
117
  source: string;
@@ -20,6 +20,7 @@
20
20
  * `strengthen`'s separate, later job; adoption is lossless transcription.
21
21
  */
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.refInterpolatedTemplate = refInterpolatedTemplate;
23
24
  exports.adoptToSpec = adoptToSpec;
24
25
  exports.adoptMarkdown = adoptMarkdown;
25
26
  exports.adoptSkill = adoptSkill;
@@ -107,28 +108,119 @@ function tsTemplate(s) {
107
108
  .replace(/\$\{/g, "\\${");
108
109
  return "`" + esc + "`";
109
110
  }
110
- function renderSpecSource(spec) {
111
+ /**
112
+ * A backticked token that looks like a repo-relative path: contains a slash and
113
+ * no whitespace. Deliberately loose — the DECIDING filter is not the shape, it is
114
+ * whether the path RESOLVES (see {@link refInterpolatedTemplate}).
115
+ */
116
+ const PATH_LIKE = /`([^`\s]+)`/g;
117
+ /** Whether a backticked token may be adopted as a verified `file()` reference. */
118
+ function adoptableRef(token, exists) {
119
+ // A path claim, not a symbol or a command: it has a separator.
120
+ if (!token.includes("/"))
121
+ return false;
122
+ // Someone else's world — a URL, an absolute path, a home path, an escape.
123
+ if (/^[a-z][a-z0-9+.-]*:/i.test(token))
124
+ return false;
125
+ if (token.startsWith("/") || token.startsWith("~"))
126
+ return false;
127
+ if (token.includes(".."))
128
+ return false;
129
+ // 🔴 THE WHOLE DESIGN IS THIS LINE. Adoption emits a ref ONLY for a path that
130
+ // resolves in THIS repo right now.
131
+ return exists(token);
132
+ }
133
+ /**
134
+ * Render a section as a template, turning every backticked path that RESOLVES
135
+ * TODAY into a verified `${file("…")}` reference.
136
+ *
137
+ * WHY THIS EXISTS. Adoption used to transcribe faithfully and extract NOTHING —
138
+ * `rules: {}`, every reference left as inert prose — on the stated grounds that
139
+ * cross-referencing is `strengthen`'s later job. An adopter measured what that
140
+ * trade actually costs (2026-08-28, a 51-skill monorepo): running
141
+ * `vigiles init --target=CLAUDE.md` turned the file into one opaque template with
142
+ * zero refs extracted, so "the price is paid immediately — the file becomes a
143
+ * build artifact, hand edits are blocked by a hook, every backtick is escaped —
144
+ * while the benefit is deferred until a human rewrites every reference by hand."
145
+ * The checker was never the problem: he hand-wrote two `file()` calls and compile
146
+ * correctly reported `[stale-file]`, exit 1. The ADOPTION PATH did not populate it,
147
+ * so nobody reached the value and the second step never happened.
148
+ *
149
+ * WHY RESOLVE-NOW IS THE RIGHT FILTER, and not a heuristic. The undecidable
150
+ * question is "is this OUR path or a path inside the third-party repo this
151
+ * document DESCRIBES?" — the same wall that made `doc-refs` default to off after
152
+ * scoring 0 true positives, and that the adopter hit independently (1560
153
+ * path-shaped strings in his corpus, 907 unresolvable, single-digit true
154
+ * positives after filtering; almost all the rest were paths in repos his skills
155
+ * merely describe). Asking instead "does it resolve HERE, right now?" sidesteps
156
+ * it: a described repo's path does not exist locally, so it is never emitted.
157
+ *
158
+ * The consequences are the ones adoption needs:
159
+ * - ZERO new failures at adoption time — only already-green refs are emitted, so
160
+ * `compile` cannot start red on a file that was fine a second earlier;
161
+ * - value from the FIRST compile rather than after a manual pass — the ref goes
162
+ * red exactly when the file moves, which is the entire point of marking it;
163
+ * - a false emit is benign (an extra verified ref), while a false SKIP costs
164
+ * only what the old behaviour already cost.
165
+ *
166
+ * Fence-awareness goes through the shared `fencedLineFlags` oracle, never a
167
+ * private toggle: a path inside a fenced block is example code, and hand-rolled
168
+ * fence state is the exact defect that oracle exists to make unrepresentable.
169
+ */
170
+ function refInterpolatedTemplate(content, exists) {
171
+ const fenced = (0, markdown_js_1.fencedLineFlags)(content);
172
+ const refs = [];
173
+ const rendered = content
174
+ .split("\n")
175
+ .map((line, i) => {
176
+ if (fenced[i])
177
+ return tsTemplate(line).slice(1, -1);
178
+ let out = "";
179
+ let last = 0;
180
+ for (const m of line.matchAll(PATH_LIKE)) {
181
+ const token = m[1];
182
+ if (!adoptableRef(token, exists))
183
+ continue;
184
+ out += tsTemplate(line.slice(last, m.index)).slice(1, -1);
185
+ out += "${file(" + JSON.stringify(token) + ")}";
186
+ refs.push(token);
187
+ last = m.index + m[0].length;
188
+ }
189
+ return out + tsTemplate(line.slice(last)).slice(1, -1);
190
+ })
191
+ .join("\n");
192
+ return { template: "`" + rendered + "`", refs };
193
+ }
194
+ function renderSpecSource(spec, exists) {
111
195
  const targetLine = spec.target !== "CLAUDE.md"
112
196
  ? `\n target: ${JSON.stringify(spec.target)},`
113
197
  : "";
114
198
  const maxLine = spec.maxSectionLines !== undefined
115
199
  ? `\n maxSectionLines: ${String(spec.maxSectionLines)},`
116
200
  : "";
201
+ const adopted = [];
117
202
  const entries = Object.entries(spec.sections)
118
- .map(([key, content]) => ` ${JSON.stringify(key)}: ${tsTemplate(content)},`)
203
+ .map(([key, content]) => {
204
+ if (!exists)
205
+ return ` ${JSON.stringify(key)}: ${tsTemplate(content)},`;
206
+ const { template, refs } = refInterpolatedTemplate(content, exists);
207
+ adopted.push(...refs);
208
+ return ` ${JSON.stringify(key)}: ${template},`;
209
+ })
119
210
  .join("\n");
120
211
  const sectionsBlock = entries
121
212
  ? `\n sections: {\n${entries}\n },`
122
213
  : `\n sections: {},`;
123
- return `// Adopted from ${spec.target} by \`vigiles init\` — faithful by default.
214
+ const source = `// Adopted from ${spec.target} by \`vigiles init\` — faithful by default.
124
215
  // Each heading became a prose section; no rules were inferred. Run the
125
216
  // \`/strengthen\` skill to upgrade prose to verified enforce()/guard() rules.
126
- import { instructionFile } from "vigiles/spec";
217
+ import { instructionFile${adopted.length ? ", file" : ""} } from "vigiles/spec";
127
218
 
128
219
  export default instructionFile({${targetLine}${maxLine}${sectionsBlock}
129
220
  rules: {},
130
221
  });
131
222
  `;
223
+ return { source, refs: adopted };
132
224
  }
133
225
  /**
134
226
  * Parse an instruction file's markdown into the faithful `instructionFile()` spec FIELDS.
@@ -192,12 +284,14 @@ function adoptToSpec(markdown, target) {
192
284
  * Convert an instruction file's markdown into a faithful `instructionFile()` spec source
193
285
  * (the deliverable `init` writes).
194
286
  */
195
- function adoptMarkdown(markdown, target) {
287
+ function adoptMarkdown(markdown, target, opts = {}) {
196
288
  const spec = adoptToSpec(markdown, target);
289
+ const { source, refs } = renderSpecSource(spec, opts.exists);
197
290
  return {
198
- source: renderSpecSource(spec),
291
+ source,
199
292
  tier: spec.tier,
200
293
  sectionCount: Object.keys(spec.sections).length,
294
+ adoptedRefs: refs,
201
295
  };
202
296
  }
203
297
  // Consumes the WHOLE leading frontmatter block (through its closing `---` and the
@@ -133,6 +133,17 @@ export interface NormalizedLeaf {
133
133
  */
134
134
  readonly chdir: string | null;
135
135
  }
136
+ /**
137
+ * Known short↔long flag aliases. Deliberately small and operation-relevant:
138
+ * a caller always gates on the head (e.g. only treats `index-url` as supply-chain
139
+ * when the head is `pip`), so recording both forms unconditionally is safe.
140
+ */
141
+ /** @internal — read by `bash-equivalents.ts` to generate shell-equivalent variants. */
142
+ export declare const SHORT_TO_LONG: Readonly<Record<string, string>>;
143
+ /** @internal — read by `bash-equivalents.ts` to generate shell-equivalent variants. */
144
+ export declare const LONG_TO_SHORT: Readonly<Record<string, string>>;
145
+ /** @internal — read by `bash-equivalents.ts` to generate shell-equivalent variants. */
146
+ export declare const WRAPPER_HEADS: Set<string>;
136
147
  /**
137
148
  * Extract every simple command as a {@link NormalizedLeaf} — the operation-level
138
149
  * twin of {@link leafCommands}. Same AST-backed structural coverage (a leaf nested
@@ -24,6 +24,7 @@
24
24
  * See `research/bash-effect-classification.md` for the full design rationale.
25
25
  */
26
26
  Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.WRAPPER_HEADS = exports.LONG_TO_SHORT = exports.SHORT_TO_LONG = void 0;
27
28
  exports.classifyBashCommand = classifyBashCommand;
28
29
  exports.isReadOnlyBash = isReadOnlyBash;
29
30
  exports.leafCommands = leafCommands;
@@ -462,13 +463,15 @@ function leafCommands(command) {
462
463
  * a caller always gates on the head (e.g. only treats `index-url` as supply-chain
463
464
  * when the head is `pip`), so recording both forms unconditionally is safe.
464
465
  */
465
- const SHORT_TO_LONG = {
466
+ /** @internal — read by `bash-equivalents.ts` to generate shell-equivalent variants. */
467
+ exports.SHORT_TO_LONG = {
466
468
  f: "force",
467
469
  n: "no-verify",
468
470
  r: "recursive",
469
471
  i: "index-url",
470
472
  };
471
- const LONG_TO_SHORT = {
473
+ /** @internal — read by `bash-equivalents.ts` to generate shell-equivalent variants. */
474
+ exports.LONG_TO_SHORT = {
472
475
  force: "f",
473
476
  "no-verify": "n",
474
477
  recursive: "r",
@@ -480,17 +483,22 @@ const LONG_TO_SHORT = {
480
483
  * truly dynamic segment (command substitution, arithmetic, a non-HOME parameter)
481
484
  * — such a word can't be soundly reduced to a literal operation token.
482
485
  */
483
- function normalizeParts(parts) {
486
+ function normalizeParts(parts, inDoubleQuotes = false, unescape = false) {
484
487
  if (!parts)
485
488
  return null;
486
489
  let out = "";
487
490
  for (const p of parts) {
488
491
  const t = sh.syntax.NodeType(p);
489
- if (t === "Lit" || t === "SglQuoted") {
492
+ if (t === "Lit") {
493
+ out += unescape
494
+ ? unescapeLit(p.Value ?? "", inDoubleQuotes)
495
+ : (p.Value ?? "");
496
+ }
497
+ else if (t === "SglQuoted") {
490
498
  out += p.Value ?? "";
491
499
  }
492
500
  else if (t === "DblQuoted") {
493
- const inner = normalizeParts(p.Parts);
501
+ const inner = normalizeParts(p.Parts, true, unescape);
494
502
  if (inner === null)
495
503
  return null;
496
504
  out += inner;
@@ -508,6 +516,25 @@ function normalizeParts(parts) {
508
516
  }
509
517
  return out;
510
518
  }
519
+ /**
520
+ * Resolve the backslashes a shell removes before it runs a word. mvdan-sh keeps
521
+ * them as written (`g\it` parses as `Lit("g\\it")`), yet `sh -c 'g\it --version'`
522
+ * runs git: outside quotes a backslash makes the next character literal and is
523
+ * itself dropped; inside double quotes it does so only before `$`, `` ` ``, `"`, `\\`
524
+ * and a newline. Without this, `g\it push --force` normalized to head `g\it` — a
525
+ * spelling the shell reads as the dangerous command and a `runs("git push")` guard
526
+ * did not (found 2026-09-02 by a reader, not by the battery, which shares this
527
+ * normalizer and so could not). Deliberately NOT expansion: `$VAR`, `eval`, `$(…)`
528
+ * and friends still return null one level up.
529
+ */
530
+ function unescapeLit(raw, inDoubleQuotes) {
531
+ if (!raw.includes("\\"))
532
+ return raw;
533
+ const joined = raw.replace(/\\\n/g, ""); // `\<newline>` is a continuation: both go
534
+ return inDoubleQuotes
535
+ ? joined.replace(/\\([$`"\\])/g, "$1")
536
+ : joined.replace(/\\(.)/g, "$1");
537
+ }
511
538
  /** Normalize a command head to its basename, stripping one leading backslash. */
512
539
  function normalizeHead(raw) {
513
540
  const unescaped = raw.startsWith("\\") ? raw.slice(1) : raw;
@@ -521,9 +548,9 @@ function buildFlags(args) {
521
548
  if (!name)
522
549
  return;
523
550
  flags.add(name);
524
- if (name.length === 1 && SHORT_TO_LONG[name])
525
- flags.add(SHORT_TO_LONG[name]);
526
- const short = LONG_TO_SHORT[name];
551
+ if (name.length === 1 && exports.SHORT_TO_LONG[name])
552
+ flags.add(exports.SHORT_TO_LONG[name]);
553
+ const short = exports.LONG_TO_SHORT[name];
527
554
  if (short)
528
555
  flags.add(short);
529
556
  };
@@ -554,7 +581,8 @@ function buildFlags(args) {
554
581
  // `sudo timeout 5 rm -rf /` unwraps fully). A wrapper with no following command
555
582
  // (bare `env`, `env -i`) is preserved as-is, so the env-dump predicate still fires.
556
583
  // ---------------------------------------------------------------------------
557
- const WRAPPER_HEADS = new Set([
584
+ /** @internal — read by `bash-equivalents.ts` to generate shell-equivalent variants. */
585
+ exports.WRAPPER_HEADS = new Set([
558
586
  "env",
559
587
  "command",
560
588
  "nice",
@@ -677,7 +705,7 @@ function stripWrappers(argv) {
677
705
  let cur = argv;
678
706
  for (let guard = 0; guard < 8; guard++) {
679
707
  const head = cur[0];
680
- if (head === undefined || !WRAPPER_HEADS.has(head))
708
+ if (head === undefined || !exports.WRAPPER_HEADS.has(head))
681
709
  break;
682
710
  const valueOpts = WRAPPER_VALUE_OPTS[head] ?? new Set();
683
711
  const chdirOpts = WRAPPER_CHDIR_OPTS[head] ?? new Set();
@@ -1069,12 +1097,24 @@ function collectAssigns(node) {
1069
1097
  function normalizeCallExpr(node, redirs) {
1070
1098
  if (sh.syntax.NodeType(node) !== "CallExpr" || !node.Args?.length)
1071
1099
  return null;
1072
- const headRaw = normalizeParts(node.Args[0]?.Parts);
1100
+ const headRaw = normalizeParts(node.Args[0]?.Parts, false, true);
1073
1101
  if (headRaw === null)
1074
1102
  return null; // dynamic head → not normalizable
1075
1103
  const rawHead = normalizeHead(headRaw);
1104
+ // Backslash resolution answers "what OPERATION is this" — the head and its
1105
+ // FLAGS — and must not touch a PATH operand. The two need opposite answers for
1106
+ // the SAME bytes: `sh` reads `--fo\rce` as `--force` (so a `{force:true}` guard
1107
+ // that missed it was open), while `\\SERVER\SHARE\repo\SECRETS\x` is a real
1108
+ // Windows UNC path whose backslashes a denylist must keep, or the write it
1109
+ // names stops matching `secrets` and the guard allows it. A word starting `-`
1110
+ // is never a path, so the split is decidable per word rather than guessed.
1076
1111
  const rawArgs = node.Args.slice(1)
1077
- .map((w) => normalizeParts(w.Parts))
1112
+ .map((w) => {
1113
+ const raw = normalizeParts(w.Parts);
1114
+ if (raw === null || !raw.startsWith("-"))
1115
+ return raw;
1116
+ return normalizeParts(w.Parts, false, true) ?? raw;
1117
+ })
1078
1118
  .filter((w) => w !== null);
1079
1119
  // Resolve through any command-wrapper (`env`/`command`/`sudo`/`timeout`/…) so
1080
1120
  // the leaf reflects the REAL operation, not the wrapper head.
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Does `variant` perform every operation `seed` performs?
3
+ *
4
+ * Not string equality and not set equality: a wrapper adds a leaf (`sudo` itself),
5
+ * so the test is CONTAINMENT — every dangerous leaf of the seed still appears.
6
+ */
7
+ export declare function sameOperation(seed: string, variant: string): boolean;
8
+ /**
9
+ * Every shell-equivalent rewrite of `seed` the families can produce.
10
+ *
11
+ * 🔴 A candidate that does NOT satisfy {@link sameOperation} THROWS rather than
12
+ * being dropped. A silent drop would hide a generator bug behind a smaller
13
+ * corpus — the battery would quietly shrink and still look like it ran.
14
+ * A family that does not APPLY (no flag to quote) yields nothing, which is
15
+ * different from producing something wrong.
16
+ */
17
+ export declare function equivalentCommands(seed: string): readonly string[];
18
+ //# sourceMappingURL=bash-equivalents.d.ts.map
@@ -0,0 +1,239 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sameOperation = sameOperation;
4
+ exports.equivalentCommands = equivalentCommands;
5
+ /**
6
+ * Shell-EQUIVALENT rewrites of a dangerous command — the generator behind the
7
+ * metamorphic disaster battery.
8
+ *
9
+ * WHY THIS EXISTS. `DISASTER_CATALOG` is seven commands a human labelled
10
+ * dangerous, written one way each. Measured 2026-09-02: the compiled guard behind
11
+ * the published "2/7 → 7/7" headline blocked all seven seeds and only 8 of 30
12
+ * shell-equivalent rewrites of them — `git push "--force" origin main` (a quoted
13
+ * flag), `sudo git push --force`, `/usr/bin/git push --force`. The number was
14
+ * true and it was measured on the only forms anyone had written down.
15
+ *
16
+ * WHY A GENERATOR AND NOT MORE HAND-WRITTEN ROWS. Adding rows by hand closes the
17
+ * forms you thought of, which is the same bounded set that produced the gap. The
18
+ * escape space is not enumerable by memory.
19
+ *
20
+ * WHY THIS NEEDS NO ORACLE — the part that makes it sound rather than clever.
21
+ * Generating a NEW dangerous command would need a human to label it. This
22
+ * generates only REWRITES of an already-labelled one, so:
23
+ *
24
+ * dangerous ← inherited from the seed a human labelled
25
+ * same thing ← decided by `leafCommandsNormalized`, our own normalizer
26
+ *
27
+ * Neither half is a new judgement. This is metamorphic testing (Chen et al.,
28
+ * 1998): with no oracle for a fresh input, assert instead that a
29
+ * semantics-preserving transform does not change the verdict.
30
+ *
31
+ * WHERE IT STOPS, and why the boundary is not a policy choice. The transform
32
+ * families are exactly the ones the normalizer collapses. `eval`, `sh -c`, a
33
+ * `$VAR` head, base64 — the normalizer returns null for those, so a variant
34
+ * built from them CANNOT pass the self-check and is never emitted. That is the
35
+ * correct boundary: a guard built on `runs()` genuinely cannot see through
36
+ * `eval "$(echo … | base64 -d)"`, so emitting it would call a correct guard
37
+ * broken — the crying-wolf failure that gets a check switched off.
38
+ */
39
+ const bash_effects_js_1 = require("./bash-effects.js");
40
+ /** The operation a leaf performs, ignoring how it was spelled. */
41
+ function operationKey(leaf) {
42
+ return JSON.stringify([
43
+ leaf.head,
44
+ leaf.args.filter((a) => a !== "" && !a.startsWith("-")),
45
+ [...leaf.flags].sort(),
46
+ ]);
47
+ }
48
+ /**
49
+ * Does `variant` perform every operation `seed` performs?
50
+ *
51
+ * Not string equality and not set equality: a wrapper adds a leaf (`sudo` itself),
52
+ * so the test is CONTAINMENT — every dangerous leaf of the seed still appears.
53
+ */
54
+ function sameOperation(seed, variant) {
55
+ const a = (0, bash_effects_js_1.leafCommandsNormalized)(seed);
56
+ const b = (0, bash_effects_js_1.leafCommandsNormalized)(variant);
57
+ if (a.length === 0 || b.length === 0)
58
+ return false;
59
+ const keys = new Set(b.map(operationKey));
60
+ return a.every((leaf) => keys.has(operationKey(leaf)));
61
+ }
62
+ /** Wrappers that pass a command through unchanged, one representative form each. */
63
+ const WRAPPER_PREFIXES = [...bash_effects_js_1.WRAPPER_HEADS]
64
+ .filter((w) => w !== "xargs" && w !== "nohup")
65
+ .map((w) => w === "timeout" ? "timeout 30" : w === "nice" ? "nice -n 5" : w);
66
+ const FAMILIES = [
67
+ {
68
+ name: "quoted flag",
69
+ // `getLiteral` returns null for a quoted word and `leafCommands` filters
70
+ // nulls, so this is the family that made a flag VANISH from argv.
71
+ rewrite: (c) => [...c.matchAll(/(?<=\s)(--?[A-Za-z][\w-]*)(?=\s|$)/g)].flatMap((m) => [
72
+ c.replace(m[1], `"${m[1]}"`),
73
+ c.replace(m[1], `'${m[1]}'`),
74
+ ]),
75
+ },
76
+ {
77
+ name: "flag alias",
78
+ rewrite: (c) => {
79
+ const out = [];
80
+ for (const [long, short] of Object.entries(bash_effects_js_1.LONG_TO_SHORT))
81
+ if (c.includes(`--${long}`))
82
+ out.push(c.replace(`--${long}`, `-${short}`));
83
+ for (const [short, long] of Object.entries(bash_effects_js_1.SHORT_TO_LONG)) {
84
+ const re = new RegExp(`(?<=\\s)-${short}(?=\\s|$)`);
85
+ if (re.test(c))
86
+ out.push(c.replace(re, `--${long}`));
87
+ }
88
+ return out;
89
+ },
90
+ },
91
+ {
92
+ name: "absolute or escaped head",
93
+ rewrite: (c) => {
94
+ const head = c.trimStart().split(/\s+/)[0];
95
+ if (!head || head.includes("/") || head.startsWith("\\"))
96
+ return [];
97
+ const rest = c.trimStart().slice(head.length);
98
+ return [
99
+ `/usr/bin/${head}${rest}`,
100
+ `/bin/${head}${rest}`,
101
+ `\\${head}${rest}`,
102
+ ];
103
+ },
104
+ },
105
+ {
106
+ name: "wrapper prefix",
107
+ rewrite: (c) => WRAPPER_PREFIXES.map((w) => `${w} ${c.trimStart()}`),
108
+ },
109
+ // The four families below are the shell's OWN obfuscations — what promptfoo's
110
+ // base64/leetspeak strategies are for a model, these are for `sh`: the shell
111
+ // itself decodes them, so they pass the equivalence check, and a guard that
112
+ // matches the source string (a grep, a substring) does not see them.
113
+ {
114
+ // `g""it`, `"git"`, `gi"t"` — a quote pair inside or around a word is removed
115
+ // by the shell before the word runs; a substring guard sees the quotes.
116
+ name: "quoted head",
117
+ rewrite: (c) => {
118
+ const { head, rest } = splitHead(c);
119
+ if (!head || !/^[A-Za-z][\w.-]*$/.test(head))
120
+ return [];
121
+ return [
122
+ `${head.slice(0, 1)}""${head.slice(1)}${rest}`,
123
+ `"${head}"${rest}`,
124
+ `${head.slice(0, -1)}"${head.slice(-1)}"${rest}`,
125
+ ];
126
+ },
127
+ },
128
+ {
129
+ // `$'git'` — ANSI-C quoting; with no escape inside it is the plain word.
130
+ name: "ansi-c quoted head",
131
+ rewrite: (c) => {
132
+ const { head, rest } = splitHead(c);
133
+ if (!head || !/^[A-Za-z][\w.-]*$/.test(head))
134
+ return [];
135
+ return [`$'${head}'${rest}`];
136
+ },
137
+ },
138
+ {
139
+ // `g\it` — a backslash before an ordinary character is that character.
140
+ // Distinct from the leading `\git` in "absolute or escaped head": that one
141
+ // is the alias-bypass idiom people actually type; this one nobody types,
142
+ // which is exactly why a hand-written matcher never lists it.
143
+ name: "escaped character in head",
144
+ rewrite: (c) => {
145
+ const { head, rest } = splitHead(c);
146
+ if (!head || !/^[A-Za-z]{2,}[\w.-]*$/.test(head))
147
+ return [];
148
+ return [`${head.slice(0, 1)}\\${head.slice(1)}${rest}`];
149
+ },
150
+ },
151
+ {
152
+ // `git push --force` / `git<TAB>push<TAB>--force` — any run of blanks
153
+ // between words is one separator to the shell; blanks inside quotes are kept.
154
+ name: "whitespace between words",
155
+ rewrite: (c) => [joinWords(c, " "), joinWords(c, "\t")],
156
+ },
157
+ {
158
+ // A leading `NAME=value` is an ENVIRONMENT ASSIGNMENT scoped to this one
159
+ // command, not an argument to it: the shell strips the assignments and runs
160
+ // what follows, so `FOO=1 git push --force` IS `git push --force`. Named by
161
+ // an adopter (2026-08-28) as a form their own guard's tests did not cover.
162
+ //
163
+ // Our compiled guard already blocked it — `runs()` reads the parsed leaf, so
164
+ // the assignments were never in its way. A guard that greps the command
165
+ // STRING has no such luck, and that is who the battery exists for.
166
+ name: "env assignment prefix",
167
+ rewrite: (c) => {
168
+ const cmd = c.trimStart();
169
+ return [`FOO=1 ${cmd}`, `GIT_TERMINAL_PROMPT=0 LC_ALL=C ${cmd}`];
170
+ },
171
+ },
172
+ ];
173
+ /** The first word of a command and everything after it, leading blanks dropped. */
174
+ function splitHead(cmd) {
175
+ const trimmed = cmd.trimStart();
176
+ const head = trimmed.split(/\s+/)[0] ?? "";
177
+ return { head, rest: trimmed.slice(head.length) };
178
+ }
179
+ /**
180
+ * Replace every run of blanks OUTSIDE quotes with `sep`. Quote-aware by hand
181
+ * because the point is to re-spell the SOURCE; a run inside `'skip hooks'` is
182
+ * data and must survive as written.
183
+ */
184
+ function joinWords(cmd, sep) {
185
+ let out = "";
186
+ let quote = null;
187
+ let i = 0;
188
+ const src = cmd.trim();
189
+ while (i < src.length) {
190
+ const ch = src[i] ?? "";
191
+ if (quote) {
192
+ out += ch;
193
+ if (ch === quote)
194
+ quote = null;
195
+ i++;
196
+ }
197
+ else if (ch === "'" || ch === '"') {
198
+ quote = ch;
199
+ out += ch;
200
+ i++;
201
+ }
202
+ else if (ch === " " || ch === "\t") {
203
+ while (src[i] === " " || src[i] === "\t")
204
+ i++;
205
+ out += sep;
206
+ }
207
+ else {
208
+ out += ch;
209
+ i++;
210
+ }
211
+ }
212
+ return out;
213
+ }
214
+ /**
215
+ * Every shell-equivalent rewrite of `seed` the families can produce.
216
+ *
217
+ * 🔴 A candidate that does NOT satisfy {@link sameOperation} THROWS rather than
218
+ * being dropped. A silent drop would hide a generator bug behind a smaller
219
+ * corpus — the battery would quietly shrink and still look like it ran.
220
+ * A family that does not APPLY (no flag to quote) yields nothing, which is
221
+ * different from producing something wrong.
222
+ */
223
+ function equivalentCommands(seed) {
224
+ const out = new Set();
225
+ for (const family of FAMILIES) {
226
+ for (const candidate of family.rewrite(seed)) {
227
+ if (candidate === seed)
228
+ continue;
229
+ if (!sameOperation(seed, candidate)) {
230
+ throw new Error(`bash-equivalents: family "${family.name}" produced a NON-equivalent ` +
231
+ `rewrite of ${JSON.stringify(seed)}: ${JSON.stringify(candidate)}. ` +
232
+ `A variant that changes the operation would blame a correct guard.`);
233
+ }
234
+ out.add(candidate);
235
+ }
236
+ }
237
+ return [...out];
238
+ }
239
+ //# sourceMappingURL=bash-equivalents.js.map
@@ -35,11 +35,11 @@ export declare function readNpmScripts(basePath: string): string[];
35
35
  * Collect commands documented in specs by loading spec source files directly.
36
36
  * Reads the structured `commands` field — no markdown parsing.
37
37
  */
38
- export declare function collectDocumentedCommands(basePath: string, specs?: ClaudeSpec[]): Set<string>;
38
+ export declare function collectDocumentedCommands(basePath: string, specs: ClaudeSpec[] | undefined, ignore: readonly string[]): Set<string>;
39
39
  /**
40
40
  * Compute script coverage: what % of npm scripts are documented in specs.
41
41
  */
42
- export declare function computeScriptCoverage(basePath: string, threshold?: number, specs?: ClaudeSpec[]): CoverageMetric;
42
+ export declare function computeScriptCoverage(basePath: string, threshold: number | undefined, specs: ClaudeSpec[] | undefined, ignore: readonly string[]): CoverageMetric;
43
43
  /**
44
44
  * Compute linter rule coverage from pre-computed totals.
45
45
  * The actual linter scanning is done by the existing discover() in cli.ts.
@@ -48,7 +48,7 @@ export declare function computeLinterRuleCoverage(enabled: number, documented: n
48
48
  /**
49
49
  * Check all coverage metrics against thresholds.
50
50
  */
51
- export declare function checkCoverage(basePath: string, thresholds: CoverageThresholds, linterEnabled: number, linterDocumented: number, specs?: ClaudeSpec[]): CoverageReport;
51
+ export declare function checkCoverage(basePath: string, thresholds: CoverageThresholds, linterEnabled: number, linterDocumented: number, specs: ClaudeSpec[] | undefined, ignore: readonly string[]): CoverageReport;
52
52
  /**
53
53
  * Format coverage report as human-readable text.
54
54
  */