mandrel-platform 1.0.0 → 1.1.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.
@@ -17,20 +17,34 @@
17
17
  * 1. STRUCTURE — each aggregator's `steps:` derive results from
18
18
  * `toJSON(needs)` and contain NO hardcoded reference to any job named in
19
19
  * its own `needs:` array (the "no hardcoded tier-name list" AC).
20
- * 2. PARITY — the two aggregators' `steps:` blocks are textually identical,
21
- * so a fix to one cannot drift from the other.
20
+ * 2. PARITY — the two aggregators' `run:` scripts are byte-identical and both
21
+ * declare the same `env:` keys, so a fix to one cannot drift from the
22
+ * other. (Story #333 loosened this from "the whole `steps:` blocks are
23
+ * identical": ci.yml has no `workflow_call` inputs, so it must source
24
+ * `CANCELLED_POLICY` as a literal where pr-quality.yml sources it from
25
+ * `inputs.cancelled-policy`. The shared script reads it from the
26
+ * environment, which is what keeps the logic itself byte-identical.)
22
27
  * 3. SEMANTICS — the shared run script passes on `success`/`skipped` and
23
28
  * fails on anything else, INCLUDING `cancelled` (load-bearing for #223's
24
29
  * fail-fast design), while naming the failing jobs and their results.
25
30
  * Executed against real bash+jq; skipped when jq is unavailable locally
