mandrel-platform 0.29.1 → 1.0.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.
- package/package.json +1 -1
- package/scripts/check-cancelled-provenance.test.mjs +494 -0
- package/scripts/check-ci-required-aggregator.test.mjs +169 -13
- package/scripts/check-destructive-migration.test.mjs +37 -0
- package/scripts/check-fail-fast-attribution.test.mjs +390 -0
- package/scripts/check-osv-scan-mode.test.mjs +489 -0
- package/scripts/osv-report-gate.test.mjs +315 -3
- package/scripts/platform-sync.mjs +89 -15
- package/scripts/platform-sync.test.mjs +125 -0
|
@@ -16,17 +16,25 @@ import {
|
|
|
16
16
|
classify,
|
|
17
17
|
findingsDigest,
|
|
18
18
|
renderSummary,
|
|
19
|
+
normalizeSource,
|
|
20
|
+
rowKey,
|
|
21
|
+
buildBaselineSet,
|
|
19
22
|
OsvGateError,
|
|
20
23
|
} from "../.github/actions/osv-scan/osv-report-gate.mjs";
|
|
21
24
|
|
|
22
|
-
// Build an OSV-scanner-shaped report for one grouped advisory.
|
|
23
|
-
|
|
25
|
+
// Build an OSV-scanner-shaped report for one grouped advisory. `published`
|
|
26
|
+
// rides on the per-vulnerability entries, mirroring the real OSV schema —
|
|
27
|
+
// group rows carry the severity, never the date.
|
|
28
|
+
const reportWith = (groups, { sourcePath = "pnpm-lock.yaml" } = {}) => ({
|
|
24
29
|
results: [
|
|
25
30
|
{
|
|
26
|
-
source: { path:
|
|
31
|
+
source: { path: sourcePath },
|
|
27
32
|
packages: groups.map((g) => ({
|
|
28
33
|
package: { name: g.name, version: g.version || "1.0.0", ecosystem: g.ecosystem || "npm" },
|
|
29
34
|
groups: [{ ids: g.ids, max_severity: g.score }],
|
|
35
|
+
...(g.published
|
|
36
|
+
? { vulnerabilities: g.ids.map((id) => ({ id, published: g.published })) }
|
|
37
|
+
: {}),
|
|
30
38
|
})),
|
|
31
39
|
},
|
|
32
40
|
],
|
|
@@ -225,3 +233,307 @@ test("renderSummary reports a clean scan and a blocked scan distinctly", () => {
|
|
|
225
233
|
assert.match(blocked.join("\n"), /❌ BLOCKED/);
|
|
226
234
|
assert.match(blocked.join("\n"), /GHSA-x/);
|
|
227
235
|
});
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Diff-aware baseline + publish grace window (Story #325)
|
|
239
|
+
//
|
|
240
|
+
// The failure these close: a newly-published advisory against a dependency
|
|
241
|
+
// that has been on `main` for weeks reds EVERY open PR simultaneously — the
|
|
242
|
+
// postcss GHSA-r28c-9q8g-f849 / brace-expansion GHSA-mh99-v99m-4gvg incidents
|
|
243
|
+
// on the swarm-os consumer. The gate must tell "this PR introduced it" from
|
|
244
|
+
// "this was already here", without ever letting a real PR-introduced advisory
|
|
245
|
+
// through and without neutering the operator-authored `revisitBy` re-gate.
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
// A baseline built from the SAME tree, as the merge-base worktree scan yields.
|
|
249
|
+
const baselineOf = (groups, opts) => buildBaselineSet(collectRows(reportWith(groups, opts), opts));
|
|
250
|
+
|
|
251
|
+
test("a finding already present at the baseline is demoted, not blocked", () => {
|
|
252
|
+
const groups = [{ name: "postcss", ids: ["GHSA-r28c-9q8g-f849"], score: "7.5" }];
|
|
253
|
+
const v = classify(collectRows(reportWith(groups)), {
|
|
254
|
+
failOn: "high",
|
|
255
|
+
baseline: baselineOf(groups),
|
|
256
|
+
});
|
|
257
|
+
assert.equal(v.blocking.length, 0);
|
|
258
|
+
assert.equal(v.preexisting.length, 1);
|
|
259
|
+
assert.equal(v.preexisting[0].ids[0], "GHSA-r28c-9q8g-f849");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("a head-only finding is PR-introduced and still blocks", () => {
|
|
263
|
+
const v = classify(
|
|
264
|
+
collectRows(
|
|
265
|
+
reportWith([
|
|
266
|
+
{ name: "postcss", ids: ["GHSA-r28c-9q8g-f849"], score: "7.5" },
|
|
267
|
+
{ name: "brand-new-dep", ids: ["GHSA-new"], score: "8.2" },
|
|
268
|
+
]),
|
|
269
|
+
),
|
|
270
|
+
{
|
|
271
|
+
failOn: "high",
|
|
272
|
+
baseline: baselineOf([{ name: "postcss", ids: ["GHSA-r28c-9q8g-f849"], score: "7.5" }]),
|
|
273
|
+
},
|
|
274
|
+
);
|
|
275
|
+
assert.equal(v.blocking.length, 1);
|
|
276
|
+
assert.equal(v.blocking[0].name, "brand-new-dep");
|
|
277
|
+
assert.equal(v.preexisting.length, 1);
|
|
278
|
+
assert.equal(v.preexisting[0].name, "postcss");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("bumping an advisory-bearing dep to another vulnerable version still blocks", () => {
|
|
282
|
+
// Same package, same advisory id — only the version moved. The baseline key
|
|
283
|
+
// carries @version precisely so this cannot inherit the demotion.
|
|
284
|
+
const v = classify(
|
|
285
|
+
collectRows(reportWith([{ name: "postcss", ids: ["GHSA-r28c"], score: "7.5", version: "8.4.0" }])),
|
|
286
|
+
{
|
|
287
|
+
failOn: "high",
|
|
288
|
+
baseline: baselineOf([
|
|
289
|
+
{ name: "postcss", ids: ["GHSA-r28c"], score: "7.5", version: "8.3.0" },
|
|
290
|
+
]),
|
|
291
|
+
},
|
|
292
|
+
);
|
|
293
|
+
assert.equal(v.blocking.length, 1);
|
|
294
|
+
assert.equal(v.blocking[0].version, "8.4.0");
|
|
295
|
+
assert.equal(v.preexisting.length, 0);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("normalizeSource reduces worktree-rooted and workspace-rooted paths alike", () => {
|
|
299
|
+
assert.equal(normalizeSource("/tmp/osv-baseline/pnpm-lock.yaml", "/tmp/osv-baseline"), "pnpm-lock.yaml");
|
|
300
|
+
assert.equal(normalizeSource("/home/runner/work/repo/pnpm-lock.yaml", "/home/runner/work/repo"), "pnpm-lock.yaml");
|
|
301
|
+
assert.equal(normalizeSource("./pnpm-lock.yaml", "/tmp/osv-baseline"), "pnpm-lock.yaml");
|
|
302
|
+
assert.equal(normalizeSource("pnpm-lock.yaml", ""), "pnpm-lock.yaml");
|
|
303
|
+
// A trailing slash on the root must not leave a leading slash behind.
|
|
304
|
+
assert.equal(normalizeSource("/tmp/base/apps/web/pnpm-lock.yaml", "/tmp/base/"), "apps/web/pnpm-lock.yaml");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("the baseline matches across differing scan roots", () => {
|
|
308
|
+
// The head scan runs in the workspace; the baseline scan runs in a git
|
|
309
|
+
// worktree at a different absolute path. Un-normalized, every head finding
|
|
310
|
+
// would look head-only and the diff-aware gate would block everything.
|
|
311
|
+
const groups = [{ name: "postcss", ids: ["GHSA-r28c"], score: "7.5" }];
|
|
312
|
+
const headRows = collectRows(
|
|
313
|
+
reportWith(groups, { sourcePath: "/home/runner/work/repo/pnpm-lock.yaml" }),
|
|
314
|
+
{ scanRoot: "/home/runner/work/repo" },
|
|
315
|
+
);
|
|
316
|
+
const baseRows = collectRows(reportWith(groups, { sourcePath: "/tmp/osv-baseline/pnpm-lock.yaml" }), {
|
|
317
|
+
scanRoot: "/tmp/osv-baseline",
|
|
318
|
+
});
|
|
319
|
+
assert.equal(rowKey(headRows[0]), rowKey(baseRows[0]));
|
|
320
|
+
|
|
321
|
+
const v = classify(headRows, { failOn: "high", baseline: buildBaselineSet(baseRows) });
|
|
322
|
+
assert.equal(v.blocking.length, 0);
|
|
323
|
+
assert.equal(v.preexisting.length, 1);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("an EXPIRED suppression outranks BOTH demotions and still re-gates", () => {
|
|
327
|
+
// The load-bearing precedence rule. An expired suppression is by
|
|
328
|
+
// construction on a pre-existing dependency, so letting either demotion
|
|
329
|
+
// apply would neuter `revisitBy` on PRs entirely.
|
|
330
|
+
//
|
|
331
|
+
// The fixture must be genuinely eligible for both demotions or this test
|
|
332
|
+
// passes vacuously: `published` is what puts the row inside the window, and
|
|
333
|
+
// without it `grace` is empty no matter how the precedence chain is wired.
|
|
334
|
+
const groups = [
|
|
335
|
+
{
|
|
336
|
+
name: "brace-expansion",
|
|
337
|
+
ids: ["GHSA-mh99-v99m-4gvg"],
|
|
338
|
+
score: "7.5",
|
|
339
|
+
published: "2026-07-22T00:00:00Z",
|
|
340
|
+
},
|
|
341
|
+
];
|
|
342
|
+
const rows = collectRows(reportWith(groups));
|
|
343
|
+
const baseline = baselineOf(groups);
|
|
344
|
+
const opts = { failOn: "high", today: "2026-07-24", baseline, graceDays: 30 };
|
|
345
|
+
|
|
346
|
+
// Guard the fixture: with NO allow-list entry this row is demoted. If this
|
|
347
|
+
// ever stops holding, the assertion below has nothing left to prove.
|
|
348
|
+
const unsuppressed = classify(rows, opts);
|
|
349
|
+
assert.equal(unsuppressed.blocking.length, 0, "fixture must be demotable without an allow-list");
|
|
350
|
+
assert.equal(unsuppressed.preexisting.length, 1, "fixture must be baseline-eligible");
|
|
351
|
+
assert.notEqual(rows[0].published, null, "fixture must carry a publish date to be grace-eligible");
|
|
352
|
+
|
|
353
|
+
const v = classify(rows, {
|
|
354
|
+
...opts,
|
|
355
|
+
allowlist: [{ id: "GHSA-mh99-v99m-4gvg", reason: "stale triage", revisitBy: "2026-01-01" }],
|
|
356
|
+
});
|
|
357
|
+
assert.equal(v.blocking.length, 1);
|
|
358
|
+
assert.equal(v.expired.length, 1);
|
|
359
|
+
assert.equal(v.preexisting.length, 0);
|
|
360
|
+
assert.equal(v.grace.length, 0);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("an UNEXPIRED suppression stays suppressed and is not double-counted", () => {
|
|
364
|
+
const groups = [{ name: "brace-expansion", ids: ["GHSA-mh99-v99m-4gvg"], score: "7.5" }];
|
|
365
|
+
const v = classify(collectRows(reportWith(groups)), {
|
|
366
|
+
failOn: "high",
|
|
367
|
+
allowlist: [{ id: "GHSA-mh99-v99m-4gvg", reason: "not reachable", revisitBy: "2099-12-31" }],
|
|
368
|
+
today: "2026-07-24",
|
|
369
|
+
baseline: baselineOf(groups),
|
|
370
|
+
});
|
|
371
|
+
assert.equal(v.suppressed.length, 1);
|
|
372
|
+
assert.equal(v.blocking.length, 0);
|
|
373
|
+
assert.equal(v.preexisting.length, 0);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("the grace window demotes a recent advisory and not an old one", () => {
|
|
377
|
+
const rows = collectRows(
|
|
378
|
+
reportWith([
|
|
379
|
+
{ name: "fresh", ids: ["GHSA-fresh"], score: "8.0", published: "2026-07-21T00:00:00Z" },
|
|
380
|
+
{ name: "stale", ids: ["GHSA-stale"], score: "8.0", published: "2026-06-24T00:00:00Z" },
|
|
381
|
+
]),
|
|
382
|
+
);
|
|
383
|
+
const v = classify(rows, { failOn: "high", graceDays: 7, today: "2026-07-24" });
|
|
384
|
+
assert.deepEqual(
|
|
385
|
+
v.grace.map((r) => r.name),
|
|
386
|
+
["fresh"],
|
|
387
|
+
);
|
|
388
|
+
assert.deepEqual(
|
|
389
|
+
v.blocking.map((r) => r.name),
|
|
390
|
+
["stale"],
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("the grace window fails closed when the publish date is unresolvable", () => {
|
|
395
|
+
// No `published` on the vulnerability entries at all…
|
|
396
|
+
const undated = classify(collectRows(reportWith([{ name: "p", ids: ["GHSA-x"], score: "8.0" }])), {
|
|
397
|
+
failOn: "high",
|
|
398
|
+
graceDays: 7,
|
|
399
|
+
today: "2026-07-24",
|
|
400
|
+
});
|
|
401
|
+
assert.equal(undated.blocking.length, 1);
|
|
402
|
+
assert.equal(undated.grace.length, 0);
|
|
403
|
+
|
|
404
|
+
// …and a present-but-garbage date is equally not a free pass.
|
|
405
|
+
const garbage = classify(
|
|
406
|
+
collectRows(reportWith([{ name: "p", ids: ["GHSA-x"], score: "8.0", published: "not-a-date" }])),
|
|
407
|
+
{ failOn: "high", graceDays: 7, today: "2026-07-24" },
|
|
408
|
+
);
|
|
409
|
+
assert.equal(garbage.blocking.length, 1);
|
|
410
|
+
assert.equal(garbage.grace.length, 0);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("the grace window is off at the default of 0", () => {
|
|
414
|
+
const rows = collectRows(
|
|
415
|
+
reportWith([
|
|
416
|
+
{ name: "fresh", ids: ["GHSA-fresh"], score: "8.0", published: "2026-07-23T00:00:00Z" },
|
|
417
|
+
]),
|
|
418
|
+
);
|
|
419
|
+
const v = classify(rows, { failOn: "high", today: "2026-07-24" });
|
|
420
|
+
assert.equal(v.graceDays, 0);
|
|
421
|
+
assert.equal(v.grace.length, 0);
|
|
422
|
+
assert.equal(v.blocking.length, 1);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("collectRows takes the EARLIEST published date across a group's aliased ids", () => {
|
|
426
|
+
const report = {
|
|
427
|
+
results: [
|
|
428
|
+
{
|
|
429
|
+
source: { path: "pnpm-lock.yaml" },
|
|
430
|
+
packages: [
|
|
431
|
+
{
|
|
432
|
+
package: { name: "p", version: "1.0.0", ecosystem: "npm" },
|
|
433
|
+
groups: [{ ids: ["GHSA-a", "CVE-b"], max_severity: "8.0" }],
|
|
434
|
+
vulnerabilities: [
|
|
435
|
+
{ id: "GHSA-a", published: "2026-07-20T00:00:00Z" },
|
|
436
|
+
{ id: "CVE-b", published: "2026-05-01T00:00:00Z" },
|
|
437
|
+
],
|
|
438
|
+
},
|
|
439
|
+
],
|
|
440
|
+
},
|
|
441
|
+
],
|
|
442
|
+
};
|
|
443
|
+
assert.equal(collectRows(report)[0].published, "2026-05-01T00:00:00Z");
|
|
444
|
+
|
|
445
|
+
// …and the earliest date is what the window is judged against, so an alias
|
|
446
|
+
// published long ago cannot be laundered into the window by a fresh alias.
|
|
447
|
+
const v = classify(collectRows(report), { failOn: "high", graceDays: 7, today: "2026-07-24" });
|
|
448
|
+
assert.equal(v.blocking.length, 1);
|
|
449
|
+
assert.equal(v.grace.length, 0);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("with no baseline and no grace window the partition is unchanged", () => {
|
|
453
|
+
// The backward-compatibility contract: default inputs must classify exactly
|
|
454
|
+
// as they did before diff-awareness existed.
|
|
455
|
+
const rows = collectRows(
|
|
456
|
+
reportWith([
|
|
457
|
+
{ name: "crit", ids: ["C"], score: "9.9" },
|
|
458
|
+
{ name: "hi", ids: ["H"], score: "7.1" },
|
|
459
|
+
{ name: "med", ids: ["M"], score: "4.5" },
|
|
460
|
+
{ name: "sup", ids: ["S"], score: "8.0" },
|
|
461
|
+
]),
|
|
462
|
+
);
|
|
463
|
+
const allowlist = [{ id: "S", reason: "triaged", revisitBy: "2099-12-31" }];
|
|
464
|
+
const v = classify(rows, { failOn: "high", allowlist, today: "2026-07-24" });
|
|
465
|
+
|
|
466
|
+
assert.deepEqual(
|
|
467
|
+
v.blocking.map((r) => r.name),
|
|
468
|
+
["crit", "hi"],
|
|
469
|
+
);
|
|
470
|
+
assert.deepEqual(
|
|
471
|
+
v.warning.map((r) => r.name),
|
|
472
|
+
["med"],
|
|
473
|
+
);
|
|
474
|
+
assert.equal(v.suppressed.length, 1);
|
|
475
|
+
assert.equal(v.expired.length, 0);
|
|
476
|
+
// The new buckets exist but are inert.
|
|
477
|
+
assert.deepEqual(v.preexisting, []);
|
|
478
|
+
assert.deepEqual(v.grace, []);
|
|
479
|
+
assert.equal(v.baselineApplied, false);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("findingsDigest ignores preexisting and grace rows entirely", () => {
|
|
483
|
+
// The scheduled tracking issue keys off this digest; a PR-side demotion must
|
|
484
|
+
// not rewrite the issue body or make an unchanged advisory set look new.
|
|
485
|
+
const groups = [
|
|
486
|
+
{ name: "p1", ids: ["GHSA-a"], score: "7.5" },
|
|
487
|
+
{ name: "p2", ids: ["GHSA-b"], score: "9.1", published: "2026-07-23T00:00:00Z" },
|
|
488
|
+
];
|
|
489
|
+
const plain = classify(collectRows(reportWith(groups)), { failOn: "high", today: "2026-07-24" });
|
|
490
|
+
const demoted = classify(collectRows(reportWith(groups)), {
|
|
491
|
+
failOn: "high",
|
|
492
|
+
today: "2026-07-24",
|
|
493
|
+
baseline: baselineOf([groups[0]]),
|
|
494
|
+
graceDays: 7,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
assert.equal(demoted.blocking.length, 0);
|
|
498
|
+
assert.equal(demoted.preexisting.length, 1);
|
|
499
|
+
assert.equal(demoted.grace.length, 1);
|
|
500
|
+
assert.equal(findingsDigest(demoted.blocking), findingsDigest([]));
|
|
501
|
+
assert.notEqual(findingsDigest(plain.blocking), findingsDigest(demoted.blocking));
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("renderSummary names both demotion buckets with one table row each", () => {
|
|
505
|
+
const groups = [
|
|
506
|
+
{ name: "postcss", ids: ["GHSA-r28c-9q8g-f849"], score: "7.5" },
|
|
507
|
+
{ name: "fresh", ids: ["GHSA-fresh"], score: "8.0", published: "2026-07-23T00:00:00Z" },
|
|
508
|
+
];
|
|
509
|
+
const v = classify(collectRows(reportWith(groups)), {
|
|
510
|
+
failOn: "high",
|
|
511
|
+
today: "2026-07-24",
|
|
512
|
+
baseline: baselineOf([groups[0]]),
|
|
513
|
+
graceDays: 7,
|
|
514
|
+
});
|
|
515
|
+
const out = renderSummary(v).join("\n");
|
|
516
|
+
|
|
517
|
+
assert.match(out, /Pre-existing on the base branch — not introduced by this PR \(1\)/);
|
|
518
|
+
assert.match(out, /Within the publish grace window \(1\)/);
|
|
519
|
+
assert.match(out, /GHSA-r28c-9q8g-f849/);
|
|
520
|
+
assert.match(out, /GHSA-fresh/);
|
|
521
|
+
// Demotions are not a pass-with-nothing-to-say: the verdict line must not
|
|
522
|
+
// claim BLOCKED when everything was demoted.
|
|
523
|
+
assert.doesNotMatch(out, /❌ BLOCKED/);
|
|
524
|
+
// The grace table carries the publish date that justified the demotion.
|
|
525
|
+
assert.match(out, /\| 2026-07-23T00:00:00Z \|/);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test("a fully-demoted verdict set is not rendered as a clean scan", () => {
|
|
529
|
+
// Demoted findings still have to be visible — silently reporting "no known
|
|
530
|
+
// advisories" would hide exactly what the diff-aware mode chose not to gate.
|
|
531
|
+
const groups = [{ name: "postcss", ids: ["GHSA-r28c"], score: "7.5" }];
|
|
532
|
+
const v = classify(collectRows(reportWith(groups)), {
|
|
533
|
+
failOn: "high",
|
|
534
|
+
baseline: baselineOf(groups),
|
|
535
|
+
});
|
|
536
|
+
const out = renderSummary(v).join("\n");
|
|
537
|
+
assert.doesNotMatch(out, /no known advisories/);
|
|
538
|
+
assert.match(out, /GHSA-r28c/);
|
|
539
|
+
});
|
|
@@ -197,6 +197,52 @@ function log(msg) {
|
|
|
197
197
|
if (!opts.json) process.stdout.write(`${msg}\n`);
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Filesystem access — perform, don't pre-check (Story #337)
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
//
|
|
204
|
+
// Every read in this script used to be guarded by `existsSync(p)` before
|
|
205
|
+
// `readFileSync(p)` / `readdirSync(p)`. That check-then-use shape is a
|
|
206
|
+
// time-of-check/time-of-use race (CodeQL js/file-system-race, high) — the path
|
|
207
|
+
// can change between the two calls, and the code then acts on a stale answer.
|
|
208
|
+
// It is also lossy in a way that matters here: `existsSync` returns false for
|
|
209
|
+
// a path that exists but cannot be stat'd, so a permission or type error was
|
|
210
|
+
// silently reinterpreted as "absent" and the script took its create branch.
|
|
211
|
+
//
|
|
212
|
+
// These helpers invert it: attempt the operation, and treat ONLY `ENOENT` as
|
|
213
|
+
// "not there". Every other error (EACCES, EISDIR, ELOOP, …) propagates, which
|
|
214
|
+
// is both race-free and strictly more informative. One syscall, not two.
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Read a UTF-8 file, or `null` when it does not exist.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} path
|
|
220
|
+
* @returns {string|null}
|
|
221
|
+
*/
|
|
222
|
+
function readFileIfPresent(path) {
|
|
223
|
+
try {
|
|
224
|
+
return readFileSync(path, "utf8");
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (err.code === "ENOENT") return null;
|
|
227
|
+
throw err;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* List a directory's entries, or `null` when it does not exist.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} path
|
|
235
|
+
* @returns {string[]|null}
|
|
236
|
+
*/
|
|
237
|
+
function readdirIfPresent(path) {
|
|
238
|
+
try {
|
|
239
|
+
return readdirSync(path);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
if (err.code === "ENOENT") return null;
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
200
246
|
// ---------------------------------------------------------------------------
|
|
201
247
|
// Defaults requiring resolution
|
|
202
248
|
// ---------------------------------------------------------------------------
|
|
@@ -282,8 +328,9 @@ function resolveSha() {
|
|
|
282
328
|
/** Recursively collect `.yml`/`.yaml` files under a directory. */
|
|
283
329
|
function collectYaml(dir) {
|
|
284
330
|
const found = [];
|
|
285
|
-
|
|
286
|
-
|
|
331
|
+
const entries = readdirIfPresent(dir);
|
|
332
|
+
if (entries === null) return found;
|
|
333
|
+
for (const entry of entries) {
|
|
287
334
|
const full = join(dir, entry);
|
|
288
335
|
const st = statSync(full);
|
|
289
336
|
if (st.isDirectory()) found.push(...collectYaml(full));
|
|
@@ -401,17 +448,18 @@ function materializeRunbooks() {
|
|
|
401
448
|
const created = [];
|
|
402
449
|
const skipped = [];
|
|
403
450
|
const localCopies = [];
|
|
404
|
-
|
|
451
|
+
const templates = readdirIfPresent(runbookTemplatesDir);
|
|
452
|
+
if (templates === null) {
|
|
405
453
|
fail(`runbook templates not found at ${runbookTemplatesDir}.`);
|
|
406
454
|
}
|
|
407
455
|
const destDir = join(opts.consumer, "docs", "runbooks");
|
|
408
|
-
for (const entry of
|
|
456
|
+
for (const entry of templates) {
|
|
409
457
|
if (!entry.endsWith(".md")) continue;
|
|
410
458
|
if (entry.toLowerCase() === "readme.md") continue; // index, not a stub
|
|
411
459
|
const src = join(runbookTemplatesDir, entry);
|
|
412
460
|
const dest = join(destDir, entry);
|
|
413
|
-
|
|
414
|
-
|
|
461
|
+
const body = readFileIfPresent(dest);
|
|
462
|
+
if (body !== null) {
|
|
415
463
|
if (body.includes(STUB_MARKER)) {
|
|
416
464
|
skipped.push(rel(dest)); // already a reference stub — idempotent no-op
|
|
417
465
|
} else {
|
|
@@ -451,16 +499,19 @@ function materializeWorkflowStubs() {
|
|
|
451
499
|
const created = [];
|
|
452
500
|
const skipped = [];
|
|
453
501
|
const localCopies = [];
|
|
454
|
-
|
|
502
|
+
// Unlike the runbook templates above, an absent workflow-template directory
|
|
503
|
+
// is a soft no-op rather than a fatal — preserved exactly.
|
|
504
|
+
const templates = readdirIfPresent(workflowTemplatesDir);
|
|
505
|
+
if (templates === null) {
|
|
455
506
|
return { created, skipped, localCopies };
|
|
456
507
|
}
|
|
457
508
|
const destDir = join(opts.consumer, ".github", "workflows");
|
|
458
|
-
for (const entry of
|
|
509
|
+
for (const entry of templates) {
|
|
459
510
|
if (!/\.ya?ml$/.test(entry)) continue;
|
|
460
511
|
const src = join(workflowTemplatesDir, entry);
|
|
461
512
|
const dest = join(destDir, entry);
|
|
462
|
-
|
|
463
|
-
|
|
513
|
+
const body = readFileIfPresent(dest);
|
|
514
|
+
if (body !== null) {
|
|
464
515
|
if (body.includes(WORKFLOW_TEMPLATE_MARKER)) {
|
|
465
516
|
skipped.push(rel(dest)); // already materialized — idempotent no-op
|
|
466
517
|
} else {
|
|
@@ -501,11 +552,26 @@ function reconcileRenovate() {
|
|
|
501
552
|
".github/renovate.json",
|
|
502
553
|
".renovaterc.json",
|
|
503
554
|
].map((p) => join(opts.consumer, p));
|
|
504
|
-
|
|
505
|
-
|
|
555
|
+
// Read-through rather than find-then-read: the first candidate that yields
|
|
556
|
+
// content IS the config, with no window in which it can vanish between the
|
|
557
|
+
// probe and the read.
|
|
558
|
+
let path = null;
|
|
559
|
+
let raw = null;
|
|
560
|
+
for (const candidate of candidates) {
|
|
561
|
+
try {
|
|
562
|
+
raw = readFileIfPresent(candidate);
|
|
563
|
+
} catch (err) {
|
|
564
|
+
fail(`could not read Renovate config at ${rel(candidate)}: ${err.message}`);
|
|
565
|
+
}
|
|
566
|
+
if (raw !== null) {
|
|
567
|
+
path = candidate;
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (path === null) return { action: "absent", file: null };
|
|
506
572
|
let cfg;
|
|
507
573
|
try {
|
|
508
|
-
cfg = parseJsonc(
|
|
574
|
+
cfg = parseJsonc(raw);
|
|
509
575
|
} catch (err) {
|
|
510
576
|
fail(`could not parse Renovate config at ${rel(path)}: ${err.message}`);
|
|
511
577
|
}
|
|
@@ -521,10 +587,18 @@ function reconcileRenovate() {
|
|
|
521
587
|
|
|
522
588
|
function reconcileTsconfig() {
|
|
523
589
|
const path = join(opts.consumer, "tsconfig.json");
|
|
524
|
-
|
|
590
|
+
// Read and parse failures are reported separately: conflating them (as the
|
|
591
|
+
// old check-then-read did) reported an unreadable file as a parse error.
|
|
592
|
+
let raw;
|
|
593
|
+
try {
|
|
594
|
+
raw = readFileIfPresent(path);
|
|
595
|
+
} catch (err) {
|
|
596
|
+
fail(`could not read tsconfig at ${rel(path)}: ${err.message}`);
|
|
597
|
+
}
|
|
598
|
+
if (raw === null) return { action: "absent", file: null };
|
|
525
599
|
let cfg;
|
|
526
600
|
try {
|
|
527
|
-
cfg = parseJsonc(
|
|
601
|
+
cfg = parseJsonc(raw);
|
|
528
602
|
} catch (err) {
|
|
529
603
|
fail(`could not parse tsconfig at ${rel(path)}: ${err.message}`);
|
|
530
604
|
}
|
|
@@ -642,3 +642,128 @@ test("a hand-authored deploy-staging.yml is flagged, not overwritten", () => {
|
|
|
642
642
|
"operator's hand-authored caller is never clobbered"
|
|
643
643
|
);
|
|
644
644
|
});
|
|
645
|
+
|
|
646
|
+
// ---------------------------------------------------------------------------
|
|
647
|
+
// Filesystem access — perform, don't pre-check (Story #337)
|
|
648
|
+
//
|
|
649
|
+
// Every read used to be guarded by `existsSync(p)` before `readFileSync(p)` —
|
|
650
|
+
// a time-of-check/time-of-use race (CodeQL js/file-system-race, high) that
|
|
651
|
+
// also collapsed "unreadable" into "absent". These pin the replacement
|
|
652
|
+
// contract: ENOENT still means absent, and every other error is reported as a
|
|
653
|
+
// READ failure rather than being mistaken for a missing file or a parse error.
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
|
|
656
|
+
/** Run the CLI expecting a non-zero exit; return the combined output. */
|
|
657
|
+
function runExpectingFailure(extraArgs) {
|
|
658
|
+
try {
|
|
659
|
+
run(extraArgs);
|
|
660
|
+
} catch (err) {
|
|
661
|
+
return `${err.stdout ?? ""}${err.stderr ?? ""}`;
|
|
662
|
+
}
|
|
663
|
+
assert.fail("expected the CLI to exit non-zero");
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
test("an absent tsconfig is reported absent, not as an error", () => {
|
|
667
|
+
rmSync(join(consumer, "tsconfig.json"));
|
|
668
|
+
const out = JSON.parse(run([]));
|
|
669
|
+
assert.equal(out.tsconfig.action, "absent");
|
|
670
|
+
assert.equal(out.tsconfig.file, null);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test("an absent renovate config is reported absent, not as an error", () => {
|
|
674
|
+
rmSync(join(consumer, "renovate.json"));
|
|
675
|
+
const out = JSON.parse(run([]));
|
|
676
|
+
assert.equal(out.renovate.action, "absent");
|
|
677
|
+
assert.equal(out.renovate.file, null);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test("an unreadable tsconfig fails as a READ error, never as absent", () => {
|
|
681
|
+
// A directory where the file should be is the portable stand-in for an
|
|
682
|
+
// unreadable path (EISDIR). The old shape reported this as a *parse*
|
|
683
|
+
// failure, because the read happened inside the parse try/catch.
|
|
684
|
+
rmSync(join(consumer, "tsconfig.json"));
|
|
685
|
+
mkdirSync(join(consumer, "tsconfig.json"));
|
|
686
|
+
const out = runExpectingFailure([]);
|
|
687
|
+
assert.match(out, /could not read tsconfig/);
|
|
688
|
+
assert.doesNotMatch(out, /could not parse tsconfig/);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("an unreadable renovate config fails as a READ error, never as absent", () => {
|
|
692
|
+
rmSync(join(consumer, "renovate.json"));
|
|
693
|
+
mkdirSync(join(consumer, "renovate.json"));
|
|
694
|
+
const out = runExpectingFailure([]);
|
|
695
|
+
assert.match(out, /could not read Renovate config/);
|
|
696
|
+
assert.doesNotMatch(out, /could not parse Renovate config/);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test("a malformed tsconfig still fails as a PARSE error", () => {
|
|
700
|
+
// The read/parse split must not blur the other way either.
|
|
701
|
+
writeFileSync(join(consumer, "tsconfig.json"), "{ not json at all");
|
|
702
|
+
const out = runExpectingFailure([]);
|
|
703
|
+
assert.match(out, /could not parse tsconfig/);
|
|
704
|
+
assert.doesNotMatch(out, /could not read tsconfig/);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("an existing runbook stub is skipped, an unreadable one is not silently created", () => {
|
|
708
|
+
// First pass materializes; second must skip via the read, not a pre-check.
|
|
709
|
+
run([]);
|
|
710
|
+
const stub = join(consumer, "docs", "runbooks", "observability.md");
|
|
711
|
+
const body = readFileSync(stub, "utf8");
|
|
712
|
+
const out = JSON.parse(run([]));
|
|
713
|
+
assert.ok(
|
|
714
|
+
out.runbooks.skipped.some((f) => f.endsWith("observability.md")),
|
|
715
|
+
"already-materialized stub is skipped on the second pass"
|
|
716
|
+
);
|
|
717
|
+
assert.equal(readFileSync(stub, "utf8"), body, "skipped stub is byte-identical");
|
|
718
|
+
assert.equal(out.runbooks.created.length, 0);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("no read in the sync path is guarded by a prior existence check", () => {
|
|
722
|
+
// The regression guard for the defect class itself: `existsSync` may survive
|
|
723
|
+
// only as the import and the one CLI-argument precondition that has no
|
|
724
|
+
// paired read. Anything else is a reintroduced check-then-use race.
|
|
725
|
+
const source = readFileSync(join(__dirname, "platform-sync.mjs"), "utf8");
|
|
726
|
+
const uses = source
|
|
727
|
+
.split("\n")
|
|
728
|
+
.map((line, i) => ({ line, n: i + 1 }))
|
|
729
|
+
.filter(({ line }) => /(?<![A-Za-z])existsSync\s*\(/.test(line))
|
|
730
|
+
.filter(({ line }) => !/^\s*(\/\/|\*)/.test(line));
|
|
731
|
+
assert.equal(
|
|
732
|
+
uses.length,
|
|
733
|
+
1,
|
|
734
|
+
`expected exactly one existsSync call site (the --consumer precondition); found: ${JSON.stringify(
|
|
735
|
+
uses
|
|
736
|
+
)}`
|
|
737
|
+
);
|
|
738
|
+
assert.match(uses[0].line, /opts\.consumer/);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test("a missing runbook-template dir fails with the message naming it, not a raw ENOENT", () => {
|
|
742
|
+
// The precondition is fatal by design. Converting the guard to a
|
|
743
|
+
// perform-then-classify read must not degrade it to an unhandled ENOENT.
|
|
744
|
+
const emptyTemplates = mkdtempSync(join(tmpdir(), "platform-sync-templates-"));
|
|
745
|
+
try {
|
|
746
|
+
const out = runExpectingFailure(["--templates", emptyTemplates]);
|
|
747
|
+
assert.match(out, /runbook templates not found at/);
|
|
748
|
+
assert.ok(out.includes(join(emptyTemplates, "runbooks")), "names the directory it looked in");
|
|
749
|
+
assert.doesNotMatch(out, /ENOENT/, "no raw errno leaks to the operator");
|
|
750
|
+
} finally {
|
|
751
|
+
rmSync(emptyTemplates, { recursive: true, force: true });
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test("a missing workflow-template dir is a soft no-op, not a failure", () => {
|
|
756
|
+
// Deliberately NOT symmetrical with the runbook precondition above: an
|
|
757
|
+
// absent workflow-template directory yields an empty result set rather than
|
|
758
|
+
// a fatal. Pinned so the read conversion cannot quietly make it fatal.
|
|
759
|
+
const templates = mkdtempSync(join(tmpdir(), "platform-sync-templates-"));
|
|
760
|
+
try {
|
|
761
|
+
mkdirSync(join(templates, "runbooks"), { recursive: true });
|
|
762
|
+
const out = JSON.parse(run(["--templates", templates]));
|
|
763
|
+
assert.deepEqual(out.workflowStubs.created, []);
|
|
764
|
+
assert.deepEqual(out.workflowStubs.skipped, []);
|
|
765
|
+
assert.deepEqual(out.workflowStubs.localCopies, []);
|
|
766
|
+
} finally {
|
|
767
|
+
rmSync(templates, { recursive: true, force: true });
|
|
768
|
+
}
|
|
769
|
+
});
|