mandrel-platform 1.10.0 → 1.12.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.
- package/README.md +14 -5
- package/package.json +4 -1
- package/scripts/audit-check.mjs +345 -106
- package/scripts/audit-check.test.mjs +208 -3
- package/scripts/check-advisory-scan-setup.test.mjs +247 -0
- package/scripts/check-coverage-threshold.mjs +252 -23
- package/scripts/check-coverage-threshold.test.mjs +292 -5
- package/scripts/check-semgrep-lockfile.test.mjs +205 -0
- package/scripts/semgrep-requirements.txt +146 -74
- package/scripts/update-semgrep-rules.mjs +15 -2
|
@@ -46,6 +46,9 @@ import {
|
|
|
46
46
|
isBoundedOverride,
|
|
47
47
|
findUnboundedOverrides,
|
|
48
48
|
lintOverrides,
|
|
49
|
+
detectPackageManager,
|
|
50
|
+
ghsaIdFromUrl,
|
|
51
|
+
recognizeReport,
|
|
49
52
|
} from "./audit-check.mjs";
|
|
50
53
|
|
|
51
54
|
const TODAY = "2026-07-02";
|
|
@@ -319,10 +322,29 @@ test("evaluateReport: uninterpretable report + non-zero pnpm exit → exit 1 (fa
|
|
|
319
322
|
assert.equal(result.reason, "uninterpretable-failclosed");
|
|
320
323
|
});
|
|
321
324
|
|
|
322
|
-
test("evaluateReport:
|
|
325
|
+
test("evaluateReport: unrecognized report + ZERO exit → exit 1 (fail closed)", () => {
|
|
326
|
+
// This assertion is the inverse of the one it replaces (Story #475), and the
|
|
327
|
+
// inversion is the point. The old contract read "no advisories key" as "no
|
|
328
|
+
// advisories" and passed on a zero exit. That is safe only while every report
|
|
329
|
+
// this gate can meet is the legacy shape — and it is not: `npm audit --json`
|
|
330
|
+
// reports v7+ advisories under `vulnerabilities` and exits 0 when clean, so
|
|
331
|
+
// the old branch would have reported an npm graph clean without reading one
|
|
332
|
+
// advisory, and would have kept doing so as highs landed.
|
|
333
|
+
//
|
|
334
|
+
// A report is clean only when a schema was RECOGNIZED and found nothing.
|
|
323
335
|
const result = evaluateReport({ metadata: {} }, 0, new Set());
|
|
324
|
-
assert.equal(result.exitCode,
|
|
325
|
-
assert.equal(result.reason, "
|
|
336
|
+
assert.equal(result.exitCode, 1);
|
|
337
|
+
assert.equal(result.reason, "uninterpretable-failclosed");
|
|
338
|
+
assert.equal(result.schema, null);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("evaluateReport: an unrecognized report fails closed on EVERY exit code", () => {
|
|
342
|
+
// Exit code must not be able to rescue an unreadable report from either side.
|
|
343
|
+
for (const exitCode of [0, 1, 2, 127]) {
|
|
344
|
+
const result = evaluateReport({ nonsense: true }, exitCode, new Set());
|
|
345
|
+
assert.equal(result.exitCode, 1, `exit ${exitCode}`);
|
|
346
|
+
assert.equal(result.reason, "uninterpretable-failclosed", `exit ${exitCode}`);
|
|
347
|
+
}
|
|
326
348
|
});
|
|
327
349
|
|
|
328
350
|
test("evaluateReport: validly-suppressed high (GHSA) → exit 0", () => {
|
|
@@ -837,3 +859,186 @@ test("parseArgs defaults the package.json path alongside the allowlist path", ()
|
|
|
837
859
|
assert.equal(allowlistPath, "/tmp/proj/audit-allowlist.json");
|
|
838
860
|
assert.equal(packageJsonPath, "/tmp/proj/package.json");
|
|
839
861
|
});
|
|
862
|
+
|
|
863
|
+
// ---------------------------------------------------------------------------
|
|
864
|
+
// Package-manager detection (Story #475)
|
|
865
|
+
//
|
|
866
|
+
// The lockfile is the discriminator, not `packageManager` / `engines`: it is
|
|
867
|
+
// what the audit actually reads, and this very repo declares one manager in
|
|
868
|
+
// metadata while committing the other's lockfile.
|
|
869
|
+
// ---------------------------------------------------------------------------
|
|
870
|
+
|
|
871
|
+
/** existsSync stub answering true for exactly the named basenames. */
|
|
872
|
+
function lockfilesPresent(...names) {
|
|
873
|
+
return (path) => names.some((name) => path.endsWith(`/${name}`));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
test("detectPackageManager: a pnpm lockfile selects pnpm", () => {
|
|
877
|
+
const result = detectPackageManager("/proj", {
|
|
878
|
+
existsSyncImpl: lockfilesPresent("pnpm-lock.yaml"),
|
|
879
|
+
});
|
|
880
|
+
assert.equal(result.manager, "pnpm");
|
|
881
|
+
assert.equal(result.error, undefined);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
test("detectPackageManager: an npm lockfile selects npm", () => {
|
|
885
|
+
const result = detectPackageManager("/proj", {
|
|
886
|
+
existsSyncImpl: lockfilesPresent("package-lock.json"),
|
|
887
|
+
});
|
|
888
|
+
assert.equal(result.manager, "npm");
|
|
889
|
+
assert.equal(result.error, undefined);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("detectPackageManager: no lockfile is a loud error, never a default", () => {
|
|
893
|
+
// Defaulting to either manager would make the gate's verdict a claim about a
|
|
894
|
+
// graph nobody chose.
|
|
895
|
+
const result = detectPackageManager("/proj", { existsSyncImpl: () => false });
|
|
896
|
+
assert.equal(result.manager, undefined);
|
|
897
|
+
assert.match(result.error, /No lockfile found/);
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test("detectPackageManager: two lockfiles are a loud error, never a guess", () => {
|
|
901
|
+
const result = detectPackageManager("/proj", {
|
|
902
|
+
existsSyncImpl: lockfilesPresent("pnpm-lock.yaml", "package-lock.json"),
|
|
903
|
+
});
|
|
904
|
+
assert.equal(result.manager, undefined);
|
|
905
|
+
assert.match(result.error, /Ambiguous lockfiles/);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
// ---------------------------------------------------------------------------
|
|
909
|
+
// GHSA id extraction — npm exposes the id ONLY inside the advisory url
|
|
910
|
+
// ---------------------------------------------------------------------------
|
|
911
|
+
|
|
912
|
+
test("ghsaIdFromUrl: reads the id from a GitHub advisory url", () => {
|
|
913
|
+
assert.equal(
|
|
914
|
+
ghsaIdFromUrl("https://github.com/advisories/GHSA-aaaa-bbbb-cccc"),
|
|
915
|
+
"GHSA-AAAA-BBBB-CCCC",
|
|
916
|
+
);
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
test("ghsaIdFromUrl: only the last path segment counts, never the host", () => {
|
|
920
|
+
// Matching a GHSA-shaped substring anywhere in a caller-controlled URL would
|
|
921
|
+
// let a hostile host or query string mint an id that silences an allowlist
|
|
922
|
+
// lookup. Only the final path segment is tested.
|
|
923
|
+
assert.equal(ghsaIdFromUrl("https://GHSA-aaaa-bbbb-cccc.example.com/x"), null);
|
|
924
|
+
assert.equal(
|
|
925
|
+
ghsaIdFromUrl("https://example.com/p?id=GHSA-aaaa-bbbb-cccc"),
|
|
926
|
+
null,
|
|
927
|
+
);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
test("ghsaIdFromUrl: rejects non-urls, empty values and non-GHSA segments", () => {
|
|
931
|
+
for (const value of ["", "not a url", null, undefined, 42, "https://example.com/x"]) {
|
|
932
|
+
assert.equal(ghsaIdFromUrl(value), null, String(value));
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
// ---------------------------------------------------------------------------
|
|
937
|
+
// The npm v7+ (`auditReportVersion` 2) report shape
|
|
938
|
+
//
|
|
939
|
+
// Fixture-driven of necessity: this repo has zero vulnerabilities tree-wide,
|
|
940
|
+
// so no live npm sample carrying an advisory can be captured from it.
|
|
941
|
+
// ---------------------------------------------------------------------------
|
|
942
|
+
|
|
943
|
+
const NPM_HIGH_URL = "https://github.com/advisories/GHSA-dddd-eeee-ffff";
|
|
944
|
+
|
|
945
|
+
/** An npm v2 report with one high advisory reached through `pkg`. */
|
|
946
|
+
function npmReportWithHigh({ severity = "high", cve = [] } = {}) {
|
|
947
|
+
return {
|
|
948
|
+
auditReportVersion: 2,
|
|
949
|
+
vulnerabilities: {
|
|
950
|
+
pkg: {
|
|
951
|
+
name: "pkg",
|
|
952
|
+
severity,
|
|
953
|
+
via: [
|
|
954
|
+
{
|
|
955
|
+
source: 123456,
|
|
956
|
+
name: "pkg",
|
|
957
|
+
title: "Prototype pollution in pkg",
|
|
958
|
+
url: NPM_HIGH_URL,
|
|
959
|
+
severity,
|
|
960
|
+
cve,
|
|
961
|
+
},
|
|
962
|
+
],
|
|
963
|
+
},
|
|
964
|
+
},
|
|
965
|
+
metadata: { vulnerabilities: { high: 1, total: 1 } },
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
test("recognizeReport: identifies each schema and normalizes its advisories", () => {
|
|
970
|
+
assert.equal(recognizeReport(reportWith({ 1: HIGH_GHSA })).schema, "legacy");
|
|
971
|
+
|
|
972
|
+
const npm = recognizeReport(npmReportWithHigh());
|
|
973
|
+
assert.equal(npm.schema, "npm");
|
|
974
|
+
assert.equal(npm.advisories.length, 1);
|
|
975
|
+
assert.deepEqual(npm.advisories[0].ids, ["GHSA-DDDD-EEEE-FFFF"]);
|
|
976
|
+
assert.equal(npm.advisories[0].severity, "high");
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
test("recognizeReport: an empty npm report is RECOGNIZED, not unreadable", () => {
|
|
980
|
+
// The real shape this repo produces today. It must read as a genuine clean —
|
|
981
|
+
// reached by inspecting `vulnerabilities`, not by failing to find
|
|
982
|
+
// `advisories`.
|
|
983
|
+
const result = recognizeReport({
|
|
984
|
+
auditReportVersion: 2,
|
|
985
|
+
vulnerabilities: {},
|
|
986
|
+
metadata: { vulnerabilities: { total: 0 } },
|
|
987
|
+
});
|
|
988
|
+
assert.equal(result.schema, "npm");
|
|
989
|
+
assert.deepEqual(result.advisories, []);
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
test("evaluateReport: an unsuppressed high in the NPM shape blocks", () => {
|
|
993
|
+
const result = evaluateReport(npmReportWithHigh(), 1, new Set());
|
|
994
|
+
assert.equal(result.exitCode, 1);
|
|
995
|
+
assert.equal(result.reason, "unsuppressed");
|
|
996
|
+
assert.equal(result.schema, "npm");
|
|
997
|
+
assert.equal(result.blocking.length, 1);
|
|
998
|
+
assert.equal(result.blocking[0].id, "GHSA-DDDD-EEEE-FFFF");
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
test("evaluateReport: an allowlisted GHSA id suppresses in the NPM shape", () => {
|
|
1002
|
+
// The id exists only inside `via[].url` here, so this is the assertion that
|
|
1003
|
+
// the allowlist still means something once the schema changes.
|
|
1004
|
+
const result = evaluateReport(
|
|
1005
|
+
npmReportWithHigh(),
|
|
1006
|
+
1,
|
|
1007
|
+
new Set(["GHSA-DDDD-EEEE-FFFF"]),
|
|
1008
|
+
);
|
|
1009
|
+
assert.equal(result.exitCode, 0);
|
|
1010
|
+
assert.equal(result.reason, "clean");
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
test("evaluateReport: a CVE id suppresses in the NPM shape too", () => {
|
|
1014
|
+
const report = npmReportWithHigh({ cve: ["CVE-2026-9999"] });
|
|
1015
|
+
assert.equal(
|
|
1016
|
+
evaluateReport(report, 1, new Set(["CVE-2026-9999"])).exitCode,
|
|
1017
|
+
0,
|
|
1018
|
+
);
|
|
1019
|
+
assert.equal(evaluateReport(report, 1, new Set()).exitCode, 1);
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
test("evaluateReport: below-gate npm severities never block", () => {
|
|
1023
|
+
for (const severity of ["moderate", "low", "info"]) {
|
|
1024
|
+
const result = evaluateReport(npmReportWithHigh({ severity }), 0, new Set());
|
|
1025
|
+
assert.equal(result.exitCode, 0, severity);
|
|
1026
|
+
assert.equal(result.reason, "clean", severity);
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
test("recognizeReport: a transitive npm chain counts its advisory once", () => {
|
|
1031
|
+
// `via` carries STRING edges for transitive chains alongside advisory
|
|
1032
|
+
// objects. Counting an edge as an advisory would inflate the finding set;
|
|
1033
|
+
// counting the same advisory once per reaching package would duplicate it.
|
|
1034
|
+
const report = {
|
|
1035
|
+
auditReportVersion: 2,
|
|
1036
|
+
vulnerabilities: {
|
|
1037
|
+
pkg: npmReportWithHigh().vulnerabilities.pkg,
|
|
1038
|
+
dependent: { name: "dependent", severity: "high", via: ["pkg"] },
|
|
1039
|
+
other: npmReportWithHigh().vulnerabilities.pkg,
|
|
1040
|
+
},
|
|
1041
|
+
metadata: {},
|
|
1042
|
+
};
|
|
1043
|
+
assert.equal(recognizeReport(report).advisories.length, 1);
|
|
1044
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-advisory-scan-setup.test.mjs — regression guard for advisory-scan.yml's
|
|
4
|
+
* `setup` input (Story #471).
|
|
5
|
+
*
|
|
6
|
+
* The bug this pins: `advisory-scan.yml` provisioned Node one way only — the
|
|
7
|
+
* `setup-toolchain` composite, which is pnpm-only (`actions/setup-node` with
|
|
8
|
+
* `cache: pnpm`, then `pnpm install --frozen-lockfile`). This repo is npm
|
|
9
|
+
* (`package-lock.json`, `npm ci` in ci.yml, no `pnpm-lock.yaml`), so the
|
|
10
|
+
* scheduled dogfood caller died at setup-node — "Dependencies lock file is not
|
|
11
|
+
* found ... Supported file patterns: pnpm-lock.yaml" — on EVERY run from the
|
|
12
|
+
* day it shipped. The job never reached the scan, so a workflow whose whole
|
|
13
|
+
* job is to notice things silently noticed nothing for seven weeks.
|
|
14
|
+
*
|
|
15
|
+
* That failure mode is why the assertions below are behavioural rather than
|
|
16
|
+
* textual wherever they can be: the `if:` expressions are EXTRACTED and RUN
|
|
17
|
+
* under GitHub's own truthiness rules (`scripts/lib/actions-expression.mjs`),
|
|
18
|
+
* because the defect class here is an expression that reads correctly and
|
|
19
|
+
* evaluates wrong.
|
|
20
|
+
*
|
|
21
|
+
* Run: node --test scripts/check-advisory-scan-setup.test.mjs
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import assert from "node:assert/strict";
|
|
25
|
+
import { test } from "node:test";
|
|
26
|
+
import { readFileSync } from "node:fs";
|
|
27
|
+
import { evaluate } from "./lib/actions-expression.mjs";
|
|
28
|
+
|
|
29
|
+
const ADVISORY = ".github/workflows/advisory-scan.yml";
|
|
30
|
+
const SCHEDULE = ".github/workflows/advisory-scan-schedule.yml";
|
|
31
|
+
const DOCS = "docs/reusable-workflows.md";
|
|
32
|
+
const ARCHITECTURE = "docs/architecture.md";
|
|
33
|
+
|
|
34
|
+
const advisory = readFileSync(ADVISORY, "utf8");
|
|
35
|
+
const schedule = readFileSync(SCHEDULE, "utf8");
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Extraction — the same read-then-execute approach as
|
|
39
|
+
// check-toolchain-cache-default.test.mjs, so a guarded expression is never
|
|
40
|
+
// asserted by its spelling.
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The block of one `- name: <name>` step, up to the next step at the same
|
|
45
|
+
* indent. Scans lines rather than building a `new RegExp` around `name`: a
|
|
46
|
+
* dynamically-constructed regex is a SAST finding and buys nothing here.
|
|
47
|
+
*/
|
|
48
|
+
function stepByName(text, name) {
|
|
49
|
+
const lines = text.split("\n");
|
|
50
|
+
const start = lines.findIndex((l) => l.trim() === `- name: ${name}`);
|
|
51
|
+
assert.notEqual(start, -1, `${ADVISORY}: step "${name}" not found`);
|
|
52
|
+
const indent = lines[start].match(/^(\s*)/)[1].length;
|
|
53
|
+
const out = [lines[start]];
|
|
54
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
55
|
+
const trimmed = lines[i].trim();
|
|
56
|
+
if (trimmed.startsWith("- ") && lines[i].match(/^(\s*)/)[1].length <= indent) break;
|
|
57
|
+
out.push(lines[i]);
|
|
58
|
+
}
|
|
59
|
+
return out.join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The `${{ … }}`-free body of a step's `if:` condition. */
|
|
63
|
+
function ifExpression(step, name) {
|
|
64
|
+
const m = step.match(/^\s*if:\s*(.+)$/m);
|
|
65
|
+
assert.ok(m, `step "${name}" has no \`if:\` guard`);
|
|
66
|
+
return m[1].trim().replace(/^\$\{\{/, "").replace(/\}\}$/, "").trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The literal `default:` of the named workflow_call input. */
|
|
70
|
+
function inputDefault(text, name) {
|
|
71
|
+
const lines = text.split("\n");
|
|
72
|
+
const start = lines.indexOf(` ${name}:`);
|
|
73
|
+
assert.notEqual(start, -1, `${ADVISORY}: workflow_call input \`${name}\` not found`);
|
|
74
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
75
|
+
if (lines[i].trim() === "") continue;
|
|
76
|
+
if (lines[i].match(/^(\s*)/)[1].length <= 6) break;
|
|
77
|
+
const d = lines[i].match(/^\s*default:\s*(.+)$/);
|
|
78
|
+
if (d) return d[1].trim();
|
|
79
|
+
}
|
|
80
|
+
return assert.fail(`${ADVISORY}: input \`${name}\` has no default`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const TOOLCHAIN_STEP = "Setup toolchain";
|
|
84
|
+
const NODE_STEP = "Setup Node.js (install-free)";
|
|
85
|
+
const GUARD_STEP = "Validate setup input";
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// AC-1 — the default is a literal, so no consumer moves.
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
test("the `setup` default is a literal 'toolchain', not an expression", () => {
|
|
92
|
+
// An expression here is what portability lint Rule 2 rejects and what GitHub
|
|
93
|
+
// silently fails to evaluate at interface-validation time. The value itself
|
|
94
|
+
// matters just as much: every consumer on today's pinned SHA passes no
|
|
95
|
+
// `setup` at all, so the default IS their behaviour.
|
|
96
|
+
const value = inputDefault(advisory, "setup");
|
|
97
|
+
assert.equal(value, "'toolchain'");
|
|
98
|
+
assert.doesNotMatch(value, /\$\{\{/, "a workflow_call default may not hold an expression");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// AC-3 — one cache site, and none on the install-free path.
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
test("exactly one cache expression exists, and it is the toolchain step's", () => {
|
|
106
|
+
// check-toolchain-cache-default.test.mjs extracts the FIRST `cache: ${{ … }}`
|
|
107
|
+
// in this file and asserts it against pr-quality.yml's. A second site would
|
|
108
|
+
// silently pin the wrong expression while that whole suite stayed green — so
|
|
109
|
+
// the count, not just the value, is the invariant.
|
|
110
|
+
const sites = advisory.split("\n").filter((l) => /^\s*cache:\s*\$\{\{.+\}\}\s*$/.test(l));
|
|
111
|
+
assert.equal(sites.length, 1, "expected exactly one cache: call site");
|
|
112
|
+
assert.ok(
|
|
113
|
+
stepByName(advisory, TOOLCHAIN_STEP).includes(sites[0]),
|
|
114
|
+
"the surviving cache site must belong to the setup-toolchain step",
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("the install-free step declares no cache key at all", () => {
|
|
119
|
+
// Not even `cache: ''` — an empty value is still a second site for a reader,
|
|
120
|
+
// and there is nothing to cache on a path that installs nothing.
|
|
121
|
+
assert.doesNotMatch(stepByName(advisory, NODE_STEP), /^\s*cache:/m);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// AC-4 — the install-free path provisions a pinned Node and installs nothing.
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
test("the install-free step pins Node from .nvmrc via a SHA-pinned setup-node", () => {
|
|
129
|
+
const step = stepByName(advisory, NODE_STEP);
|
|
130
|
+
assert.match(step, /node-version-file:\s*\.nvmrc/, "Node must come from .nvmrc, not a literal");
|
|
131
|
+
assert.match(
|
|
132
|
+
step,
|
|
133
|
+
/uses:\s*actions\/setup-node@[0-9a-f]{40}/,
|
|
134
|
+
"setup-node must be SHA-pinned",
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("no dependency install runs on the install-free path", () => {
|
|
139
|
+
// The whole point of the path: osv-scanner reads lockfiles off disk, and both
|
|
140
|
+
// 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");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// AC-5 — exhaustive selection, and a loud failure on anything else.
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
test("each recognized `setup` value selects exactly one provisioning step", () => {
|
|
151
|
+
// Evaluated, not grepped: `&&`/`||` in an Actions expression yield OPERANDS
|
|
152
|
+
// and every non-empty string is truthy, so a guard can read right and select
|
|
153
|
+
// both branches — or neither.
|
|
154
|
+
const guards = [
|
|
155
|
+
{ name: TOOLCHAIN_STEP, expr: ifExpression(stepByName(advisory, TOOLCHAIN_STEP), TOOLCHAIN_STEP) },
|
|
156
|
+
{ name: NODE_STEP, expr: ifExpression(stepByName(advisory, NODE_STEP), NODE_STEP) },
|
|
157
|
+
];
|
|
158
|
+
for (const setup of ["toolchain", "node"]) {
|
|
159
|
+
const selected = guards.filter(({ expr }) => evaluate(expr, { setup }) === true);
|
|
160
|
+
assert.equal(
|
|
161
|
+
selected.length,
|
|
162
|
+
1,
|
|
163
|
+
`setup '${setup}' selected ${selected.length} step(s): ${selected.map((s) => s.name).join(", ")}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("an unrecognized `setup` value selects NO provisioning step — so a guard must reject it first", () => {
|
|
169
|
+
// This is the fail-closed half. With both branches skipped and no guard, the
|
|
170
|
+
// job would run the composites' gate scripts against whatever ambient Node
|
|
171
|
+
// the runner image carries: green, unpinned, and wrong.
|
|
172
|
+
const guards = [TOOLCHAIN_STEP, NODE_STEP].map((name) =>
|
|
173
|
+
ifExpression(stepByName(advisory, name), name),
|
|
174
|
+
);
|
|
175
|
+
for (const setup of ["", "nodejs", "Toolchain ", "true"]) {
|
|
176
|
+
assert.equal(
|
|
177
|
+
guards.filter((expr) => evaluate(expr, { setup }) === true).length,
|
|
178
|
+
0,
|
|
179
|
+
`setup '${setup}' must not select a provisioning step`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const guard = stepByName(advisory, GUARD_STEP);
|
|
184
|
+
assert.match(guard, /toolchain\|node\)/, "the guard must accept exactly the two known values");
|
|
185
|
+
assert.match(guard, /::error::/, "the guard must fail loudly, not warn");
|
|
186
|
+
assert.match(guard, /exit 1/, "the guard must fail the job");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("the guard runs before either provisioning step", () => {
|
|
190
|
+
// A guard that ran after the branches would report the typo only once the
|
|
191
|
+
// damage — a scan on ambient Node — had already been done.
|
|
192
|
+
const order = [GUARD_STEP, TOOLCHAIN_STEP, NODE_STEP].map((name) =>
|
|
193
|
+
advisory.indexOf(`- name: ${name}`),
|
|
194
|
+
);
|
|
195
|
+
assert.ok(order.every((i) => i !== -1), "every named step must exist");
|
|
196
|
+
assert.ok(order[0] < order[1] && order[0] < order[2], "the guard must come first");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// AC-6 — the dogfood caller is on the new path.
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
test("the scheduled dogfood caller passes setup: node", () => {
|
|
204
|
+
// This repo commits package-lock.json and has no pnpm-lock.yaml, so the
|
|
205
|
+
// pnpm default cannot survive its own setup step here.
|
|
206
|
+
assert.match(schedule, /^\s*setup:\s*node\s*$/m, `${SCHEDULE} must pass setup: node`);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("this repo really is the npm case the caller claims", () => {
|
|
210
|
+
// The assertion above is only correct while the premise holds. If this repo
|
|
211
|
+
// ever adopts pnpm, this test fails and the caller gets revisited — rather
|
|
212
|
+
// than silently keeping an install-free path it no longer needs.
|
|
213
|
+
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
|
214
|
+
assert.ok(pkg, "package.json must parse");
|
|
215
|
+
assert.doesNotThrow(
|
|
216
|
+
() => readFileSync("package-lock.json", "utf8"),
|
|
217
|
+
"package-lock.json must exist for the npm premise to hold",
|
|
218
|
+
);
|
|
219
|
+
assert.throws(
|
|
220
|
+
() => readFileSync("pnpm-lock.yaml", "utf8"),
|
|
221
|
+
"a pnpm-lock.yaml would mean setup: node is no longer the right call here",
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// AC-8 / AC-9 — the documented contract.
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
test("the advisory-scan Inputs table documents the `setup` input", () => {
|
|
230
|
+
// The table is the consumer-facing contract; an input absent from it is one
|
|
231
|
+
// no consumer can be expected to find.
|
|
232
|
+
const docs = readFileSync(DOCS, "utf8");
|
|
233
|
+
const rows = docs.split("\n").filter((l) => /^\|\s*`setup`\s*\|\s*string\s*\|/.test(l));
|
|
234
|
+
assert.equal(rows.length, 1, "expected exactly one documented `setup` row");
|
|
235
|
+
assert.match(rows[0], /`'toolchain'`/, "row does not state the 'toolchain' default");
|
|
236
|
+
assert.match(rows[0], /\.nvmrc/, "row does not state the node path's .nvmrc requirement");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("the Tech Stack row names this repo's real package manager", () => {
|
|
240
|
+
// The row claiming pnpm is what made the pnpm-only setup look correct when
|
|
241
|
+
// advisory-scan.yml was pointed at this repo.
|
|
242
|
+
const arch = readFileSync(ARCHITECTURE, "utf8");
|
|
243
|
+
const rows = arch.split("\n").filter((l) => /^\|\s*Package manager\s*\|/.test(l));
|
|
244
|
+
assert.equal(rows.length, 1, "expected exactly one Package manager row");
|
|
245
|
+
assert.match(rows[0], /npm/, "row must name npm");
|
|
246
|
+
assert.match(rows[0], /package-lock\.json/, "row must name the committed lockfile");
|
|
247
|
+
});
|