mandrel-platform 0.11.7 → 0.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 CHANGED
@@ -3,6 +3,10 @@
3
3
  Shared CI/deploy workflows, composite toolchain action, npm config package,
4
4
  Renovate preset, and operator runbook templates for the Mandrel platform.
5
5
 
6
+ **Docs:** [reusable-workflows.md](docs/reusable-workflows.md) (the `workflow_call`
7
+ contract) · [decisions.md](docs/decisions.md) (decision log). Status, the
8
+ consumer convergence matrix, and the forward roadmap are tracked privately.
9
+
6
10
  ---
7
11
 
8
12
  ## Reusable workflows
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "0.11.7",
3
+ "version": "0.12.0",
4
4
  "description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
5
5
  "license": "MIT",
6
6
  "engines": {
@@ -8,7 +8,7 @@
8
8
  * athportal, swarm-os) went undetected and undocumented: every consumer
9
9
  * pinned `pr-quality.yml@<shaA>` and `deploy-cloudflare.yml@<shaB>` — two
10
10
  * different release SHAs per repo, neither on the current platform release,
11
- * with no automated drift detection (roadmap.md §4.2 / §4.3). This script is
11
+ * with no automated drift detection. This script is
12
12
  * the standing check that surfaces it automatically.
13
13
  *
14
14
  * For each consumer in `scripts/pin-drift-consumers.json` it:
@@ -24,6 +24,15 @@
24
24
  * commit. A consumer is `current` when its single pin equals the latest
25
25
  * release SHA, `lagging` when it pins an older release/SHA, and
26
26
  * `unknown` when the pinned SHA can't be matched to a release.
27
+ * 5. Reads the consumer's `package.json` and extracts its
28
+ * `mandrel-platform` npm dependency version (the platform also ships as
29
+ * an npm config package: tsconfig.base.json, biome.base.json, the
30
+ * Renovate preset). It compares that version to the latest release's
31
+ * version and flags an `npm` verdict (`current` / `lagging` / `ahead` /
32
+ * `absent` / `unknown`), plus a **surface skew** when the npm pin and the
33
+ * workflow `uses:` pin disagree about being current — the exact
34
+ * split-pin class the `uses:`-only check missed (npm lagged at 0.11.3
35
+ * while the workflows tracked v0.11.6).
27
36
  *
28
37
  * Data-driven: a new consumer is one object in pin-drift-consumers.json.
29
38
  *
@@ -195,6 +204,135 @@ export function classifyConsumer(pins, latestReleaseSha) {
195
204
  };
196
205
  }
197
206
 
