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.
@@ -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: uninterpretable report + zero exit → exit 0 (clean, nothing to report)", () => {
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, 0);
325
- assert.equal(result.reason, "clean-no-advisories");
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
+ });