26
- * (CI's ubuntu runner always has it).
31
+ * (CI's ubuntu runner always has it). `gh` is stubbed to fail so these
32
+ * tests stay hermetic — provenance classification (Story #333) has its
33
+ * own suite in scripts/check-cancelled-provenance.test.mjs.
27
34
  *
28
35
  * Run: node --test scripts/check-ci-required-aggregator.test.mjs
29
36
  */
30
37
 
31
38
  import assert from "node:assert/strict";
32
39
  import { test } from "node:test";
33
- import { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
40
+ import {
41
+ readFileSync,
42
+ mkdtempSync,
43
+ mkdirSync,
44
+ writeFileSync,
45
+ chmodSync,
46
+ rmSync,
47
+ } from "node:fs";
34
48
  import { execFileSync, spawnSync } from "node:child_process";
35
49
  import { join, resolve, dirname } from "node:path";
36
50
  import { fileURLToPath } from "node:url";
@@ -155,14 +169,61 @@ for (const { rel, needs, steps } of blocks) {
155
169
  // 2. PARITY — the two implementations are textually identical
156
170
  // ---------------------------------------------------------------------------
157
171
 
158
- test("pr-quality.yml and ci.yml aggregator steps are textually identical", () => {
172
+ // Story #333 re-shaped this from "the whole `steps:` blocks are byte-identical"
173
+ // to "the `run:` scripts are byte-identical AND both declare the same `env:`
174
+ // keys". ci.yml has no `workflow_call` inputs, so it cannot source
175
+ // `CANCELLED_POLICY` from `inputs.cancelled-policy` the way pr-quality.yml
176
+ // does — but the LOGIC is what must not drift, and the script reads the policy
177
+ // from the environment precisely so the two can share it verbatim.
178
+
179
+ /** The `KEY:` names declared under a steps block's `env:` mapping. */
180
+ function extractEnvKeys(steps) {
181
+ const stepLines = steps.split("\n");
182
+ const start = stepLines.findIndex((l) => /^\s+env:\s*$/.test(l));
183
+ assert.notEqual(start, -1, "`env:` block not found");
184
+ const envIndent = stepLines[start].match(/^(\s*)/)[1].length;
185
+ const keys = [];
186
+ for (let i = start + 1; i < stepLines.length; i++) {
187
+ if (/^\s*$/.test(stepLines[i])) continue;
188
+ if (/^\s*#/.test(stepLines[i])) continue;
189
+ const indent = stepLines[i].match(/^(\s*)/)[1].length;
190
+ if (indent <= envIndent) break;
191
+ const key = stepLines[i].match(/^\s+([A-Za-z_][A-Za-z0-9_]*):/);
192
+ if (key) keys.push(key[1]);
193
+ }
194
+ assert.ok(keys.length > 0, "`env:` block declared no keys");
195
+ return keys.sort();
196
+ }
197
+
198
+ test("pr-quality.yml and ci.yml aggregator run scripts are byte-identical", () => {
159
199
  assert.equal(
160
- blocks[0].steps,
161
- blocks[1].steps,
162
- "the two `ci-required` steps blocks must not drift — apply every change to both"
200
+ extractRunScript(blocks[0].steps),
201
+ extractRunScript(blocks[1].steps),
202
+ "the two `ci-required` run scripts must not drift — apply every logic change to both"
203
+ );
204
+ });
205
+
206
+ test("pr-quality.yml and ci.yml aggregators declare the same env keys", () => {
207
+ assert.deepEqual(
208
+ extractEnvKeys(blocks[0].steps),
209
+ extractEnvKeys(blocks[1].steps),
210
+ "the shared run script reads its inputs from the environment — a key present " +
211
+ "in only one workflow would leave the other running the same script unconfigured"
163
212
  );
164
213
  });
165
214
 
215
+ test("the shared aggregator script never references workflow_call inputs", () => {
216
+ // ci.yml has none, so an `inputs.*` reference could not be mirrored and
217
+ // would break the byte-identical run-script guarantee above.
218
+ for (const { rel, steps } of blocks) {
219
+ assert.doesNotMatch(
220
+ extractRunScript(steps),
221
+ /inputs\./,
222
+ `${rel}: the run script must read configuration from \`env:\`, not \`inputs.*\``
223
+ );
224
+ }
225
+ });
226
+
166
227
  // ---------------------------------------------------------------------------
167
228
  // 3. SEMANTICS — pass on success/skipped, fail (naming jobs) on anything else
168
229
  // ---------------------------------------------------------------------------
@@ -176,7 +237,7 @@ function jqAvailable() {
176
237
  }
177
238
  }
178
239
 
179
- function runAggregator(needsResults) {
240
+ function runAggregator(needsResults, { captureSummary = false } = {}) {
180
241
  const script = extractRunScript(blocks[0].steps);
181
242
  const dir = mkdtempSync(join(tmpdir(), "ci-required-"));
182
243
  try {
@@ -185,10 +246,36 @@ function runAggregator(needsResults) {
185
246
  const needsJson = Object.fromEntries(
186
247
  Object.entries(needsResults).map(([k, result]) => [k, { result, outputs: {} }])
187
248
  );
188
- return spawnSync("bash", [file], {
189
- encoding: "utf8",
190
- env: { ...process.env, NEEDS_JSON: JSON.stringify(needsJson) },
191
- });
249
+ // Neutralize the provenance lookup (Story #333): shadow `gh` with a stub
250
+ // that always fails, so these tests stay hermetic and exercise the
251
+ // `unknown`-provenance path. Provenance classification itself is covered
252
+ // by scripts/check-cancelled-provenance.test.mjs.
253
+ const bin = join(dir, "bin");
254
+ mkdirSync(bin);
255
+ const ghStub = join(bin, "gh");
256
+ writeFileSync(ghStub, "#!/usr/bin/env bash\nexit 1\n");
257
+ chmodSync(ghStub, 0o755);
258
+
259
+ const env = {
260
+ ...process.env,
261
+ PATH: `${bin}:${process.env.PATH}`,
262
+ NEEDS_JSON: JSON.stringify(needsJson),
263
+ };
264
+ // The step-summary write must degrade when GITHUB_STEP_SUMMARY is unset
265
+ // (this harness, and any `act`-style local runner), so only define it when
266
+ // a test is actually asserting on the summary body.
267
+ const summaryPath = join(dir, "summary.md");
268
+ if (captureSummary) {
269
+ writeFileSync(summaryPath, "");
270
+ env.GITHUB_STEP_SUMMARY = summaryPath;
271
+ } else {
272
+ delete env.GITHUB_STEP_SUMMARY;
273
+ }
274
+ const r = spawnSync("bash", [file], { encoding: "utf8", env });
275
+ return {
276
+ ...r,
277
+ summary: captureSummary ? readFileSync(summaryPath, "utf8") : "",
278
+ };
192
279
  } finally {
193
280
  rmSync(dir, { recursive: true, force: true });
194
281
  }
@@ -225,3 +312,72 @@ test("run script: every non-passing job is named", semantics, () => {
225
312
  assert.match(r.stderr, /unit\(cancelled\)/);
226
313
  assert.doesNotMatch(r.stderr, /e2e/);
227
314
  });
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // 4. TRIAGE PARTITION (Story #331) — a fail-fast run reds N jobs but only one
318
+ // of them actually failed. The aggregate must say WHICH, so triage does not
319
+ // land on a collateral cancel.
320
+ // ---------------------------------------------------------------------------
321
+
322
+ test("run script: partitions own-failures from collateral cancels", semantics, () => {
323
+ const r = runAggregator({
324
+ lint: "success",
325
+ security: "failure",
326
+ unit: "cancelled",
327
+ e2e: "cancelled",
328
+ });
329
+ assert.equal(r.status, 1);
330
+
331
+ const own = r.stderr
332
+ .split("\n")
333
+ .find((l) => l.includes("Failed on their own"));
334
+ const collateral = r.stderr
335
+ .split("\n")
336
+ .find((l) => l.includes("do not triage"));
337
+
338
+ assert.ok(own, `no own-failure line in stderr:\n${r.stderr}`);
339
+ assert.ok(collateral, `no collateral line in stderr:\n${r.stderr}`);
340
+
341
+ // The tier that actually failed is named ONLY on the triage line, and the
342
+ // cancelled siblings ONLY on the collateral line — a partition, not two
343
+ // copies of the same flat list.
344
+ assert.match(own, /security\(failure\)/);
345
+ assert.doesNotMatch(own, /unit|e2e/);
346
+ assert.match(collateral, /unit\(cancelled\)/);
347
+ assert.match(collateral, /e2e\(cancelled\)/);
348
+ assert.doesNotMatch(collateral, /security/);
349
+ });
350
+
351
+ test("run script: an all-cancelled aggregate says no job failed on its own", semantics, () => {
352
+ const r = runAggregator({ lint: "success", unit: "cancelled" }, { captureSummary: true });
353
+ assert.equal(r.status, 1);
354
+ assert.doesNotMatch(r.stderr, /Failed on their own/);
355
+ assert.match(r.summary, /No job reported its own failure/);
356
+ });
357
+
358
+ test("run script: writes the triage partition to the job summary", semantics, () => {
359
+ const r = runAggregator(
360
+ { security: "failure", unit: "cancelled" },
361
+ { captureSummary: true }
362
+ );
363
+ assert.equal(r.status, 1);
364
+ assert.match(r.summary, /ci-required failed/);
365
+ assert.match(r.summary, /Failed on their own[^\n]*security\(failure\)/);
366
+ assert.match(r.summary, /Cancelled[^\n]*do not triage:[^\n]*unit\(cancelled\)/);
367
+ });
368
+
369
+ test("run script: a green aggregate writes no summary", semantics, () => {
370
+ const r = runAggregator({ lint: "success", unit: "success" }, { captureSummary: true });
371
+ assert.equal(r.status, 0, r.stderr);
372
+ assert.equal(r.summary, "");
373
+ });
374
+
375
+ test("run script: survives an unset GITHUB_STEP_SUMMARY", semantics, () => {
376
+ // The failure path writes a summary; with the variable unset (local runners,
377
+ // `act`, this harness) the redirect must degrade rather than error out and
378
+ // swallow the exit-1 verdict.
379
+ const r = runAggregator({ unit: "failure" });
380
+ assert.equal(r.status, 1);
381
+ assert.match(r.stderr, /unit\(failure\)/);
382
+ assert.doesNotMatch(r.stderr, /ambiguous redirect|No such file or directory/);
383
+ });
@@ -56,6 +56,43 @@ test("globToRegExp: `**` crosses path separators, `*` does not", () => {
56
56
  assert.ok(globToRegExp("*.sql").test("x.sql"));
57
57
  });
58
58
 
59
+ // The glob set reaches this function from a CLI argument, so CodeQL reports
60
+ // `js/regex-injection` (high) on the `new RegExp` it builds. That alert is a
61
+ // false positive ONLY for as long as the translator escapes every regex
62
+ // metacharacter — the input must be able to control `*` / `**` semantics and
63
+ // nothing else. These two tests are the evidence that claim rests on; if a
64
+ // future edit drops a character from the escape set, they fail rather than
65
+ // letting a stale dismissal stand.
66
+ test("globToRegExp: escapes every regex metacharacter to a literal", () => {
67
+ // `*` is excluded deliberately — it is the one character the glob syntax
68
+ // gives meaning to. `/` is not a metacharacter in the RegExp *constructor*
69
+ // (only in literal notation), but is asserted anyway.
70
+ const METACHARS = [".", "+", "?", "^", "$", "{", "}", "(", ")", "|", "[", "]", "\\", "/"];
71
+ for (const meta of METACHARS) {
72
+ const glob = `a${meta}b`;
73
+ const re = globToRegExp(glob);
74
+ assert.ok(re.test(glob), `\`${meta}\` must match itself literally (got ${re})`);
75
+ assert.ok(
76
+ !re.test("aXb"),
77
+ `\`${meta}\` leaked regex meaning — it matched an arbitrary character (got ${re})`
78
+ );
79
+ }
80
+ });
81
+
82
+ test("globToRegExp: a regex-injection payload translates to a literal pattern", () => {
83
+ // The classic catastrophic-backtracking payload. If any of it survived
84
+ // unescaped, this pattern would carry regex semantics into the matcher.
85
+ const re = globToRegExp("(a+)+$");
86
+ // Compared as a STRING, not against a regex literal: writing the expected
87
+ // pattern as `/^\(a\+\)\+\$$/` puts a (wholly escaped, quantifier-free)
88
+ // regex literal in the file that CodeQL's js/redos analysis reads as a live
89
+ // backtracking hazard. The assertion is identical in strength — this test
90
+ // has always been about the produced source text.
91
+ assert.equal(String(re), "/^\\(a\\+\\)\\+\\$$/");
92
+ assert.ok(re.test("(a+)+$"), "the payload matches only its literal self");
93
+ assert.ok(!re.test("aaaaaaaa"), "the payload carries no quantifier semantics");
94
+ });
95
+
59
96
  test("isMigrationFile: matches migration globs and any .sql tail", () => {
60
97
  assert.ok(isMigrationFile("apps/api/migrations/0007_drop.ts"));
61
98
  assert.ok(isMigrationFile("drizzle/0001_init.sql"));
@@ -90,7 +90,14 @@ export const RULES = [
90
90
  {
91
91
  id: 'quality-yml-ref',
92
92
  description: 'References `quality.yml` — verify this file exists in the project (swarm-os ships `ci.yml` instead)',
93
- pattern: /quality\.yml/g,
93
+ // The bare filename only — the lookbehind stops the platform's OWN
94
+ // `pr-quality.yml` (and any other `<prefix>-quality.yml`) from matching as
95
+ // a substring. Without it this rule produced 58 findings against
96
+ // mandrel-platform's docs and 1 was a real bare reference, drowning a
97
+ // genuine `expired-placeholder` error in known-benign warnings. Guarding on
98
+ // `[-\w]` rather than spelling out `pr-` keeps it correct for a consumer
99
+ // that names its own caller `ci-quality.yml`.
100
+ pattern: /(?<![-\w])quality\.yml/g,
94
101
  severity: 'warning',
95
102
  },
96
103
  {
@@ -102,7 +109,13 @@ export const RULES = [
102
109
  // 4-digit 20xx year and defer the "is it actually in the past?" decision
103
110
  // to `matchFilter`, so the rule stays correct as the calendar advances and
104
111
  // never flags a still-valid FUTURE expiry.
105
- pattern: /expires[:\s]+(20\d{2}-\d{2}-\d{2})/gi,
112
+ // The optional quotes either side of the separator are load-bearing: the
113
+ // CVE allowlist's own shape is JSON (`"expires": "2026-12-31"` — see
114
+ // audit-check.mjs), and `expires"` is neither `:` nor whitespace, so the
115
+ // unquoted-only form skipped every documented allowlist entry. That is the
116
+ // same fail-open class as the 202[0-4] year window this rule already fixed;
117
+ // it was hiding a second lapsed date in docs/runbooks/dependency-update.md.
118
+ pattern: /expires['"]?[:\s]+['"]?(20\d{2}-\d{2}-\d{2})/gi,
106
119
  severity: 'error',
107
120
  // Only flag when the captured date is strictly before today (UTC). Future
108
121
  // expiries are still valid and must not be reported.
@@ -3,16 +3,23 @@
3
3
  * check-docs-staleness.test.mjs — node:test suite for the docs-staleness lint
4
4
  * (Story #197).
5
5
  *
6
- * Focus: the `expired-placeholder` rule. The rule previously hardcoded the
7
- * years 2020–2024 (`/expires[:\s]+202[0-4]-\d{2}-\d{2}/i`), so an expiry that
8
- * lapsed in 2025, 2026, or any later year sailed through the gate — a
9
- * fail-open. The fix broadens the pattern to any 20xx year and defers the
10
- * "is it actually in the past?" decision to `isExpiredDate`, so the rule stays
11
- * correct as the calendar advances and never flags a still-valid future date.
6
+ * Focus: rule PRECISION — the two ways a staleness rule stops being useful.
12
7
  *
13
- * These tests exercise the year fix directly (`isExpiredDate`) and end-to-end
14
- * (`lintFile` against a real fixture file), pinning "today" via a fixed clock
15
- * so they are deterministic.
8
+ * 1. Fail-open (`expired-placeholder` under-fires). The rule first hardcoded
9
+ * the years 2020–2024 (`/expires[:\s]+202[0-4]-\d{2}-\d{2}/i`), so an expiry
10
+ * that lapsed in 2025 or later sailed through. The fix broadens to any 20xx
11
+ * year and defers "is it actually in the past?" to `isExpiredDate`, so the
12
+ * rule stays correct as the calendar advances and never flags a still-valid
13
+ * future date. A second instance of the same class: the separator required
14
+ * `expires` to be followed directly by `:` or whitespace, which skipped the
15
+ * CVE allowlist's own JSON shape (`"expires": "…"`).
16
+ * 2. Fail-noisy (`quality-yml-ref` over-fires). A plain substring match meant
17
+ * the platform's own `pr-quality.yml` matched, so 58 of 59 findings were
18
+ * false positives — enough to bury a real error in the same run.
19
+ *
20
+ * These tests exercise the date logic directly (`isExpiredDate`), the compiled
21
+ * patterns, and end-to-end behaviour (`lintFile` against a real fixture file),
22
+ * pinning "today" via a fixed clock so they are deterministic.
16
23
  *
17
24
  * Run: node --test scripts/check-docs-staleness.test.mjs
18
25
  */
@@ -128,3 +135,101 @@ test('lintFile honours the staleness-ignore suppression comment for the year rul
128
135
  },
129
136
  );
130
137
  });
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // expired-placeholder — the JSON-quoted shape.
141
+ //
142
+ // Second fail-open of the same class as the 202[0-4] year window: the pattern
143
+ // required `expires` to be followed directly by `:` or whitespace, so the CVE
144
+ // allowlist's own JSON shape (`"expires": "2026-12-31"`, per audit-check.mjs)
145
+ // never matched — `expires"` is neither. Every documented allowlist entry was
146
+ // therefore invisible to the gate, including a lapsed one in
147
+ // docs/runbooks/dependency-update.md.
148
+ // ---------------------------------------------------------------------------
149
+
150
+ test('expired-placeholder matches the JSON-quoted allowlist shape', () => {
151
+ const rule = RULES.find((r) => r.id === 'expired-placeholder');
152
+ for (const line of [
153
+ ' "expires": "2025-12-31",', // the CVE allowlist's real shape
154
+ " 'expires': '2025-12-31',", // single-quoted (YAML/JS)
155
+ ' expires: "2025-12-31"', // quoted value, bare key
156
+ ]) {
157
+ rule.pattern.lastIndex = 0;
158
+ assert.ok(rule.pattern.test(line), `pattern should match ${JSON.stringify(line)}`);
159
+ }
160
+ });
161
+
162
+ test('expired-placeholder still matches the unquoted shapes (no regression)', () => {
163
+ const rule = RULES.find((r) => r.id === 'expired-placeholder');
164
+ for (const line of [
165
+ 'expires: 2025-01-01',
166
+ '# CVE-2022-3517 — expires 2025-06-01',
167
+ ]) {
168
+ rule.pattern.lastIndex = 0;
169
+ assert.ok(rule.pattern.test(line), `pattern should match ${JSON.stringify(line)}`);
170
+ }
171
+ });
172
+
173
+ test('lintFile flags a lapsed JSON-quoted expiry end-to-end', () => {
174
+ withTempDoc('Allowlist entry.\n "expires": "2025-12-31",\nEnd.\n', (file) => {
175
+ const expired = lintFile(file).filter((f) => f.rule.id === 'expired-placeholder');
176
+ assert.equal(expired.length, 1);
177
+ assert.match(expired[0].match, /2025-12-31/);
178
+ });
179
+ });
180
+
181
+ test('lintFile does NOT flag a future JSON-quoted expiry', () => {
182
+ withTempDoc('Allowlist entry.\n "expires": "2099-12-31",\nEnd.\n', (file) => {
183
+ const expired = lintFile(file).filter((f) => f.rule.id === 'expired-placeholder');
184
+ assert.equal(expired.length, 0);
185
+ });
186
+ });
187
+
188
+ test('lintFile does NOT flag a placeholder expiry token', () => {
189
+ // The runbooks intentionally use `<YYYY-MM-DD>` rather than a concrete date,
190
+ // precisely so an example can never lapse into a finding.
191
+ withTempDoc('Allowlist entry.\n "expires": "<YYYY-MM-DD>",\nEnd.\n', (file) => {
192
+ const expired = lintFile(file).filter((f) => f.rule.id === 'expired-placeholder');
193
+ assert.equal(expired.length, 0);
194
+ });
195
+ });
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // quality-yml-ref — bare filename only.
199
+ //
200
+ // The pattern was a plain substring match, so the platform's own
201
+ // `pr-quality.yml` matched: 58 of 59 findings against mandrel-platform's docs
202
+ // were that false positive, burying a real `expired-placeholder` error. The
203
+ // assertion below is on the invariant (a `<prefix>-quality.yml` is a different
204
+ // file) rather than on the single `pr-` spelling that motivated it.
205
+ // ---------------------------------------------------------------------------
206
+
207
+ test('quality-yml-ref does NOT match a prefixed <prefix>-quality.yml', () => {
208
+ const rule = RULES.find((r) => r.id === 'quality-yml-ref');
209
+ assert.ok(rule, 'quality-yml-ref rule must exist');
210
+ for (const line of [
211
+ 'uses: dsj1984/mandrel-platform/.github/workflows/pr-quality.yml@abc123',
212
+ 'the `pr-quality.yml` reusable workflow',
213
+ 'a consumer that names its caller `ci-quality.yml`',
214
+ 'see my_quality.yml for details',
215
+ ]) {
216
+ rule.pattern.lastIndex = 0;
217
+ assert.equal(
218
+ rule.pattern.test(line),
219
+ false,
220
+ `pattern must not match ${JSON.stringify(line)}`,
221
+ );
222
+ }
223
+ });
224
+
225
+ test('quality-yml-ref still matches a bare quality.yml reference', () => {
226
+ const rule = RULES.find((r) => r.id === 'quality-yml-ref');
227
+ for (const line of [
228
+ "| athportal | `quality.yml` | `quality` |",
229
+ 'the quality.yml workflow was renamed',
230
+ 'quality.yml',
231
+ ]) {
232
+ rule.pattern.lastIndex = 0;
233
+ assert.ok(rule.pattern.test(line), `pattern should match ${JSON.stringify(line)}`);
234
+ }
235
+ });