mandrel-platform 1.13.0 → 1.13.1

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.
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-husky-hook-modes.test.mjs — regression guard for the mode bit that
4
+ * decides whether this repository's git hooks run at all.
5
+ *
6
+ * The bug this pins: `.husky/pre-commit` was tracked at mode 100644. Git
7
+ * 2.36+ refuses to execute a hook that is not marked executable, and the way
8
+ * it refuses is the problem — it prints
9
+ *
10
+ * hint: The '.../.husky/pre-commit' hook was ignored because it's not
11
+ * set as executable.
12
+ *
13
+ * to stderr and then lets the commit SUCCEED with exit 0. So the quality gate
14
+ * the hook exists to run (`quality-preview.js`, which blocks MI/CRAP drift at
15
+ * commit time) never ran for anybody, in any clone, from the day it was
16
+ * added — and nothing anywhere went red to say so. An inert gate and a
17
+ * passing gate are indistinguishable from the outside.
18
+ *
19
+ * Why the mode is asserted against the INDEX and not the filesystem. `chmod`
20
+ * on the working copy is not the fix and would not be caught here: git stores
21
+ * only two file modes, 100644 and 100755, and it is the tracked one that
22
+ * every fresh clone and every `git checkout` materializes. A working tree can
23
+ * be executable while the committed mode is not (and vice versa), so a
24
+ * `statSync` check would go green on the maintainer's machine and ship the
25
+ * defect to everyone else. `git ls-files -s` reads the index, which is the
26
+ * mode that actually propagates.
27
+ *
28
+ * Why this is not a one-line assertion on one path. The upstream cause is
29
+ * still live: the hook is installed by the vendored bootstrap
30
+ * (`.agents/scripts/lib/bootstrap/quality-bootstrap.js`) with a bare
31
+ * `fs.writeFileSync` and no `chmod`, so a hook file CREATED by a fresh
32
+ * bootstrap run is born 0644. (An existing file survives — `writeFileSync`
33
+ * truncates in place and preserves the mode — which is why fixing the tracked
34
+ * mode holds.) `.agents/**` is vendored payload re-materialized from the
35
+ * mandrel package, so that cause cannot be fixed here; this guard is the
36
+ * compensating control, and it therefore covers every hook the directory may
37
+ * grow, not just the one that was broken.
38
+ *
39
+ * Run: node --test scripts/check-husky-hook-modes.test.mjs
40
+ */
41
+
42
+ import assert from "node:assert/strict";
43
+ import { test } from "node:test";
44
+ import { execFileSync } from "node:child_process";
45
+
46
+ const HOOKS_DIR = ".husky";
47
+ const EXECUTABLE = "100755";
48
+
49
+ /**
50
+ * Every client-side hook name git will invoke. A file in `.husky/` whose name
51
+ * is not on this list is never run by git no matter what its mode is, so a
52
+ * typo (`pre_commit`, `precommit`) is the same silent no-op as a missing
53
+ * executable bit and is asserted against below.
54
+ *
55
+ * Source: `githooks(5)`, client-side hooks only — server-side hooks
56
+ * (`pre-receive`, `update`, `post-receive`) cannot fire from a clone.
57
+ */
58
+ const GIT_CLIENT_HOOKS = new Set([
59
+ "applypatch-msg",
60
+ "pre-applypatch",
61
+ "post-applypatch",
62
+ "pre-commit",
63
+ "pre-merge-commit",
64
+ "prepare-commit-msg",
65
+ "commit-msg",
66
+ "post-commit",
67
+ "pre-rebase",
68
+ "post-checkout",
69
+ "post-merge",
70
+ "pre-push",
71
+ "pre-auto-gc",
72
+ "post-rewrite",
73
+ "sendemail-validate",
74
+ "post-index-change",
75
+ "reference-transaction",
76
+ "push-to-checkout",
77
+ ]);
78
+
79
+ /**
80
+ * Tracked entries directly under `.husky/`, as `{ mode, path, name }`.
81
+ *
82
+ * `git ls-files -s` emits `<mode> <object> <stage>\t<path>`. Husky's own
83
+ * `_/` shim directory and dotfiles (`.gitignore`) are not hooks and are
84
+ * excluded; everything else in the directory is one by convention.
85
+ */
86
+ function trackedHooks() {
87
+ const out = execFileSync("git", ["ls-files", "-s", "--", HOOKS_DIR], {
88
+ encoding: "utf8",
89
+ });
90
+
91
+ return out
92
+ .split("\n")
93
+ .filter((line) => line.length > 0)
94
+ .map((line) => {
95
+ const [meta, path] = line.split("\t");
96
+ return { mode: meta.split(" ")[0], path, name: path.slice(HOOKS_DIR.length + 1) };
97
+ })
98
+ .filter(({ name }) => !name.startsWith(".") && !name.startsWith("_/"));
99
+ }
100
+
101
+ test("the quality-gate pre-commit hook is still tracked", () => {
102
+ // Without this the mode assertion below passes vacuously the moment the
103
+ // hook is deleted — which is the same outcome (no gate) by another route.
104
+ const names = trackedHooks().map((h) => h.name);
105
+ assert.ok(
106
+ names.includes("pre-commit"),
107
+ `${HOOKS_DIR}/pre-commit is not tracked; the commit-time quality gate would not run. Tracked: ${names.join(", ") || "(none)"}`,
108
+ );
109
+ });
110
+
111
+ test("every tracked hook under .husky/ is executable in the index", () => {
112
+ const hooks = trackedHooks();
113
+ assert.ok(hooks.length > 0, `no tracked hooks found under ${HOOKS_DIR}/`);
114
+
115
+ const nonExecutable = hooks.filter((h) => h.mode !== EXECUTABLE);
116
+ assert.deepEqual(
117
+ nonExecutable.map((h) => `${h.path} (${h.mode})`),
118
+ [],
119
+ `git 2.36+ silently IGNORES a non-executable hook and lets the commit succeed with exit 0. ` +
120
+ `Fix the tracked mode with: git update-index --chmod=+x <path> (a bare chmod does not change it).`,
121
+ );
122
+ });
123
+
124
+ test("every tracked hook under .husky/ has a name git will actually invoke", () => {
125
+ const unknown = trackedHooks()
126
+ .map((h) => h.name)
127
+ .filter((name) => !GIT_CLIENT_HOOKS.has(name));
128
+
129
+ assert.deepEqual(
130
+ unknown,
131
+ [],
132
+ `git invokes hooks by exact filename; a name outside githooks(5) never runs, as silently as a non-executable one.`,
133
+ );
134
+ });
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * check-runner-runs-on.test.mjs — regression guard for the `runner` input's
4
- * two documented shapes (Story #421).
3
+ * check-runner-runs-on.test.mjs — regression guard for every shape the
4
+ * `runner` input can arrive in (Stories #421, #493).
5
5
  *
6
6
  * The bug this pins: every `runs-on:` site consumed the input raw as
7
7
  * `${{ inputs.runner }}`. GitHub does not parse a JSON-array *string* in that
@@ -21,6 +21,25 @@
21
21
  * expression from the workflow and EVALUATES it under Actions semantics, the
22
22
  * same read-then-execute approach as check-toolchain-cache-default.test.mjs.
23
23
  *
24
+ * The same failure had a second door, closed by #493: `runner: ''`. A
25
+ * workflow_call `default:` fires only when the key is ABSENT, so a caller
26
+ * who passes the key with an empty value — threading an unset input or a
27
+ * matrix value through — got the label `""` rather than `ubuntu-latest`, and
28
+ * with it the identical never-scheduled job. The fallback therefore lives at
29
+ * the `runs-on` site, and the byte-identical assertion below is what stops a
30
+ * fix that reaches six of the seven workflows from shipping as if it reached
31
+ * all seven.
32
+ *
33
+ * Story #493 also left a downstream door open, closed here: `runs-on` was not
34
+ * the only site reading `inputs.runner`. `pr-quality.yml`'s harden-runner
35
+ * egress-audit step gates on `startsWith(inputs.runner, 'ubuntu-')`, which was
36
+ * unreachable-but-consistent while `runner: ''` never scheduled a job. Once the
37
+ * empty value resolved to the hosted default, the job ran on ubuntu-latest
38
+ * while the gate read the raw `''` and skipped — a SECURITY step opting itself
39
+ * out with nothing red to show for it. So the gate is asserted the same way:
40
+ * extracted and EVALUATED, and required to AGREE with what `runs-on` resolves
41
+ * to for the same input.
42
+ *
24
43
  * Run: node --test scripts/check-runner-runs-on.test.mjs
25
44
  */
26
45
 
@@ -28,6 +47,7 @@ import assert from "node:assert/strict";
28
47
  import { test } from "node:test";
29
48
  import { readFileSync, readdirSync } from "node:fs";
30
49
  import { evaluate } from "./lib/actions-expression.mjs";
50
+ import { stepByName } from "./lib/yaml-step.mjs";
31
51
 
32
52
  const WORKFLOW_DIR = ".github/workflows";
33
53
 
@@ -58,6 +78,28 @@ function runsOnExpressions(text) {
58
78
  return out;
59
79
  }
60
80
 
81
+ /**
82
+ * The `default:` this workflow declares for its `runner` workflow_call input.
83
+ *
84
+ * Read from the YAML rather than hardcoded so the empty-input assertion below
85
+ * pins the real contract: whatever label a caller gets by omitting `runner`
86
+ * is the label an explicitly-empty `runner` must get too. Scanned line by
87
+ * line — the block boundary is indentation, and a dynamically-constructed
88
+ * regex is a SAST finding that buys nothing here.
89
+ */
90
+ function declaredRunnerDefault(text) {
91
+ const lines = text.split("\n");
92
+ const start = lines.indexOf(" runner:");
93
+ assert.notEqual(start, -1, "no `runner:` workflow_call input found");
94
+ for (let i = start + 1; i < lines.length; i++) {
95
+ const line = lines[i];
96
+ if (line.trim() !== "" && !line.startsWith(" ")) break;
97
+ const m = line.match(/^\s+default:\s*'([^']*)'\s*$/);
98
+ if (m) return m[1];
99
+ }
100
+ return assert.fail("the `runner` input declares no `default:` to fall back to");
101
+ }
102
+
61
103
  const WORKFLOWS = runnerWorkflows();
62
104
 
63
105
  test("every workflow taking a `runner` input is covered by this guard", () => {
@@ -127,6 +169,38 @@ for (const { file, text } of WORKFLOWS) {
127
169
  }
128
170
  });
129
171
 
172
+ test(`${file}: an empty runner resolves to this workflow's documented default`, () => {
173
+ // The silent-queue shape #421 left behind. `runner: ''` is not exotic — a
174
+ // caller threading `runner: ${{ inputs.runner }}` or a matrix value that
175
+ // resolves to nothing passes it without meaning to, and `format('"{0}"',
176
+ // '')` yielded the label `""`. An empty label matches no runner, so the
177
+ // job sat `queued` with no logs and no red, exactly as the JSON-array
178
+ // string did. The fallback belongs at the `runs-on` site because the
179
+ // input `default:` only fires when the key is ABSENT, never when it is
180
+ // present and empty.
181
+ const fallback = declaredRunnerDefault(text);
182
+ assert.notEqual(fallback, "", `${file}: the declared default is itself empty`);
183
+ for (const { line, expr } of sites) {
184
+ const resolved = evaluate(expr, { runner: "" });
185
+ assert.equal(
186
+ typeof resolved,
187
+ "string",
188
+ `${file}:${line}: an empty runner must resolve to a single label, not ${JSON.stringify(resolved)}`,
189
+ );
190
+ assert.notEqual(
191
+ resolved,
192
+ "",
193
+ `${file}:${line}: an empty runner resolves to an empty label — no runner ` +
194
+ `carries it, so the job queues until the 24-hour timeout with nothing to read`,
195
+ );
196
+ assert.equal(
197
+ resolved,
198
+ fallback,
199
+ `${file}:${line}: an empty runner must land on the input's documented default`,
200
+ );
201
+ }
202
+ });
203
+
130
204
  test(`${file}: every runs-on site resolves identically`, () => {
131
205
  // One workflow must not drift into two dialects of the same decision.
132
206
  const rendered = sites.map(({ expr }) =>
@@ -136,6 +210,25 @@ for (const { file, text } of WORKFLOWS) {
136
210
  });
137
211
  }
138
212
 
213
+
214
+ test("every runs-on expression across the seven workflows is byte-identical", () => {
215
+ // The per-workflow tests above each score one file, so a fix applied to six
216
+ // of the seven passes every one of them and ships the seventh still broken.
217
+ // This is the assertion a partial edit cannot survive: one decision, spelled
218
+ // one way, everywhere it is made.
219
+ const distinct = new Map();
220
+ for (const { file, text } of WORKFLOWS) {
221
+ for (const { line, expr } of runsOnExpressions(text)) {
222
+ if (!distinct.has(expr)) distinct.set(expr, []);
223
+ distinct.get(expr).push(`${file}:${line}`);
224
+ }
225
+ }
226
+ const report = [...distinct.entries()]
227
+ .map(([expr, at]) => `${expr} @ ${at.join(", ")}`)
228
+ .join("\n ");
229
+ assert.equal(distinct.size, 1, `runs-on expressions have drifted apart:\n ${report}`);
230
+ });
231
+
139
232
  test("the documented array form resolves AND derives toolchain-cache 'false'", () => {
140
233
  // The coupling the gap report found: before this fix the only `runner` value
141
234
  // that derived the correct cache posture was the one that never reached a
@@ -152,3 +245,131 @@ test("the documented array form resolves AND derives toolchain-cache 'false'", (
152
245
  assert.ok(cache, "no `cache: ${{ … }}` value found at the setup-toolchain call site");
153
246
  assert.equal(evaluate(cache[1].trim(), { runner, "toolchain-cache": "auto" }), "false");
154
247
  });
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // The harden-runner egress-audit gate (`pr-quality.yml`)
251
+ //
252
+ // `runs-on` was not the only expression reading `inputs.runner`. Anything that
253
+ // branches on the runner class has to resolve the input the SAME way, or the
254
+ // job and the step disagree about which machine they are on. This section
255
+ // pins that agreement behaviourally — extract the real `if:` and run it.
256
+ // ---------------------------------------------------------------------------
257
+
258
+ /**
259
+ * The `${{ … }}` body of the harden-runner step's `if:` gate.
260
+ *
261
+ * Keyed off the step, not off a line pattern that happens to contain
262
+ * `startsWith` — the point is to score whatever expression actually guards
263
+ * that step, including one a future edit spells differently.
264
+ */
265
+ function hardenRunnerGate(text) {
266
+ const block = stepByName(text, "Harden runner (egress audit)");
267
+ assert.match(
268
+ block,
269
+ /uses: step-security\/harden-runner@/,
270
+ "the extracted block is not the harden-runner step",
271
+ );
272
+ const m = block.match(/^\s*if:\s*\$\{\{(.+)\}\}\s*$/m);
273
+ assert.ok(m, "the harden-runner step has no single-expression `if:` gate to score");
274
+ return m[1].trim();
275
+ }
276
+
277
+ /**
278
+ * What `runs-on` resolves to for `runner`, as a hosted-ubuntu predicate.
279
+ *
280
+ * A single string label starting with `ubuntu-` is a GitHub-hosted ubuntu
281
+ * image — the one environment where harden-runner installs its own monitor.
282
+ * An ARRAY (the documented self-hosted form) is not, regardless of the labels
283
+ * inside it: harden-runner ships its agent in a self-hosted runner image, so
284
+ * the step is correctly a no-op there.
285
+ */
286
+ function resolvesToHostedUbuntu(runsOnExpr, runner) {
287
+ const resolved = evaluate(runsOnExpr, { runner });
288
+ return typeof resolved === "string" && resolved.startsWith("ubuntu-");
289
+ }
290
+
291
+ const QUALITY_FILE = `${WORKFLOW_DIR}/pr-quality.yml`;
292
+ const QUALITY_TEXT = readFileSync(QUALITY_FILE, "utf8");
293
+
294
+ test("pr-quality.yml: the harden-runner gate reaches every tier through one anchor", () => {
295
+ // The gate is written once (`&harden-runner`) and aliased into the other
296
+ // tiers. A second literal copy could carry a stale expression that every
297
+ // behavioural assertion below would miss, because they score the anchor.
298
+ assert.match(QUALITY_TEXT, /^ {6}- &harden-runner$/m, "the harden-runner anchor is missing");
299
+ assert.ok(
300
+ (QUALITY_TEXT.match(/^ {6}- \*harden-runner$/gm) ?? []).length > 0,
301
+ "expected the harden-runner anchor to be aliased into the other tiers",
302
+ );
303
+ assert.equal(
304
+ (QUALITY_TEXT.match(/uses: step-security\/harden-runner@/g) ?? []).length,
305
+ 1,
306
+ "expected exactly one harden-runner step — a second one would bypass the anchor",
307
+ );
308
+ });
309
+
310
+ test("pr-quality.yml: an empty runner keeps the egress audit ON", () => {
311
+ // The regression. `runner: ''` resolves to the hosted ubuntu-latest default
312
+ // at `runs-on` (#493), so the job DOES run on a GitHub-hosted machine — but
313
+ // a gate reading the raw input saw `startsWith('', 'ubuntu-')` → false and
314
+ // skipped. Nothing goes red when a step is skipped, so the egress baseline
315
+ // silently disappears for exactly the callers who never asked to opt out.
316
+ const gate = hardenRunnerGate(QUALITY_TEXT);
317
+ assert.equal(
318
+ evaluate(gate, { runner: "", "enable-harden-runner": true }),
319
+ true,
320
+ "an empty runner lands on hosted ubuntu-latest, so the egress audit must run there",
321
+ );
322
+ });
323
+
324
+ test("pr-quality.yml: the gate agrees with what runs-on resolves to", () => {
325
+ // The real contract, and the one that survives a respelling of either
326
+ // expression: the step runs precisely when the job is on a hosted ubuntu
327
+ // image. Scoring both sides against the same input is what makes a future
328
+ // change to one of them fail here instead of shipping a silent divergence.
329
+ const gate = hardenRunnerGate(QUALITY_TEXT);
330
+ const [runsOn] = runsOnExpressions(QUALITY_TEXT);
331
+ for (const runner of [
332
+ "",
333
+ "ubuntu-latest",
334
+ "ubuntu-24.04",
335
+ "ubuntu-22.04",
336
+ "ubuntu-latest-8-cores",
337
+ "macos-14",
338
+ "windows-latest",
339
+ '["self-hosted","beestera-runner"]',
340
+ '["ubuntu-latest"]',
341
+ ]) {
342
+ assert.equal(
343
+ evaluate(gate, { runner, "enable-harden-runner": true }),
344
+ resolvesToHostedUbuntu(runsOn.expr, runner),
345
+ `runner ${JSON.stringify(runner)}: the gate and the resolved runs-on disagree ` +
346
+ `about whether this job is on a GitHub-hosted ubuntu image`,
347
+ );
348
+ }
349
+ });
350
+
351
+ test("pr-quality.yml: `enable-harden-runner: false` still opts out everywhere", () => {
352
+ // The documented escape hatch. A fallback added to the runner half of the
353
+ // gate must not make the boolean half unreachable — `false && …` yields
354
+ // `false`, but only if the operands stayed in that order.
355
+ const gate = hardenRunnerGate(QUALITY_TEXT);
356
+ for (const runner of ["", "ubuntu-latest", "ubuntu-24.04", '["self-hosted","x"]']) {
357
+ assert.equal(
358
+ evaluate(gate, { runner, "enable-harden-runner": false }),
359
+ false,
360
+ `runner ${JSON.stringify(runner)}: opting out must win regardless of the runner`,
361
+ );
362
+ }
363
+ });
364
+
365
+ test("pr-quality.yml: the toolchain-cache derivation reads an empty runner as hosted", () => {
366
+ // The sibling `inputs.runner` reader, checked rather than assumed. It is
367
+ // correct as written for `runner: ''` — but only incidentally, because
368
+ // `contains('', 'self-hosted')` is false and the derivation is
369
+ // self-hosted-side. Pinning it here means a future inversion to a
370
+ // hosted-side test (`contains(runner, 'ubuntu')`) trips instead of quietly
371
+ // disabling the cache for every empty-runner caller.
372
+ const cache = QUALITY_TEXT.match(/^\s*cache:\s*\$\{\{(.+)\}\}\s*$/m);
373
+ assert.ok(cache, "no `cache: ${{ … }}` value found at the setup-toolchain call site");
374
+ assert.equal(evaluate(cache[1].trim(), { runner: "", "toolchain-cache": "auto" }), "true");
375
+ });
@@ -38,9 +38,51 @@ const LOCKFILE = "scripts/semgrep-requirements.txt";
38
38
  const WORKFLOW = ".github/workflows/pr-quality.yml";
39
39
  const UPDATER = "scripts/update-semgrep-rules.mjs";
40
40
 
41
+ const DOCS = "docs/reusable-workflows.md";
42
+
41
43
  const lockfile = readFileSync(LOCKFILE, "utf8");
42
44
  const workflow = readFileSync(WORKFLOW, "utf8");
43
45
 
46
+ const SAST_STEP_HEADER = "- name: SAST (Semgrep, blocking, no SARIF upload)";
47
+
48
+ /**
49
+ * Return the SAST step's `run:` body — from its `- name:` line to the next
50
+ * step at the same indentation — with comment-only lines stripped.
51
+ *
52
+ * Both halves earn their place. Scoping to the step is what stops a whole-file
53
+ * `indexOf` from resolving an anchor against an unrelated tier a thousand
54
+ * lines away; dropping comments is what stops it from resolving against PROSE
55
+ * ABOUT the code instead of the code. The selector-before-venv guard below did
56
+ * both: its `select-semgrep-python.sh` hit landed in the side-checkout step's
57
+ * comment block and its `-m venv` hit in a paragraph explaining venvs, so the
58
+ * ordering it asserted was the ordering of two comments.
59
+ */
60
+ function sastStepCode(text = workflow) {
61
+ const start = text.indexOf(SAST_STEP_HEADER);
62
+ if (start === -1) return "";
63
+ const rest = text.slice(start + SAST_STEP_HEADER.length);
64
+ const end = rest.indexOf("\n - ");
65
+ const step = end === -1 ? rest : rest.slice(0, end);
66
+ return step
67
+ .split("\n")
68
+ .filter((line) => !line.trimStart().startsWith("#"))
69
+ .join("\n");
70
+ }
71
+
72
+ /**
73
+ * True when the SAST step chooses its interpreter before it builds the venv.
74
+ * A predicate rather than a bare assertion so the guard can be exercised
75
+ * against a MUTATED workflow — a test that only ever sees the passing input
76
+ * cannot show it would notice the failure.
77
+ */
78
+ function selectorPrecedesVenv(text) {
79
+ const code = sastStepCode(text);
80
+ const select = code.indexOf('source "${SELECT_PYTHON}"');
81
+ const venv = code.indexOf('"${SEMGREP_PYTHON}" -m venv');
82
+ if (select === -1 || venv === -1) return false;
83
+ return select < venv;
84
+ }
85
+
44
86
  // semgrep's own `requires_python`, recorded per release. Verified on PyPI
45
87
  // 2026-09-10: 1.97.0 was `>=3.8`, 1.136.0 `>=3.9`, and 1.137.0 raised it to
46
88
  // `>=3.10` — which is why a runner on macOS system Python (3.9.6) could not
@@ -265,16 +307,38 @@ test("the selector rides the same side-checkout as the lockfile", () => {
265
307
  test("the SAST step selects an interpreter before it creates the venv", () => {
266
308
  // A venv inherits the interpreter that built it, so a floor enforced after
267
309
  // `-m venv` cannot fix anything. Order is the whole guarantee.
268
- const select = workflow.indexOf("select-semgrep-python.sh");
269
- const venv = workflow.indexOf("-m venv");
270
- assert.notEqual(select, -1, `${WORKFLOW}: the SAST step must source the interpreter selector`);
271
- assert.notEqual(venv, -1, `${WORKFLOW}: the SAST step must still create a venv`);
272
- assert.ok(select < venv, "the selector must be sourced BEFORE the venv is created");
273
-
310
+ const code = sastStepCode();
311
+ assert.notEqual(code, "", `${WORKFLOW}: the SAST step was renamed or removed`);
312
+ assert.ok(
313
+ code.includes('source "${SELECT_PYTHON}"'),
314
+ `${WORKFLOW}: the SAST step must source the interpreter selector`,
315
+ );
274
316
  assert.ok(
275
- workflow.includes('"${SEMGREP_PYTHON}" -m venv'),
317
+ code.includes('"${SEMGREP_PYTHON}" -m venv'),
276
318
  "the venv must be built from the selected interpreter, not from bare python3",
277
319
  );
320
+ assert.ok(selectorPrecedesVenv(workflow), "the selector must be sourced BEFORE the venv");
321
+ });
322
+
323
+ test("the selector-before-venv guard fails when the two are swapped", () => {
324
+ // The mutation the guard exists to catch, performed on a copy: move the venv
325
+ // line above the `source` line and the check must go red. Without this, the
326
+ // guard could be resolving anchors that no reordering of the real step would
327
+ // ever move — which is precisely how it passed while comparing two comments.
328
+ const VENV_LINE = ' "${SEMGREP_PYTHON}" -m venv "${venv_dir}"\n';
329
+ const SOURCE_LINE = ' source "${SELECT_PYTHON}"\n';
330
+ assert.ok(workflow.includes(VENV_LINE), `${WORKFLOW}: the venv line changed shape`);
331
+ assert.ok(workflow.includes(SOURCE_LINE), `${WORKFLOW}: the source line changed shape`);
332
+
333
+ const mutated = workflow
334
+ .replace(VENV_LINE, "")
335
+ .replace(SOURCE_LINE, `${VENV_LINE}${SOURCE_LINE}`);
336
+ assert.notEqual(mutated, workflow, "the mutation must actually change the workflow");
337
+ assert.equal(
338
+ selectorPrecedesVenv(mutated),
339
+ false,
340
+ "a venv built before the interpreter is chosen must fail the guard",
341
+ );
278
342
  });
279
343
 
280
344
  test("the non-lockfile path installs exactly SEMGREP_PIN, never a resolved older release", () => {
@@ -341,3 +405,170 @@ test("the floor added no workflow_call input and no new job permission", () => {
341
405
  assert.ok(!header.includes("id-token:"), "no new permission was needed for an interpreter floor");
342
406
  assert.ok(!header.includes("packages:"), "no new permission was needed for an interpreter floor");
343
407
  });
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // 6. The hash-pinned path's real preconditions, and an audible fallback (#495)
411
+ // ---------------------------------------------------------------------------
412
+
413
+ test("the hash-pinned branch tests architecture as well as OS and ABI", () => {
414
+ // The lockfile was resolved with `--platform manylinux*_x86_64` only, and
415
+ // PyPI ships a separate `manylinux_2_34_aarch64` semgrep wheel that no entry
416
+ // covers. On an arm64 Linux runner the OS+ABI test passed and
417
+ // `--require-hashes --only-binary` hard-failed on a hash mismatch — a red
418
+ // that reads like a tampered artifact rather than an unsupported arch.
419
+ const code = sastStepCode();
420
+ assert.ok(
421
+ code.includes('[ "${pyver}" = "312" ]'),
422
+ `${WORKFLOW}: the cp312 test must stay an equality`,
423
+ );
424
+ assert.ok(
425
+ code.includes('[ "${runner_arch}" = "x86_64" ]'),
426
+ `${WORKFLOW}: the hash-pinned branch must also require x86_64`,
427
+ );
428
+ assert.ok(
429
+ code.includes('runner_arch="$(uname -m)"'),
430
+ "the architecture must be read from the runner, not assumed",
431
+ );
432
+ });
433
+
434
+ test("the lockfile's declared ABI matches the branch's cp equality", () => {
435
+ // Two spellings of one fact: `3.12` steers the interpreter probe and `312`
436
+ // gates the install. A bump that moved one and not the other would probe for
437
+ // an interpreter the install then rejects.
438
+ const abi = workflow.match(/export SEMGREP_LOCKFILE_ABI='([^']+)'/);
439
+ assert.ok(abi, `${WORKFLOW}: SEMGREP_LOCKFILE_ABI must be exported for the selector`);
440
+ assert.equal(
441
+ abi[1].replace(".", ""),
442
+ "312",
443
+ `SEMGREP_LOCKFILE_ABI is ${abi[1]} but the hash-pinned branch installs only on cp312`,
444
+ );
445
+ });
446
+
447
+ test("every reason for leaving the hash-pinned path is warned about by name", () => {
448
+ // The single line this replaced said "Non-Linux runner (or no lockfile)" on
449
+ // all four, so the two that actually cost the fleet its supply-chain pin — a
450
+ // rolled interpreter and an unsupported arch — were reported as neither of
451
+ // them, at log level, on a green job.
452
+ const code = sastStepCode();
453
+ const reasons = code.split("\n").filter((l) => l.includes("fallback_reason="));
454
+ assert.equal(reasons.length, 4, "expected one named reason per precondition");
455
+
456
+ for (const [precondition, phrase] of [
457
+ ["OS", "not Linux"],
458
+ ["architecture", "not x86_64"],
459
+ ["interpreter ABI", "not the lockfile's cp312"],
460
+ ["missing lockfile", "lockfile is missing"],
461
+ ]) {
462
+ assert.ok(
463
+ reasons.some((line) => line.includes(phrase)),
464
+ `no fallback reason names the ${precondition} precondition (looked for "${phrase}")`,
465
+ );
466
+ }
467
+
468
+ const warning = code
469
+ .split("\n")
470
+ .find((l) => l.includes("::warning::") && l.includes("${fallback_reason}"));
471
+ assert.ok(
472
+ warning,
473
+ "the fallback must emit a ::warning:: carrying the resolved reason, not a log line",
474
+ );
475
+ assert.ok(
476
+ warning.includes("WITHOUT hash-pinning"),
477
+ "the warning must say what was actually lost",
478
+ );
479
+ });
480
+
481
+ test("the SAST exclude list covers every platform side-checkout", () => {
482
+ // Two directories are materialized from mandrel-platform's own tree —
483
+ // `_mandrel-platform-sast` (the ruleset) and `_mandrel-platform-semgrep`
484
+ // (the lockfile + selector). Only the first was ever excluded, so platform
485
+ // source could enter a caller's finding set. The glob covers the next one
486
+ // too.
487
+ const code = sastStepCode();
488
+ assert.ok(
489
+ code.includes("--exclude '_mandrel-platform-*'"),
490
+ `${WORKFLOW}: the exclude list must cover _mandrel-platform-* , not one checkout by name`,
491
+ );
492
+
493
+ // Both checkout paths must fall under the glob, or the exclude is cosmetic.
494
+ for (const path of ["_mandrel-platform-sast", "_mandrel-platform-semgrep"]) {
495
+ assert.ok(
496
+ workflow.includes(`path: ${path}`),
497
+ `${WORKFLOW}: expected a side-checkout at ${path}`,
498
+ );
499
+ assert.ok(path.startsWith("_mandrel-platform-"), `${path} is not covered by the glob`);
500
+ }
501
+ });
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // 7. The rules updater shares the selector, and counts its own hashes (#495)
505
+ // ---------------------------------------------------------------------------
506
+
507
+ test("the rules updater builds its venv from the shared interpreter selector", () => {
508
+ const updater = readFileSync(UPDATER, "utf8");
509
+ assert.ok(
510
+ updater.includes("select-semgrep-python.sh"),
511
+ `${UPDATER}: the venv interpreter must come from the same selector the SAST step uses`,
512
+ );
513
+ assert.ok(
514
+ !/spawnSync\("python3"/.test(updater),
515
+ `${UPDATER}: a hard-coded python3 installs nothing on a macOS system interpreter`,
516
+ );
517
+ });
518
+
519
+ test("the rules updater no longer installs setuptools", () => {
520
+ // It was there only because semgrep 1.97.0's transitive
521
+ // opentelemetry-instrumentation 0.46b0 imported pkg_resources at load.
522
+ // 0.58b0 does not, and 80.9.0 — the last release shipping pkg_resources —
523
+ // carries its own advisories.
524
+ const updater = readFileSync(UPDATER, "utf8");
525
+ assert.ok(
526
+ !/["']setuptools["']/.test(updater),
527
+ `${UPDATER}: setuptools must not be reinstalled into the vendoring venv`,
528
+ );
529
+ });
530
+
531
+ test("the SEMGREP_HASHES comment states the artifact count it actually carries", () => {
532
+ // The comment read "the four platform wheels + the sdist" while the array
533
+ // held eight. A reader checking whether the hash set is complete is reading
534
+ // the comment, so a stale count is a supply-chain claim that is not true.
535
+ const updater = readFileSync(UPDATER, "utf8");
536
+ const version = REQS.get("semgrep");
537
+
538
+ const mapStart = updater.indexOf("const SEMGREP_HASHES = {");
539
+ assert.notEqual(mapStart, -1, `${UPDATER}: SEMGREP_HASHES not found`);
540
+ const entryStart = updater.indexOf(`"${version}": [`, mapStart);
541
+ assert.notEqual(entryStart, -1, `${UPDATER}: SEMGREP_HASHES has no entry for ${version}`);
542
+ const entry = updater.slice(entryStart, updater.indexOf("],", entryStart));
543
+ const recorded = entry.split("sha256:").length - 1;
544
+ assert.ok(recorded > 0, `${UPDATER}: the ${version} entry carries no hashes`);
545
+
546
+ const comment = updater.slice(0, mapStart);
547
+ const claimed = comment.match(/(\d+) artifacts/);
548
+ assert.ok(
549
+ claimed,
550
+ `${UPDATER}: the SEMGREP_HASHES comment must state how many artifacts it pins`,
551
+ );
552
+ assert.equal(
553
+ Number.parseInt(claimed[1], 10),
554
+ recorded,
555
+ `the comment claims ${claimed[1]} artifacts but the ${version} entry pins ${recorded}`,
556
+ );
557
+ });
558
+
559
+ // ---------------------------------------------------------------------------
560
+ // 8. The documented behaviour is the shipped behaviour (#495)
561
+ // ---------------------------------------------------------------------------
562
+
563
+ test("the SAST docs describe the ABI-first probe order and the fallback warning", () => {
564
+ // A consumer debugging a lost hash pin reads this section, not the workflow.
565
+ const docs = readFileSync(DOCS, "utf8");
566
+ const start = docs.indexOf("> **Python interpreter floor.**");
567
+ assert.notEqual(start, -1, `${DOCS}: the SAST interpreter section was renamed or removed`);
568
+ const section = docs.slice(start, docs.indexOf("#### SAST ruleset provenance", start));
569
+
570
+ assert.match(section, /SEMGREP_LOCKFILE_ABI|lockfile.{0,40}ABI/i, "the ABI input must be named");
571
+ assert.match(section, /probed first|first on Linux/i, "the probe order must be stated");
572
+ assert.match(section, /::warning::/, "the fallback warning must be documented");
573
+ assert.match(section, /x86_64/, "the architecture precondition must be documented");
574
+ });