207
+ /**
208
+ * Extract a comparable `x.y.z` semver core from a release tag or version spec.
209
+ * The platform tags releases as `mandrel-platform-v<semver>`; consumer specs
210
+ * may carry a range prefix (`^0.11.3`, `~0.11.3`). Returns the dotted triple
211
+ * or null when no numeric semver core is present (`workspace:*`, `latest`, a
212
+ * git URL).
213
+ *
214
+ * @param {unknown} value
215
+ * @returns {string | null}
216
+ */
217
+ export function parseSemver(value) {
218
+ if (typeof value !== "string") return null;
219
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(value);
220
+ return m ? `${m[1]}.${m[2]}.${m[3]}` : null;
221
+ }
222
+
223
+ /**
224
+ * Compare two `x.y.z` semver cores. Returns -1 when a < b, 0 when equal, 1
225
+ * when a > b. Inputs MUST already be normalized dotted triples (see
226
+ * `parseSemver`).
227
+ *
228
+ * @param {string} a
229
+ * @param {string} b
230
+ * @returns {-1 | 0 | 1}
231
+ */
232
+ export function compareSemver(a, b) {
233
+ const pa = a.split(".").map(Number);
234
+ const pb = b.split(".").map(Number);
235
+ for (let i = 0; i < 3; i += 1) {
236
+ if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
237
+ }
238
+ return 0;
239
+ }
240
+
241
+ /**
242
+ * Extract the consumer's `mandrel-platform` npm dependency spec from a
243
+ * package.json text blob. Scans `dependencies`, `devDependencies`,
244
+ * `optionalDependencies`, and `peerDependencies` in that order. Returns the
245
+ * raw spec string (e.g. `"0.11.3"`, `"^0.11.7"`, `"workspace:*"`) or null when
246
+ * the package isn't depended on (or the JSON is unreadable).
247
+ *
248
+ * @param {string} text package.json contents.
249
+ * @param {string} [pkgName] Dependency name to look for.
250
+ * @returns {string | null}
251
+ */
252
+ export function extractNpmPlatformVersion(text, pkgName = "mandrel-platform") {
253
+ let pkg;
254
+ try {
255
+ pkg = JSON.parse(text);
256
+ } catch {
257
+ return null;
258
+ }
259
+ if (!pkg || typeof pkg !== "object") return null;
260
+ const fields = [
261
+ "dependencies",
262
+ "devDependencies",
263
+ "optionalDependencies",
264
+ "peerDependencies",
265
+ ];
266
+ for (const field of fields) {
267
+ const deps = pkg[field];
268
+ if (deps && typeof deps === "object" && typeof deps[pkgName] === "string") {
269
+ return deps[pkgName];
270
+ }
271
+ }
272
+ return null;
273
+ }
274
+
275
+ /**
276
+ * Classify the consumer's npm pin against the latest platform release version.
277
+ *
278
+ * @param {string | null} spec Raw `mandrel-platform` dependency spec, or null if absent.
279
+ * @param {string | null} latestVersion Latest release version (`x.y.z`), or null if unknown.
280
+ * @returns {{
281
+ * rawSpec: string | null,
282
+ * version: string | null,
283
+ * npmState: 'absent' | 'current' | 'lagging' | 'ahead' | 'unknown',
284
+ * }}
285
+ */
286
+ export function classifyNpmPin(spec, latestVersion) {
287
+ if (spec === null || spec === undefined) {
288
+ return { rawSpec: null, version: null, npmState: "absent" };
289
+ }
290
+ const version = parseSemver(spec);
291
+ if (version === null) {
292
+ // Non-numeric spec (workspace:*, dist-tag, git URL) — can't compare by SHA.
293
+ return { rawSpec: spec, version: null, npmState: "unknown" };
294
+ }
295
+ const latest = parseSemver(latestVersion);
296
+ if (latest === null) {
297
+ return { rawSpec: spec, version, npmState: "unknown" };
298
+ }
299
+ const cmp = compareSemver(version, latest);
300
+ const npmState = cmp === 0 ? "current" : cmp < 0 ? "lagging" : "ahead";
301
+ return { rawSpec: spec, version, npmState };
302
+ }
303
+
304
+ /**
305
+ * Detect a surface skew: the npm pin and the workflow `uses:` pin disagree
306
+ * about being current. This is the split-pin class the `uses:`-only check
307
+ * missed — e.g. workflows on the latest release while the npm config package
308
+ * lags an older one (or vice versa). Only meaningful when BOTH surfaces
309
+ * resolve to a comparable currency state.
310
+ *
311
+ * @param {'current' | 'lagging' | 'unknown' | 'no-pins'} usesLagState
312
+ * @param {'absent' | 'current' | 'lagging' | 'ahead' | 'unknown'} npmState
313
+ * @returns {boolean}
314
+ */
315
+ export function detectSurfaceSkew(usesLagState, npmState) {
316
+ const usesKnown = usesLagState === "current" || usesLagState === "lagging";
317
+ const npmKnown = npmState === "current" || npmState === "lagging";
318
+ if (!usesKnown || !npmKnown) return false;
319
+ return (usesLagState === "current") !== (npmState === "current");
320
+ }
321
+
322
+ /**
323
+ * Combine the workflow-pin verdict, the npm verdict, and the surface-skew flag
324
+ * into a single per-consumer drift boolean. `npm ahead` and `npm absent` are
325
+ * informational, not drift; `npm lagging` and any surface skew are.
326
+ *
327
+ * @param {{ drift: boolean }} verdict
328
+ * @param {{ npmState: string }} npm
329
+ * @param {boolean} surfaceSkew
330
+ * @returns {boolean}
331
+ */
332
+ export function combineDrift(verdict, npm, surfaceSkew) {
333
+ return verdict.drift || npm.npmState === "lagging" || surfaceSkew;
334
+ }
335
+
198
336
  /**
199
337
  * Render the human-readable dashboard report.
200
338
  *
@@ -208,12 +346,16 @@ export function classifyConsumer(pins, latestReleaseSha) {
208
346
  * error?: string,
209
347
  * pins: Array<{ file: string, line: number, target: string, ref: string | null }>,
210
348
  * verdict: ReturnType<typeof classifyConsumer>,
349
+ * npm?: ReturnType<typeof classifyNpmPin>,
350
+ * surfaceSkew?: boolean,
351
+ * drift?: boolean,
211
352
  * }>,
212
353
  * }} report
213
354
  * @returns {string}
214
355
  */
