mandrel-platform 1.13.0 → 1.13.2

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.
@@ -25,14 +25,38 @@
25
25
  *
26
26
  * WHAT COUNTS AS A DOWNLOAD
27
27
  * -------------------------
28
- * A `curl` invocation that writes a fetched artifact to a file — `-o <path>` or
29
- * `--output <path>`. Deliberately NOT a download, and so not flagged:
30
- * • a `curl` with no output flag (a status probe, a POST);
28
+ * Any fetch that lands in a file, in every spelling a shell author reaches for
29
+ * — narrowing the scope to one spelling is how a real download escapes the
30
+ * guard. In scope:
31
+ * • `curl -o <path>` / `curl --output <path>` / `curl --output=<path>`;
32
+ * • a combined curl short group whose value-taking tail is the output flag,
33
+ * e.g. `curl -sSLo <path>` — the same command, written shorter;
34
+ * • `curl -O` / `curl --remote-name`, which name the file from the URL
35
+ * instead of taking a path (and so may sit anywhere in a short group,
36
+ * `-fsSLO` included, because they take no value);
37
+ * • `wget -O <path>` / `wget --output-document <path>` — reported against
38
+ * curl's flag spelling below, deliberately: the first-party fetches are
39
+ * all curl, and a new wget one should join them rather than invent a
40
+ * second resilience contract.
41
+ * Deliberately NOT a download, and so not flagged:
42
+ * • a `curl` with no output flag at all (a status probe, a POST, a fetch
43
+ * piped straight into another command);
44
+ * • a write to stdout — `-o -`, `--output -`, and wget's `-O -`. `-O` is the
45
+ * two commands' false friend: curl's takes no argument (so `curl -O -`
46
+ * fetches the URL `-`), while wget's is the output path;
31
47
  * • `-o /dev/null`, which is a reachability/status check, not an asset fetch
32
48
  * (`pr-quality.yml`'s fail-fast cancellation POST is exactly this shape).
33
49
  * Retrying a POST is a different decision with different safety, and this lint
34
50
  * deliberately does not make it.
35
51
  *
52
+ * HOW FLAGS ARE MATCHED
53
+ * ---------------------
54
+ * As whole shell words, never as substrings: `--retry-connrefused` does not
55
+ * satisfy `--retry`, and a flag sitting inside a trailing `#` comment does not
56
+ * count. A `#` opens a comment only at start of line or after whitespace and
57
+ * outside quotes, so `$#`, `${#arr[@]}` and a fragment `#` inside a quoted URL
58
+ * are left alone. Every pattern here is a literal regex.
59
+ *
36
60
  * SCOPE: `.github/actions/**` action manifests. Exit 0 when clean, 1 when any
37
61
  * download is missing a required flag (prints file:line and the missing set).
38
62
  */
@@ -64,6 +88,21 @@ export const REQUIRED_FLAGS = Object.freeze([
64
88
  }),
65
89
  ]);
66
90
 
91
+ /** `curl` invoked as a command — after whitespace or a shell operator. */
92
+ const CURL_COMMAND = /(^|[\s;&|(])curl(\s|$)/;
93
+ /** `wget` invoked as a command — same boundary rule. */
94
+ const WGET_COMMAND = /(^|[\s;&|(])wget(\s|$)/;
95
+ /** A combined short-option group: one `-` followed by letters only. */
96
+ const SHORT_GROUP = /^-[A-Za-z]+$/;
97
+ /** Quote characters wrapping a word, stripped so a path compares as itself. */
98
+ const LEADING_QUOTES = /^['"]+/;
99
+ const TRAILING_QUOTES = /['"]+$/;
100
+ /** Output targets that are not a fetched asset on disk. */
101
+ const STDOUT_TARGETS = new Set(['-', '/dev/null']);
102
+ /** Long output flags in their `--flag=value` spelling. */
103
+ const CURL_OUTPUT_EQ = '--output=';
104
+ const WGET_OUTPUT_EQ = '--output-document=';
105
+
67
106
  /**
68
107
  * Pure: fold shell line-continuations so a `curl` split across several lines is
69
108
  * linted as the single command it is. The logical line keeps the 1-based number
@@ -95,18 +134,122 @@ export function collapseContinuations(source) {
95
134
  }
96
135
 
97
136
  /**
98
- * Pure: does this logical line invoke `curl` to write a fetched artifact to a
99
- * real file? See the header for what is deliberately excluded.
137
+ * Pure: drop a shell comment and everything after it.
138
+ *
139
+ * `#` only opens a comment at the start of the line or after whitespace, and
140
+ * only outside quotes — which is what keeps `$#`, `${#arr[@]}` and the fragment
141
+ * in `"https://host/p#frag"` from truncating the command that carries them.
142
+ *
143
+ * @param {string} text
144
+ * @returns {string}
145
+ */
146
+ export function stripShellComment(text) {
147
+ let quote = null;
148
+ for (let i = 0; i < text.length; i += 1) {
149
+ const char = text[i];
150
+ if (char === '\\' && quote !== "'") {
151
+ i += 1;
152
+ continue;
153
+ }
154
+ if (quote !== null) {
155
+ if (char === quote) quote = null;
156
+ continue;
157
+ }
158
+ if (char === '"' || char === "'") {
159
+ quote = char;
160
+ continue;
161
+ }
162
+ const opensComment =
163
+ char === '#' && (i === 0 || text[i - 1] === ' ' || text[i - 1] === '\t');
164
+ if (opensComment) return text.slice(0, i);
165
+ }
166
+ return text;
167
+ }
168
+
169
+ /**
170
+ * Pure: the shell words of a logical line — comment dropped, split on
171
+ * whitespace, wrapping quotes removed so `"-"` compares equal to `-`.
172
+ *
173
+ * @param {string} text
174
+ * @returns {string[]}
175
+ */
176
+ export function shellWords(text) {
177
+ return stripShellComment(text)
178
+ .split(/\s+/)
179
+ .filter((word) => word.length > 0)
180
+ .map((word) => word.replace(LEADING_QUOTES, '').replace(TRAILING_QUOTES, ''));
181
+ }
182
+
183
+ /**
184
+ * Pure: does this curl invocation write its body to a real file? See the
185
+ * header for the spellings in scope and the ones deliberately excluded.
186
+ *
187
+ * @param {string[]} words
188
+ * @returns {boolean}
189
+ */
190
+ function curlWritesFile(words) {
191
+ for (let i = 0; i < words.length; i += 1) {
192
+ const word = words[i];
193
+ const shortGroup = SHORT_GROUP.test(word);
194
+ // `-O` takes no value, so it may sit anywhere in a group (`-fsSLO`).
195
+ if (word === '--remote-name' || (shortGroup && word.includes('O'))) return true;
196
+ if (word.startsWith(CURL_OUTPUT_EQ)) {
197
+ return !STDOUT_TARGETS.has(word.slice(CURL_OUTPUT_EQ.length));
198
+ }
199
+ // `-o` takes a value, so in a group it must be the tail: `-sSLo <path>`.
200
+ if (word === '--output' || (shortGroup && word.endsWith('o'))) {
201
+ return !STDOUT_TARGETS.has(words[i + 1] ?? '-');
202
+ }
203
+ }
204
+ return false;
205
+ }
206
+
207
+ /**
208
+ * Pure: does this wget invocation write its body to a real file? Unlike curl,
209
+ * wget's `-O` IS the output path — `wget -O - "$url"` is a stdout pipe.
210
+ *
211
+ * @param {string[]} words
212
+ * @returns {boolean}
213
+ */
214
+ function wgetWritesFile(words) {
215
+ for (let i = 0; i < words.length; i += 1) {
216
+ const word = words[i];
217
+ if (word.startsWith(WGET_OUTPUT_EQ)) {
218
+ return !STDOUT_TARGETS.has(word.slice(WGET_OUTPUT_EQ.length));
219
+ }
220
+ if (word === '-O' || word === '--output-document') {
221
+ return !STDOUT_TARGETS.has(words[i + 1] ?? '-');
222
+ }
223
+ }
224
+ return false;
225
+ }
226
+
227
+ /**
228
+ * Pure: does this logical line fetch an artifact into a file? See the header
229
+ * for every spelling in scope and for what is deliberately excluded.
100
230
  *
101
231
  * @param {string} text
102
232
  * @returns {boolean}
103
233
  */
104
234
  export function isAssetDownload(text) {
105
- if (!/(^|[\s;&|(])curl(\s|$)/.test(text)) return false;
106
- const output = text.match(/(?:^|\s)(?:-o|--output)\s+(\S+)/);
107
- if (!output) return false;
108
- // `-o /dev/null` is a status probe, not an asset fetch.
109
- return !/^["']?\/dev\/null["']?$/.test(output[1]);
235
+ const command = stripShellComment(text);
236
+ const words = shellWords(command);
237
+ if (CURL_COMMAND.test(command)) return curlWritesFile(words);
238
+ if (WGET_COMMAND.test(command)) return wgetWritesFile(words);
239
+ return false;
240
+ }
241
+
242
+ /**
243
+ * Pure: is `flag` present as its own argument — on its own or as `flag=value`?
244
+ * Substring matching is what let `--retry-connrefused` satisfy `--retry`.
245
+ *
246
+ * @param {string[]} words
247
+ * @param {string} flag
248
+ * @returns {boolean}
249
+ */
250
+ function hasFlag(words, flag) {
251
+ const assigned = `${flag}=`;
252
+ return words.some((word) => word === flag || word.startsWith(assigned));
110
253
  }
111
254
 
112
255
  /**
@@ -116,7 +259,8 @@ export function isAssetDownload(text) {
116
259
  * @returns {string[]}
117
260
  */
118
261
  export function missingFlags(text) {
119
- return REQUIRED_FLAGS.filter(({ flag }) => !text.includes(flag)).map(
262
+ const words = shellWords(text);
263
+ return REQUIRED_FLAGS.filter(({ flag }) => !hasFlag(words, flag)).map(
120
264
  ({ flag }) => flag,
121
265
  );
122
266
  }
@@ -1,4 +1,4 @@
1
- // Unit coverage for the asset-download retry lint (Story #446).
1
+ // Unit coverage for the asset-download retry lint (Story #446, tightened in #490).
2
2
  //
3
3
  // The claim worth pinning is that this guard FAILS on the defect it exists to
4
4
  // catch. A lint only ever asserted against a passing tree is indistinguishable
@@ -6,6 +6,11 @@
6
6
  // this repo has been bitten by before, so every rejection case below feeds the
7
7
  // checker a fixture it must refuse.
8
8
  //
9
+ // Story #490 added the second half of that claim: a guard that can be SATISFIED
10
+ // without the invariant does not guard it either. `--retry-connrefused` used to
11
+ // satisfy `--retry` by substring, a flag inside a `#` comment counted, and four
12
+ // of the five ways to spell a download were outside the checker's scope.
13
+ //
9
14
  // Run: node --test scripts/check-action-download-retries.test.mjs
10
15
 
11
16
  import { test } from "node:test";
@@ -19,6 +24,8 @@ import {
19
24
  isAssetDownload,
20
25
  lintSource,
21
26
  missingFlags,
27
+ shellWords,
28
+ stripShellComment,
22
29
  } from "./check-action-download-retries.mjs";
23
30
 
24
31
  /** The exact shape that failed run 34133838332. */
@@ -38,6 +45,9 @@ const HARDENED_DOWNLOAD = BARE_DOWNLOAD.replace(
38
45
  "curl -fsSL --retry 3 --retry-connrefused --max-time 300 ",
39
46
  );
40
47
 
48
+ /** Every required flag, in table order — the full-miss expectation. */
49
+ const ALL_REQUIRED = REQUIRED_FLAGS.map(({ flag }) => flag);
50
+
41
51
  // ---------------------------------------------------------------------------
42
52
  // The guard must REJECT — the case that makes it worth having
43
53
  // ---------------------------------------------------------------------------
@@ -66,6 +76,141 @@ test("a download carrying only SOME required flags is still rejected", () => {
66
76
  assert.deepEqual(finding.missing, ["--retry-connrefused", "--max-time"]);
67
77
  });
68
78
 
79
+ // ---------------------------------------------------------------------------
80
+ // Flags are whole words, not substrings (Story #490 / AC-1)
81
+ // ---------------------------------------------------------------------------
82
+
83
+ test("a sibling flag does not satisfy the flag it contains", () => {
84
+ // `--retry-connrefused` contains `--retry`, which is exactly how a download
85
+ // with no retry budget at all used to pass this lint.
86
+ assert.deepEqual(
87
+ missingFlags('curl -fsSL --retry-connrefused --max-time 300 "$u" -o x'),
88
+ ["--retry"],
89
+ );
90
+ const [finding] = lintSource(
91
+ "action.yml",
92
+ BARE_DOWNLOAD.replace(
93
+ "curl -fsSL ",
94
+ "curl -fsSL --retry-connrefused --max-time 300 ",
95
+ ),
96
+ );
97
+ assert.deepEqual(finding.missing, ["--retry"], "the lint reports it too");
98
+ });
99
+
100
+ test("a flag is present as its own word or as `flag=value`", () => {
101
+ assert.deepEqual(
102
+ missingFlags('curl --retry=3 --retry-connrefused --max-time=300 -o x "$u"'),
103
+ [],
104
+ "curl's `--flag=value` spelling counts",
105
+ );
106
+ assert.deepEqual(
107
+ missingFlags('curl --retry-max-time 60 -o x "$u"'),
108
+ ALL_REQUIRED,
109
+ "a longer flag that merely starts with a required one satisfies nothing",
110
+ );
111
+ });
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // A `#` comment hides nothing, and shell `#` is not a comment (AC-2, AC-4)
115
+ // ---------------------------------------------------------------------------
116
+
117
+ test("a flag inside a trailing comment does not count", () => {
118
+ assert.deepEqual(
119
+ missingFlags('curl -fsSL -o x "$u" # --retry 3 --retry-connrefused --max-time 300'),
120
+ ALL_REQUIRED,
121
+ );
122
+ assert.deepEqual(
123
+ missingFlags("# curl --retry 3 --retry-connrefused --max-time 300 -o x"),
124
+ ALL_REQUIRED,
125
+ "a whole-line comment carries no flags either",
126
+ );
127
+ });
128
+
129
+ test("a `#` inside a quoted URL does not truncate the command", () => {
130
+ assert.deepEqual(
131
+ missingFlags(
132
+ 'curl -o x "https://host/p#frag" --retry 3 --retry-connrefused --max-time 300',
133
+ ),
134
+ [],
135
+ "flags after the fragment are still matched",
136
+ );
137
+ assert.equal(
138
+ isAssetDownload('curl -fsSL "https://host/p#frag" -o "$f"'),
139
+ true,
140
+ "the fragment does not hide the output flag either",
141
+ );
142
+ });
143
+
144
+ test("shell `#` expansions are not comments", () => {
145
+ assert.deepEqual(
146
+ missingFlags(
147
+ 'curl "${#arr[@]}" --retry 3 --retry-connrefused --max-time 300 -o x "$u"',
148
+ ),
149
+ [],
150
+ "`${#arr[@]}` before the flags leaves them visible",
151
+ );
152
+ assert.equal(stripShellComment('echo "$#" --retry'), 'echo "$#" --retry');
153
+ assert.equal(stripShellComment("len=${#arr[@]} --retry"), "len=${#arr[@]} --retry");
154
+ assert.equal(stripShellComment("curl -o x \\# --retry"), "curl -o x \\# --retry");
155
+ });
156
+
157
+ test("stripShellComment cuts at the first real comment", () => {
158
+ assert.equal(stripShellComment("curl -o x # note"), "curl -o x ");
159
+ assert.equal(stripShellComment("#note"), "");
160
+ assert.equal(stripShellComment("curl -o x\t# note"), "curl -o x\t");
161
+ assert.equal(stripShellComment("curl -o x"), "curl -o x");
162
+ assert.equal(stripShellComment("echo 'a # b' # note"), "echo 'a # b' ");
163
+ });
164
+
165
+ test("shellWords splits on whitespace and unwraps quoting", () => {
166
+ assert.deepEqual(shellWords(' curl -o "$f" "$u" # x'), ["curl", "-o", "$f", "$u"]);
167
+ assert.deepEqual(shellWords('curl -o "-"'), ["curl", "-o", "-"]);
168
+ assert.deepEqual(shellWords(""), []);
169
+ });
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Every download spelling is in scope (AC-3)
173
+ // ---------------------------------------------------------------------------
174
+
175
+ test("every spelling that writes a fetched artifact to a file is a download", () => {
176
+ const downloads = [
177
+ 'curl -fsSL "$u" -o "${tmp}/${asset}"',
178
+ 'curl -sSLo "$f" "$u"',
179
+ 'curl --output "$f" "$u"',
180
+ 'curl --output="$f" "$u"',
181
+ 'curl -fsSL -O "$u"',
182
+ 'curl -fsSLO "$u"',
183
+ 'curl --remote-name "$u"',
184
+ 'wget -O x "$u"',
185
+ 'wget --output-document x "$u"',
186
+ 'wget --output-document=x "$u"',
187
+ ];
188
+ for (const line of downloads) {
189
+ assert.equal(isAssetDownload(line), true, `${line} is a download`);
190
+ }
191
+ });
192
+
193
+ test("a fetch that never lands in a file is not a download", () => {
194
+ const notDownloads = [
195
+ 'curl -o - "$u"',
196
+ 'curl --output - "$u"',
197
+ 'curl -sSLo - "$u"',
198
+ 'wget -O - "$u"',
199
+ 'curl -fsSL "$u" | tar -xz',
200
+ 'curl -fsSL "https://example.test/health"',
201
+ 'curl -sS -o /dev/null -w "%{http_code}" -X POST "$api"',
202
+ ];
203
+ for (const line of notDownloads) {
204
+ assert.equal(isAssetDownload(line), false, `${line} is not a download`);
205
+ }
206
+ });
207
+
208
+ test("a wget download is held to the same contract", () => {
209
+ const [finding] = lintSource("action.yml", ' wget -O "$f" "$u"');
210
+ assert.ok(finding, "an unhardened wget fetch is caught, not silently skipped");
211
+ assert.deepEqual(finding.missing, ALL_REQUIRED);
212
+ });
213
+
69
214
  // ---------------------------------------------------------------------------
70
215
  // The guard must ACCEPT — no false positives on the shapes that are fine
71
216
  // ---------------------------------------------------------------------------
@@ -83,13 +228,18 @@ test("a curl that is not an asset download is not a violation", () => {
83
228
  ' curl -fsSL "https://example.test/health"',
84
229
  ].join("\n");
85
230
  assert.deepEqual(lintSource("workflow.yml", probes), []);
86
- assert.equal(isAssetDownload(probes.split("\n")[0]), false, "-o /dev/null is a probe");
87
231
  assert.equal(isAssetDownload(probes.split("\n")[1]), false, "no -o is not a download");
232
+ assert.equal(
233
+ isAssetDownload(' curl -sS -o /dev/null -w "%{http_code}" -X POST "$api"'),
234
+ false,
235
+ "-o /dev/null is a probe",
236
+ );
88
237
  });
89
238
 
90
239
  test("a line that merely mentions curl in prose is not a download", () => {
91
- assert.equal(isAssetDownload(" # predecessor used `curl --retry 3`"), false);
240
+ assert.equal(isAssetDownload(" # predecessor used `curl --retry 3` -o x"), false);
92
241
  assert.equal(isAssetDownload(" echo curling the asset"), false);
242
+ assert.equal(isAssetDownload(" echo wgetting the asset -O x"), false);
93
243
  });
94
244
 
95
245
  // ---------------------------------------------------------------------------
@@ -139,6 +289,21 @@ test("missingFlags reports in table order and REQUIRED_FLAGS each carry a why",
139
289
  test("every first-party action manifest in this repo passes", () => {
140
290
  const manifests = findActionManifests();
141
291
  assert.ok(manifests.length > 0, "the action surface is discoverable");
292
+ assert.ok(
293
+ manifests.some((p) => p.includes("gitleaks-scan")),
294
+ "the action whose 504 motivated this lint is in scope",
295
+ );
142
296
  const findings = manifests.flatMap((p) => lintSource(p, readFileSync(p, "utf8")));
143
297
  assert.deepEqual(findings, [], "no action ships an unhardened asset download");
144
298
  });
299
+
300
+ test("the shipped downloads are still recognised as downloads", () => {
301
+ // The tightening must not narrow the lint into vacuous success: the live
302
+ // manifests must still present downloads for it to have judged.
303
+ const seen = findActionManifests().flatMap((p) =>
304
+ collapseContinuations(readFileSync(p, "utf8"))
305
+ .map(({ text }) => text)
306
+ .filter((text) => isAssetDownload(text)),
307
+ );
308
+ assert.ok(seen.length >= 4, `expected the tree's asset downloads, saw ${seen.length}`);
309
+ });
@@ -18,6 +18,14 @@
18
18
  * because the defect class here is an expression that reads correctly and
19
19
  * evaluates wrong.
20
20
  *
21
+ * The install-free assertion is JOB-wide (Story #494). It used to slice the
22
+ * "Setup Node.js (install-free)" step alone, which proves nothing: the step
23
+ * that provisions Node was never the one likely to grow an install. What the
24
+ * path promises is that NOTHING in the job installs when `setup: node` is
25
+ * selected, so the check enumerates every step of the job, keeps the ones
26
+ * whose `if:` guard actually selects them under that input, and asserts the
27
+ * absence across the set.
28
+ *
21
29
  * Run: node --test scripts/check-advisory-scan-setup.test.mjs
22
30
  */
23
31
 
@@ -59,11 +67,114 @@ function stepByName(text, name) {
59
67
  return out.join("\n");
60
68
  }
61
69
 
70
+ /** Strip a `${{ … }}` wrapper, leaving the bare Actions expression. */
71
+ function bareExpression(raw) {
72
+ return raw.trim().replace(/^\$\{\{/, "").replace(/\}\}$/, "").trim();
73
+ }
74
+
62
75
  /** The `${{ … }}`-free body of a step's `if:` condition. */
63
76
  function ifExpression(step, name) {
64
77
  const m = step.match(/^\s*if:\s*(.+)$/m);
65
78
  assert.ok(m, `step "${name}" has no \`if:\` guard`);
66
- return m[1].trim().replace(/^\$\{\{/, "").replace(/\}\}$/, "").trim();
79
+ return bareExpression(m[1]);
80
+ }
81
+
82
+ /**
83
+ * The line span of one top-level job — from its ` <id>:` header to the next
84
+ * key at that indent, trailing blank lines excluded. A plain line comparison
85
+ * rather than a regex built around `job`: a dynamically-constructed RegExp is
86
+ * a SAST finding, and a job header is an exact line anyway.
87
+ */
88
+ function jobRange(text, job) {
89
+ const lines = text.split("\n");
90
+ const start = lines.findIndex((l) => l.trimEnd() === ` ${job}:`);
91
+ assert.notEqual(start, -1, `${ADVISORY}: job '${job}' not found`);
92
+ let end = lines.length;
93
+ for (let i = start + 1; i < lines.length; i++) {
94
+ if (/^ {2}\S/.test(lines[i])) {
95
+ end = i;
96
+ break;
97
+ }
98
+ }
99
+ while (end > start + 1 && lines[end - 1].trim() === "") end--;
100
+ return { lines, start, end };
101
+ }
102
+
103
+ /**
104
+ * Every step of a job, in order, as its own block of text. Steps are split on
105
+ * the `- ` bullets under `steps:` at the bullet's own indent, so a nested
106
+ * `with:` / `env:` mapping stays with the step that owns it.
107
+ */
108
+ function jobSteps(text, job) {
109
+ const { lines, start, end } = jobRange(text, job);
110
+ const body = lines.slice(start + 1, end);
111
+ const stepsIdx = body.findIndex((l) => l.trim() === "steps:");
112
+ assert.notEqual(stepsIdx, -1, `${ADVISORY}: job '${job}' declares no \`steps:\``);
113
+ const rest = body.slice(stepsIdx + 1);
114
+ const first = rest.findIndex((l) => /^\s*-\s/.test(l));
115
+ assert.notEqual(first, -1, `${ADVISORY}: job '${job}' declares no steps`);
116
+ const indent = rest[first].match(/^(\s*)/)[1].length;
117
+ const steps = [];
118
+ for (const line of rest.slice(first)) {
119
+ const bullet = /^\s*-\s/.test(line) && line.match(/^(\s*)/)[1].length === indent;
120
+ if (bullet) steps.push([]);
121
+ steps[steps.length - 1].push(line);
122
+ }
123
+ return steps.map((step) => step.join("\n"));
124
+ }
125
+
126
+ /** A step's `name:`, else its opening line — for a readable failure message. */
127
+ function stepLabel(step) {
128
+ const m = step.match(/^\s*(?:-\s*)?name:\s*(.+)$/m);
129
+ return m ? m[1].trim() : step.split("\n")[0].trim();
130
+ }
131
+
132
+ /** Whether a step is selected for the run under the given `inputs` context. */
133
+ function runsWhen(step, inputs) {
134
+ const m = step.match(/^\s*if:\s*(.+)$/m);
135
+ if (!m) return true;
136
+ try {
137
+ return evaluate(bareExpression(m[1]), inputs) === true;
138
+ } catch {
139
+ // A guard this evaluator cannot run counts as SELECTED. Fail closed: an
140
+ // expression nobody can evaluate must never be the reason an install slips
141
+ // past the check below — at worst it costs a loud failure a human reads.
142
+ return true;
143
+ }
144
+ }
145
+
146
+ // Install commands, matched against the step's YAML with comment-only lines
147
+ // removed: the job's comments discuss `pnpm install` at length, and prose is
148
+ // not a command. Literal patterns, never a built RegExp.
149
+ const INSTALL_COMMANDS = [
150
+ { label: "pnpm install", pattern: /\bpnpm install\b/ },
151
+ { label: "npm ci", pattern: /\bnpm ci\b/ },
152
+ { label: "npm install", pattern: /\bnpm install\b/ },
153
+ { label: "yarn install", pattern: /\byarn install\b/ },
154
+ ];
155
+
156
+ /** Every step selected by `setup` that runs an install, with what it runs. */
157
+ function installingSteps(text, setup) {
158
+ return jobSteps(text, JOB)
159
+ .filter((step) => runsWhen(step, { setup }))
160
+ .map((step) => ({
161
+ step: stepLabel(step),
162
+ commands: INSTALL_COMMANDS.filter(({ pattern }) =>
163
+ pattern.test(
164
+ step
165
+ .split("\n")
166
+ .filter((l) => !l.trim().startsWith("#"))
167
+ .join("\n"),
168
+ ),
169
+ ).map(({ label }) => label),
170
+ }))
171
+ .filter(({ commands }) => commands.length > 0);
172
+ }
173
+
174
+ /** `text` with one extra step spliced in after the job's last existing step. */
175
+ function withStepAppended(text, job, step) {
176
+ const { lines, end } = jobRange(text, job);
177
+ return [...lines.slice(0, end), ...step.split("\n"), ...lines.slice(end)].join("\n");
67
178
  }
68
179
 
69
180
  /** The literal `default:` of the named workflow_call input. */
@@ -80,6 +191,7 @@ function inputDefault(text, name) {
80
191
  return assert.fail(`${ADVISORY}: input \`${name}\` has no default`);
81
192
  }
82
193
 
194
+ const JOB = "advisory-scan";
83
195
  const TOOLCHAIN_STEP = "Setup toolchain";
84
196
  const NODE_STEP = "Setup Node.js (install-free)";
85
197
  const GUARD_STEP = "Validate setup input";
@@ -135,12 +247,54 @@ test("the install-free step pins Node from .nvmrc via a SHA-pinned setup-node",
135
247
  );
136
248
  });
137
249
 
138
- test("no dependency install runs on the install-free path", () => {
250
+ // ---------------------------------------------------------------------------
251
+ // Story #494 — "install-free" is a property of the JOB, not of one step.
252
+ // ---------------------------------------------------------------------------
253
+
254
+ test("no dependency install runs ANYWHERE in the job on the install-free path", () => {
139
255
  // The whole point of the path: osv-scanner reads lockfiles off disk, and both
140
256
  // composites' gate scripts import only node builtins and relative siblings.
141
- const step = stepByName(advisory, NODE_STEP);
142
- assert.doesNotMatch(step, /pnpm install/, "the install-free path must not install");
143
- assert.doesNotMatch(step, /npm ci|npm install/, "the install-free path must not install");
257
+ // So nothing in the job needs a dependency tree — and the step that
258
+ // provisions Node was never the step likely to grow an install anyway.
259
+ assert.deepEqual(
260
+ installingSteps(advisory, "node"),
261
+ [],
262
+ "a step selected by `setup: node` installs dependencies",
263
+ );
264
+ });
265
+
266
+ test("the check is job-wide: an install appended after the last step is caught", () => {
267
+ // The mutation the step-scoped version missed — it sliced the install-free
268
+ // step alone, so this fixture passed while the path's one promise was broken.
269
+ const mutated = withStepAppended(
270
+ advisory,
271
+ JOB,
272
+ [" - name: Restore dependencies", " run: npm ci"].join("\n"),
273
+ );
274
+ assert.deepEqual(installingSteps(mutated, "node"), [
275
+ { step: "Restore dependencies", commands: ["npm ci"] },
276
+ ]);
277
+ });
278
+
279
+ test("an install guarded onto the toolchain path is not charged to the node path", () => {
280
+ // The other half of the mutation, and the proof that the `if:` guards are
281
+ // still EVALUATED here rather than grepped: the same appended step is
282
+ // invisible under `setup: node` and visible under `setup: toolchain`. Drop
283
+ // the evaluation and this check either flags every guarded install or, if it
284
+ // ignored guards entirely, would have to ignore unguarded ones too.
285
+ const mutated = withStepAppended(
286
+ advisory,
287
+ JOB,
288
+ [
289
+ " - name: Restore pnpm dependencies",
290
+ " if: inputs.setup == 'toolchain'",
291
+ " run: pnpm install --frozen-lockfile",
292
+ ].join("\n"),
293
+ );
294
+ assert.deepEqual(installingSteps(mutated, "node"), []);
295
+ assert.deepEqual(installingSteps(mutated, "toolchain"), [
296
+ { step: "Restore pnpm dependencies", commands: ["pnpm install"] },
297
+ ]);
144
298
  });
145
299
 
146
300
  // ---------------------------------------------------------------------------
@@ -222,6 +376,33 @@ test("this repo really is the npm case the caller claims", () => {
222
376
  );
223
377
  });
224
378
 
379
+ // ---------------------------------------------------------------------------
380
+ // Story #494 — the copy-paste path documents the input that decides the job.
381
+ // ---------------------------------------------------------------------------
382
+
383
+ test("the header's consumer snippet shows `setup:` and names both accepted values", () => {
384
+ // The snippet is what a consumer copies; an input missing from it is one they
385
+ // never learn they had. `setup` is the input whose wrong value kills the job
386
+ // at setup-node before the scan runs — the seven silent weeks above — so the
387
+ // copy path has to carry it, and has to name the other value it accepts.
388
+ const header = advisory.split("\nname:")[0];
389
+ const snippet = header.split("\n").filter((l) => l.startsWith("#"));
390
+ const withIdx = snippet.findIndex((l) => /^#\s+with:\s*$/.test(l));
391
+ assert.notEqual(withIdx, -1, `${ADVISORY}: the consumer snippet has no \`with:\` block`);
392
+ const withBlock = snippet.slice(withIdx).join("\n");
393
+ assert.match(
394
+ withBlock,
395
+ /^#\s+setup:\s*(toolchain|node)\s*$/m,
396
+ "the consumer snippet must pass `setup:`",
397
+ );
398
+ for (const value of ["toolchain", "node"]) {
399
+ assert.ok(
400
+ withBlock.includes(value),
401
+ `the consumer snippet must name the accepted value '${value}'`,
402
+ );
403
+ }
404
+ });
405
+
225
406
  // ---------------------------------------------------------------------------
226
407
  // AC-8 / AC-9 — the documented contract.
227
408
  // ---------------------------------------------------------------------------