215
356
  export function renderReport(report) {
216
357
  const { platformRepo, latestRelease, results } = report;
358
+ const latestVersion = parseSemver(latestRelease.tag);
217
359
  const out = [];
218
360
  out.push("## Cross-consumer pin-drift dashboard");
219
361
  out.push("");
@@ -224,17 +366,19 @@ export function renderReport(report) {
224
366
  : "unknown";
225
367
  out.push(`Latest release: ${relLabel}`);
226
368
  out.push("");
227
- out.push("| Consumer | Pins | Pinned SHA | Lag | Status |");
228
- out.push("| -------- | ---- | ---------- | --- | ------ |");
369
+ out.push("| Consumer | Pins | uses SHA | uses lag | npm pin | npm lag | Status |");
370
+ out.push("| -------- | ---- | -------- | -------- | ------- | ------- | ------ |");
229
371
 
230
372
  const driftLines = [];
231
373
  for (const r of results) {
232
374
  if (r.error) {
233
- out.push(`| \`${r.name}\` | — | — | — | ⚠️ error |`);
375
+ out.push(`| \`${r.name}\` | — | — | — | — | — | ⚠️ error |`);
234
376
  driftLines.push(`- \`${r.name}\` (${r.repo}): error — ${r.error}`);
235
377
  continue;
236
378
  }
237
379
  const v = r.verdict;
380
+ const npm = r.npm ?? { rawSpec: null, version: null, npmState: "absent" };
381
+ const surfaceSkew = r.surfaceSkew === true;
238
382
  const shaLabel = v.pinnedSha
239
383
  ? `\`${v.pinnedSha.slice(0, 7)}\``
240
384
  : v.splitPinned
@@ -250,14 +394,27 @@ export function renderReport(report) {
250
394
  : v.lagState === "no-pins"
251
395
  ? "no pins"
252
396
  : "unknown";
397
+ const npmLabel = npm.version
398
+ ? `\`${npm.version}\``
399
+ : npm.rawSpec
400
+ ? `\`${npm.rawSpec}\``
401
+ : "—";
402
+ const npmLagLabel = npm.npmState === "absent" ? "—" : npm.npmState;
253
403
  let status;
254
- if (v.lagState === "no-pins") status = "➖ no platform pins";
404
+ if (v.lagState === "no-pins" && npm.npmState === "absent")
405
+ status = "➖ no platform refs";
255
406
  else if (v.splitPinned) status = "❌ split pin";
256
- else if (v.lagState === "lagging") status = "⚠️ lagging";
257
- else if (v.lagState === "current") status = "✅ current";
407
+ else if (surfaceSkew) status = " npm/uses skew";
408
+ else if (v.lagState === "lagging" || npm.npmState === "lagging")
409
+ status = "⚠️ lagging";
410
+ else if (
411
+ (v.lagState === "current" || v.lagState === "no-pins") &&
412
+ (npm.npmState === "current" || npm.npmState === "absent")
413
+ )
414
+ status = "✅ current";
258
415
  else status = "❔ unknown";
259
416
  out.push(
260
- `| \`${r.name}\` | ${v.pinCount} | ${shaLabel} | ${lagLabel} | ${status} |`,
417
+ `| \`${r.name}\` | ${v.pinCount} | ${shaLabel} | ${lagLabel} | ${npmLabel} | ${npmLagLabel} | ${status} |`,
261
418
  );
262
419
 
263
420
  if (v.splitPinned) {
@@ -279,6 +436,16 @@ export function renderReport(report) {
279
436
  `- \`${r.name}\` (${r.repo}): LAGGING — pins \`${v.pinnedSha.slice(0, 7)}\`, latest release is \`${(latestRelease.sha || "?").slice(0, 7)}\` (${latestRelease.tag || "?"}).`,
280
437
  );
281
438
  }
439
+
440
+ if (surfaceSkew) {
441
+ driftLines.push(
442
+ `- \`${r.name}\` (${r.repo}): SURFACE SKEW — workflow \`uses:\` pins are ${lagLabel} but the npm \`mandrel-platform\` dependency (\`${npm.version ?? npm.rawSpec}\`) is ${npm.npmState}. The npm config package and the workflow pins are on different releases.`,
443
+ );
444
+ } else if (npm.npmState === "lagging") {
445
+ driftLines.push(
446
+ `- \`${r.name}\` (${r.repo}): NPM LAGGING — depends on \`mandrel-platform@${npm.version}\`, latest release is \`${latestVersion ?? "?"}\` (${latestRelease.tag || "?"}).`,
447
+ );
448
+ }
282
449
  }
283
450
 
284
451
  out.push("");
@@ -290,7 +457,7 @@ export function renderReport(report) {
290
457
  out.push("### ✅ No drift");
291
458
  out.push("");
292
459
  out.push(
293
- "Every consumer pins a single platform SHA on the latest release.",
460
+ "Every consumer pins a single platform SHA on the latest release, and its npm `mandrel-platform` dependency is on the matching version.",
294
461
  );
295
462
  }
296
463
  out.push("");
@@ -405,6 +572,34 @@ export function fetchConsumerWorkflows(repo, branch, runGh) {
405
572
  return files;
406
573
  }
407
574
 
575
+ /**
576
+ * Fetch a consumer's root `package.json` text over the GitHub contents API.
577
+ * Returns null when the file is absent or unreadable (a consumer that adopts
578
+ * the platform workflows but not the npm config package legitimately has no
579
+ * `mandrel-platform` dependency). The existing `Contents: read` token scope
580
+ * already covers this — no additional permission is required.
581
+ *
582
+ * @param {string} repo "owner/name".
583
+ * @param {string} branch Branch / ref to read.
584
+ * @param {(args: string[]) => string} runGh
585
+ * @returns {string | null}
586
+ */
587
+ export function fetchConsumerPackageJson(repo, branch, runGh) {
588
+ let obj;
589
+ try {
590
+ obj = ghApiJson(
591
+ `repos/${repo}/contents/package.json?ref=${encodeURIComponent(branch)}`,
592
+ runGh,
593
+ );
594
+ } catch {
595
+ return null;
596
+ }
597
+ if (obj && obj.encoding === "base64" && typeof obj.content === "string") {
598
+ return Buffer.from(obj.content, "base64").toString("utf-8");
599
+ }
600
+ return null;
601
+ }
602
+
408
603
  /**
409
604
  * Resolve a consumer's effective branch: the entry's `branch` if set, else the
410
605
  * repo's default branch.
@@ -436,7 +631,9 @@ export function resolveBranch(consumer, runGh) {
436
631
  */
437
632
  export function buildReport(config, runGh) {
438
633
  const platformRepo = config.platformRepo;
634
+ const platformPkg = config.platformPackage || "mandrel-platform";
439
635
  const latestRelease = resolveLatestRelease(platformRepo, runGh);
636
+ const latestVersion = parseSemver(latestRelease.tag);
440
637
  const results = [];
441
638
  for (const consumer of config.consumers) {
442
639
  try {
@@ -447,19 +644,38 @@ export function buildReport(config, runGh) {
447
644
  pins.push(...extractPlatformPins(f.path, f.text, platformRepo));
448
645
  }
449
646
  const verdict = classifyConsumer(pins, latestRelease.sha);
450
- results.push({ name: consumer.name, repo: consumer.repo, branch, pins, verdict });
647
+ const pkgText = fetchConsumerPackageJson(consumer.repo, branch, runGh);
648
+ const npmSpec =
649
+ pkgText === null ? null : extractNpmPlatformVersion(pkgText, platformPkg);
650
+ const npm = classifyNpmPin(npmSpec, latestVersion);
651
+ const surfaceSkew = detectSurfaceSkew(verdict.lagState, npm.npmState);
652
+ results.push({
653
+ name: consumer.name,
654
+ repo: consumer.repo,
655
+ branch,
656
+ pins,
657
+ verdict,
658
+ npm,
659
+ surfaceSkew,
660
+ drift: combineDrift(verdict, npm, surfaceSkew),
661
+ });
451
662
  } catch (err) {
663
+ const verdict = classifyConsumer([], latestRelease.sha);
664
+ const npm = classifyNpmPin(null, latestVersion);
452
665
  results.push({
453
666
  name: consumer.name,
454
667
  repo: consumer.repo,
455
668
  branch: consumer.branch || "?",
456
669
  error: err instanceof Error ? err.message : String(err),
457
670
  pins: [],
458
- verdict: classifyConsumer([], latestRelease.sha),
671
+ verdict,
672
+ npm,
673
+ surfaceSkew: false,
674
+ drift: false,
459
675
  });
460
676
  }
461
677
  }
462
- return { platformRepo, latestRelease, results };
678
+ return { platformRepo, latestRelease, latestVersion, results };
463
679
  }
464
680
 
465
681
  /**
@@ -467,7 +683,7 @@ export function buildReport(config, runGh) {
467
683
  * @returns {boolean} true when any consumer has drift or an error.
468
684
  */
469
685
  export function hasDrift(report) {
470
- return report.results.some((r) => r.error || r.verdict.drift);
686
+ return report.results.some((r) => r.error || r.drift);
471
687
  }
472
688
 
473
689
  // ---------------------------------------------------------------------------
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-pin-drift.test.mjs — node:test suite for the cross-consumer pin-drift
4
+ * dashboard (Story #67, MP-12) and the npm-dimension extension that closes the
5
+ * gap where a consumer's `mandrel-platform` npm dependency could lag the
6
+ * workflow `uses:` pins undetected (npm at 0.11.3 while the workflows tracked
7
+ * v0.11.6).
8
+ *
9
+ * The checker exposes pure helpers plus an injectable `runGh` seam, so the
10
+ * whole pipeline is exercised offline with canned GitHub responses — no
11
+ * network, no `gh` auth.
12
+ *
13
+ * Run: node scripts/check-pin-drift.test.mjs (or `node --test scripts/`)
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { test } from "node:test";
21
+
22
+ import {
23
+ buildReport,
24
+ classifyNpmPin,
25
+ combineDrift,
26
+ compareSemver,
27
+ detectSurfaceSkew,
28
+ extractNpmPlatformVersion,
29
+ fetchConsumerPackageJson,
30
+ hasDrift,
31
+ parseSemver,
32
+ renderReport,
33
+ runCli,
34
+ } from "./check-pin-drift.mjs";
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // parseSemver
38
+ // ---------------------------------------------------------------------------
39
+
40
+ test("parseSemver extracts the dotted triple from a release tag", () => {
41
+ assert.equal(parseSemver("mandrel-platform-v0.11.7"), "0.11.7");
42
+ assert.equal(parseSemver("v1.4.0"), "1.4.0");
43
+ });
44
+
45
+ test("parseSemver strips range prefixes from a dependency spec", () => {
46
+ assert.equal(parseSemver("^0.11.7"), "0.11.7");
47
+ assert.equal(parseSemver("~1.2.3"), "1.2.3");
48
+ assert.equal(parseSemver(">=2.0.0"), "2.0.0");
49
+ assert.equal(parseSemver("0.11.3"), "0.11.3");
50
+ });
51
+
52
+ test("parseSemver returns null for non-numeric specs and non-strings", () => {
53
+ assert.equal(parseSemver("workspace:*"), null);
54
+ assert.equal(parseSemver("latest"), null);
55
+ assert.equal(parseSemver("github:owner/repo"), null);
56
+ assert.equal(parseSemver(null), null);
57
+ assert.equal(parseSemver(undefined), null);
58
+ });
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // compareSemver — numeric, not lexical
62
+ // ---------------------------------------------------------------------------
63
+
64
+ test("compareSemver orders versions numerically", () => {
65
+ assert.equal(compareSemver("0.11.3", "0.11.7"), -1);
66
+ assert.equal(compareSemver("1.4.0", "1.4.0"), 0);
67
+ assert.equal(compareSemver("2.0.0", "1.9.9"), 1);
68
+ });
69
+
70
+ test("compareSemver compares each segment as a number, not a string", () => {
71
+ // Lexical comparison would put "0.2.0" after "0.10.0"; numeric must not.
72
+ assert.equal(compareSemver("0.2.0", "0.10.0"), -1);
73
+ assert.equal(compareSemver("0.11.10", "0.11.9"), 1);
74
+ });
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // extractNpmPlatformVersion
78
+ // ---------------------------------------------------------------------------
79
+
80
+ test("extractNpmPlatformVersion reads the dep from devDependencies", () => {
81
+ const text = JSON.stringify({ devDependencies: { "mandrel-platform": "0.11.3" } });
82
+ assert.equal(extractNpmPlatformVersion(text), "0.11.3");
83
+ });
84
+
85
+ test("extractNpmPlatformVersion prefers dependencies over devDependencies", () => {
86
+ const text = JSON.stringify({
87
+ dependencies: { "mandrel-platform": "1.0.0" },
88
+ devDependencies: { "mandrel-platform": "2.0.0" },
89
+ });
90
+ assert.equal(extractNpmPlatformVersion(text), "1.0.0");
91
+ });
92
+
93
+ test("extractNpmPlatformVersion falls back to optional/peer deps", () => {
94
+ const peer = JSON.stringify({ peerDependencies: { "mandrel-platform": "3.1.4" } });
95
+ assert.equal(extractNpmPlatformVersion(peer), "3.1.4");
96
+ const opt = JSON.stringify({ optionalDependencies: { "mandrel-platform": "5.0.0" } });
97
+ assert.equal(extractNpmPlatformVersion(opt), "5.0.0");
98
+ });
99
+
100
+ test("extractNpmPlatformVersion honors a custom package name", () => {
101
+ const text = JSON.stringify({ devDependencies: { "@scope/other": "9.9.9" } });
102
+ assert.equal(extractNpmPlatformVersion(text, "@scope/other"), "9.9.9");
103
+ });
104
+
105
+ test("extractNpmPlatformVersion returns null when absent or malformed", () => {
106
+ assert.equal(extractNpmPlatformVersion(JSON.stringify({ devDependencies: {} })), null);
107
+ assert.equal(extractNpmPlatformVersion("{ not json"), null);
108
+ assert.equal(extractNpmPlatformVersion("null"), null);
109
+ });
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // classifyNpmPin
113
+ // ---------------------------------------------------------------------------
114
+
115
+ test("classifyNpmPin classifies current / lagging / ahead", () => {
116
+ assert.deepEqual(classifyNpmPin("0.11.7", "0.11.7"), {
117
+ rawSpec: "0.11.7",
118
+ version: "0.11.7",
119
+ npmState: "current",
120
+ });
121
+ assert.equal(classifyNpmPin("0.11.3", "0.11.7").npmState, "lagging");
122
+ assert.equal(classifyNpmPin("1.0.0", "0.11.7").npmState, "ahead");
123
+ });
124
+
125
+ test("classifyNpmPin marks an absent dependency", () => {
126
+ assert.deepEqual(classifyNpmPin(null, "0.11.7"), {
127
+ rawSpec: null,
128
+ version: null,
129
+ npmState: "absent",
130
+ });
131
+ });
132
+
133
+ test("classifyNpmPin is unknown for non-numeric specs or unknown latest", () => {
134
+ assert.equal(classifyNpmPin("workspace:*", "0.11.7").npmState, "unknown");
135
+ assert.equal(classifyNpmPin("0.11.7", null).npmState, "unknown");
136
+ });
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // detectSurfaceSkew — the incident this guard exists for
140
+ // ---------------------------------------------------------------------------
141
+
142
+ test("detectSurfaceSkew flags uses-current but npm-lagging (and the reverse)", () => {
143
+ assert.equal(detectSurfaceSkew("current", "lagging"), true);
144
+ assert.equal(detectSurfaceSkew("lagging", "current"), true);
145
+ });
146
+
147
+ test("detectSurfaceSkew is false when both surfaces agree", () => {
148
+ assert.equal(detectSurfaceSkew("current", "current"), false);
149
+ assert.equal(detectSurfaceSkew("lagging", "lagging"), false);
150
+ });
151
+
152
+ test("detectSurfaceSkew is false when either surface is not comparable", () => {
153
+ assert.equal(detectSurfaceSkew("current", "absent"), false);
154
+ assert.equal(detectSurfaceSkew("current", "unknown"), false);
155
+ assert.equal(detectSurfaceSkew("unknown", "lagging"), false);
156
+ assert.equal(detectSurfaceSkew("no-pins", "lagging"), false);
157
+ });
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // combineDrift
161
+ // ---------------------------------------------------------------------------
162
+
163
+ test("combineDrift folds uses-drift, npm-lag, and surface-skew", () => {
164
+ const clean = { drift: false };
165
+ assert.equal(combineDrift(clean, { npmState: "current" }, false), false);
166
+ assert.equal(combineDrift(clean, { npmState: "absent" }, false), false);
167
+ assert.equal(combineDrift(clean, { npmState: "ahead" }, false), false);
168
+ assert.equal(combineDrift(clean, { npmState: "lagging" }, false), true);
169
+ assert.equal(combineDrift(clean, { npmState: "current" }, true), true);
170
+ assert.equal(combineDrift({ drift: true }, { npmState: "current" }, false), true);
171
+ });
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Integration: buildReport + renderReport with an injected gh runner
175
+ // ---------------------------------------------------------------------------
176
+
177
+ const PLATFORM = "dsj1984/mandrel-platform";
178
+ const TAG = "mandrel-platform-v1.4.0";
179
+ const LATEST_SHA = "a".repeat(40);
180
+ const OLD_SHA = "b".repeat(40);
181
+
182
+ function b64(value) {
183
+ const s = typeof value === "string" ? value : JSON.stringify(value);
184
+ return Buffer.from(s, "utf-8").toString("base64");
185
+ }
186
+
187
+ function usesYaml(sha) {
188
+ return ["jobs:", " q:", ` uses: ${PLATFORM}/.github/workflows/pr-quality.yml@${sha}`].join(
189
+ "\n",
190
+ );
191
+ }
192
+
193
+ function pkgJson(version) {
194
+ const devDependencies = version ? { "mandrel-platform": version } : {};
195
+ return { name: "consumer", devDependencies };
196
+ }
197
+
198
+ /**
199
+ * Build an injectable gh runner from a per-repo fixture map:
200
+ * { "owner/repo": { workflowSha, npm: string | null | "throw" } }
201
+ */
202
+ function makeRunGh(fixtures) {
203
+ return (args) => {
204
+ const path = args[1];
205
+ if (path === `repos/${PLATFORM}/releases/latest`) {
206
+ return JSON.stringify({ tag_name: TAG });
207
+ }
208
+ if (path === `repos/${PLATFORM}/git/ref/tags/${TAG}`) {
209
+ return JSON.stringify({ object: { sha: LATEST_SHA, type: "commit" } });
210
+ }
211
+ for (const [repo, cfg] of Object.entries(fixtures)) {
212
+ if (path === `repos/${repo}/contents/.github/workflows?ref=main`) {
213
+ return JSON.stringify([
214
+ { type: "file", name: "ci.yml", encoding: "base64", content: b64(usesYaml(cfg.workflowSha)) },
215
+ ]);
216
+ }
217
+ if (path === `repos/${repo}/contents/package.json?ref=main`) {
218
+ if (cfg.npm === "throw") throw new Error("404 Not Found");
219
+ return JSON.stringify({ encoding: "base64", content: b64(pkgJson(cfg.npm)) });
220
+ }
221
+ }
222
+ throw new Error(`unexpected gh api path: ${path}`);
223
+ };
224
+ }
225
+
226
+ const CONFIG = {
227
+ platformRepo: PLATFORM,
228
+ consumers: [
229
+ { name: "aligned", repo: "o/aligned", branch: "main" },
230
+ { name: "skew", repo: "o/skew", branch: "main" },
231
+ { name: "both-lag", repo: "o/both-lag", branch: "main" },
232
+ { name: "no-npm", repo: "o/no-npm", branch: "main" },
233
+ ],
234
+ };
235
+
236
+ const FIXTURES = {
237
+ "o/aligned": { workflowSha: LATEST_SHA, npm: "1.4.0" },
238
+ "o/skew": { workflowSha: LATEST_SHA, npm: "1.3.0" },
239
+ "o/both-lag": { workflowSha: OLD_SHA, npm: "1.3.0" },
240
+ "o/no-npm": { workflowSha: LATEST_SHA, npm: null },
241
+ };
242
+
243
+ function byName(report, name) {
244
+ return report.results.find((r) => r.name === name);
245
+ }
246
+
247
+ test("buildReport: aligned consumer is current with no drift", () => {
248
+ const report = buildReport(CONFIG, makeRunGh(FIXTURES));
249
+ assert.equal(report.latestVersion, "1.4.0");
250
+ const r = byName(report, "aligned");
251
+ assert.equal(r.verdict.lagState, "current");
252
+ assert.equal(r.npm.npmState, "current");
253
+ assert.equal(r.surfaceSkew, false);
254
+ assert.equal(r.drift, false);
255
+ });
256
+
257
+ test("buildReport: npm lagging while workflows current is a surface skew", () => {
258
+ const report = buildReport(CONFIG, makeRunGh(FIXTURES));
259
+ const r = byName(report, "skew");
260
+ assert.equal(r.verdict.lagState, "current");
261
+ assert.equal(r.npm.npmState, "lagging");
262
+ assert.equal(r.surfaceSkew, true);
263
+ assert.equal(r.drift, true);
264
+ });
265
+
266
+ test("buildReport: both surfaces lagging is drift but not a skew", () => {
267
+ const report = buildReport(CONFIG, makeRunGh(FIXTURES));
268
+ const r = byName(report, "both-lag");
269
+ assert.equal(r.verdict.lagState, "lagging");
270
+ assert.equal(r.npm.npmState, "lagging");
271
+ assert.equal(r.surfaceSkew, false);
272
+ assert.equal(r.drift, true);
273
+ });
274
+
275
+ test("buildReport: a consumer without the npm dep is current, not drift", () => {
276
+ const report = buildReport(CONFIG, makeRunGh(FIXTURES));
277
+ const r = byName(report, "no-npm");
278
+ assert.equal(r.npm.npmState, "absent");
279
+ assert.equal(r.surfaceSkew, false);
280
+ assert.equal(r.drift, false);
281
+ });
282
+
283
+ test("buildReport: an unreadable package.json is treated as absent, not an error", () => {
284
+ const report = buildReport(
285
+ { platformRepo: PLATFORM, consumers: [{ name: "c", repo: "o/c", branch: "main" }] },
286
+ makeRunGh({ "o/c": { workflowSha: LATEST_SHA, npm: "throw" } }),
287
+ );
288
+ const r = byName(report, "c");
289
+ assert.equal(r.error, undefined);
290
+ assert.equal(r.npm.npmState, "absent");
291
+ assert.equal(r.drift, false);
292
+ });
293
+
294
+ test("hasDrift is true when any consumer drifts", () => {
295
+ const report = buildReport(CONFIG, makeRunGh(FIXTURES));
296
+ assert.equal(hasDrift(report), true);
297
+ });
298
+
299
+ test("renderReport surfaces the npm columns and drift lines", () => {
300
+ const report = buildReport(CONFIG, makeRunGh(FIXTURES));
301
+ const text = renderReport(report);
302
+ assert.match(text, /npm pin/);
303
+ assert.match(text, /npm lag/);
304
+ assert.match(text, /SURFACE SKEW/);
305
+ assert.match(text, /npm\/uses skew/);
306
+ // The aligned consumer renders its npm version in the table.
307
+ assert.match(text, /`1\.4\.0`/);
308
+ });
309
+
310
+ test("fetchConsumerPackageJson returns null when the file is missing", () => {
311
+ const runGh = () => {
312
+ throw new Error("404");
313
+ };
314
+ assert.equal(fetchConsumerPackageJson("o/x", "main", runGh), null);
315
+ });
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // CLI: --json, --strict, exit codes
319
+ // ---------------------------------------------------------------------------
320
+
321
+ let cfgDir;
322
+ function writeConfig() {
323
+ cfgDir = mkdtempSync(join(tmpdir(), "pin-drift-test-"));
324
+ const p = join(cfgDir, "consumers.json");
325
+ writeFileSync(p, JSON.stringify(CONFIG));
326
+ return p;
327
+ }
328
+
329
+ function capture() {
330
+ const chunks = [];
331
+ return { write: (s) => chunks.push(s), text: () => chunks.join("") };
332
+ }
333
+
334
+ test("runCli --json emits a machine-readable envelope and exits 0 without --strict", () => {
335
+ const configPath = writeConfig();
336
+ const stdout = capture();
337
+ const stderr = capture();
338
+ try {
339
+ const code = runCli({
340
+ argv: ["--config", configPath, "--json"],
341
+ runGh: makeRunGh(FIXTURES),
342
+ stdout,
343
+ stderr,
344
+ summaryPath: undefined,
345
+ });
346
+ assert.equal(code, 0);
347
+ const envelope = JSON.parse(stdout.text());
348
+ assert.equal(envelope.kind, "pin-drift-report");
349
+ assert.equal(envelope.drift, true);
350
+ assert.equal(envelope.results.length, 4);
351
+ } finally {
352
+ rmSync(cfgDir, { recursive: true, force: true });
353
+ }
354
+ });
355
+
356
+ test("runCli --strict exits 1 when drift is present", () => {
357
+ const configPath = writeConfig();
358
+ try {
359
+ const code = runCli({
360
+ argv: ["--config", configPath, "--strict"],
361
+ runGh: makeRunGh(FIXTURES),
362
+ stdout: capture(),
363
+ stderr: capture(),
364
+ summaryPath: undefined,
365
+ });
366
+ assert.equal(code, 1);
367
+ } finally {
368
+ rmSync(cfgDir, { recursive: true, force: true });
369
+ }
370
+ });
@@ -1,5 +1,5 @@
1
1
  {
2
- "$comment": "Data-driven consumer registry for scripts/check-pin-drift.mjs (Story #67, MP-12). Each entry is one downstream repo that pins mandrel-platform reusable workflows / composite actions via `uses: dsj1984/mandrel-platform/...@<sha>`. Adding a new consumer is a single object here — the drift checker enumerates `.github/workflows/*` in each repo over the GitHub API, extracts every mandrel-platform pin, and asserts a single SHA per consumer plus lag vs the latest mandrel-platform release. `branch` is optional (defaults to the repo's default branch).",
2
+ "$comment": "Data-driven consumer registry for scripts/check-pin-drift.mjs (Story #67, MP-12). Each entry is one downstream repo that pins mandrel-platform reusable workflows / composite actions via `uses: dsj1984/mandrel-platform/...@<sha>`. Adding a new consumer is a single object here — the drift checker enumerates `.github/workflows/*` and reads `package.json` in each repo over the GitHub API, extracts every mandrel-platform `uses:` pin plus the `mandrel-platform` npm dependency version, and asserts a single SHA per consumer plus lag (and surface skew between the workflow and npm surfaces) vs the latest mandrel-platform release. `branch` is optional (defaults to the repo's default branch).",
3
3
  "platformRepo": "dsj1984/mandrel-platform",
4
4
  "consumers": [
5
5
  {