mandrel-platform 1.0.1 → 1.2.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.
@@ -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
+ });
@@ -0,0 +1,532 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-first-party-pin-freshness.mjs
4
+ *
5
+ * First-party self-pin freshness guard (Story #354).
6
+ *
7
+ * This repo publishes its own composite actions and reusable workflows and
8
+ * then CALLS them by absolute `owner/repo/subpath@<sha>` reference — the same
9
+ * form a consumer uses. That self-pin is the ONLY thing that decides which
10
+ * revision of the action actually runs. Fixing an action file in the working
11
+ * tree therefore changes nothing at runtime until every call site is
12
+ * repointed, and nothing in the repo detected that gap:
13
+ *
14
+ * • `check-action-pins.mjs` enforces only the single-pin invariant — that
15
+ * every call site for a subpath agrees. A fleet of call sites agreeing on
16
+ * ONE STALE sha is perfectly green. (At the time it also EXEMPTED
17
+ * first-party refs from the SHA ratchet entirely; that exemption was
18
+ * retired by the Story #354 audit — see the `unpinnedRefs` note below.)
19
+ * • `check-workflow-portability.mjs` Rule 3 does read the manifest at the
20
+ * pinned SHA, but re-runs only its own Rules 1–2 against it (relative
21
+ * `uses:`, `${{ }}` in input descriptions/defaults). A BEHAVIOURAL lag —
22
+ * e.g. `mktemp -d` → `${RUNNER_TEMP}` — is invisible to it.
23
+ *
24
+ * That blind spot shipped issue #352: PR #345 scoped the gitleaks/osv
25
+ * extraction dirs to `${RUNNER_TEMP}`, no call site was repointed, and every
26
+ * consumer on a self-hosted fleet kept leaking ~164 MB per run into the
27
+ * host-shared temp root on the latest release.
28
+ *
29
+ * This checker closes it by classifying every first-party SHA pin into one of
30
+ * two failure classes — deliberately kept distinct, because their remedies
31
+ * differ:
32
+ *
33
+ * • `stale` — the manifest AT THE PINNED SHA differs from the
34
+ * working-tree manifest at the same subpath. The fix is to
35
+ * BUMP the pin to a commit carrying the current manifest.
36
+ * • `unreachable` — the pinned SHA is not an ancestor of the checked-out
37
+ * ref. Typically a pre-squash branch commit: content-
38
+ * identical to `main` today, resolvable only until GitHub
39
+ * garbage-collects it, after which every consumer fails at
40
+ * action-load time. The fix is to RE-PIN to the squashed
41
+ * commit on `main`.
42
+ *
43
+ * A first-party ref that is NOT a 40-hex SHA has no freshness answer at all —
44
+ * a branch or tag names a revision that can move after this check runs — so it
45
+ * is collected into `unpinnedRefs` and merely REPORTED. Enforcing the SHA
46
+ * shape belongs upstream of freshness, in `check-action-pins.mjs`, which
47
+ * ratchets first-party refs alongside third-party ones and runs in the
48
+ * PR-gating `ci.yml` where this check deliberately does not (see below). The
49
+ * note here is the backstop for a consumer that adopted only this script.
50
+ *
51
+ * Requires full git history — run the checkout with `fetch-depth: 0`. A
52
+ * shallow clone cannot answer either question and is refused loudly rather
53
+ * than reported as a wall of false `unreachable` findings.
54
+ *
55
+ * ## Where this runs, and why not on PRs
56
+ *
57
+ * Wired onto push-to-`main` and the `pin-drift.yml` schedule; deliberately
58
+ * ABSENT from the PR-gating `ci.yml`. A PR that edits a composite action
59
+ * cannot pin its own not-yet-existing merge commit, so a PR-time gate would
60
+ * be unsatisfiable on exactly the changes it exists to protect. A red `main`
61
+ * is instead the signal to open the follow-up bump PR — the land-then-bump
62
+ * sequence documented in docs/reusable-workflows.md.
63
+ *
64
+ * Usage:
65
+ * node scripts/check-first-party-pin-freshness.mjs
66
+ * node scripts/check-first-party-pin-freshness.mjs --cwd /path/to/repo
67
+ * node scripts/check-first-party-pin-freshness.mjs --ref origin/main
68
+ * node scripts/check-first-party-pin-freshness.mjs --first-party-owner my-org/my-repo
69
+ *
70
+ * Exit codes:
71
+ * 0 — every first-party pin resolves to a fresh, reachable manifest.
72
+ * 1 — one or more `stale` / `unreachable` pins (each named in stderr with
73
+ * file, line, subpath and SHA), or the history needed to decide is
74
+ * unavailable.
75
+ *
76
+ * Consumer adoption:
77
+ * Copy this script into your project's `scripts/` directory alongside
78
+ * `scripts/lib/{args,uses-pins,walk}.mjs` (its only dependencies — no YAML
79
+ * parser), then run it on push to your default branch:
80
+ *
81
+ * - uses: actions/checkout@<sha>
82
+ * with: { fetch-depth: 0 }
83
+ * - run: node scripts/check-first-party-pin-freshness.mjs --first-party-owner <owner/repo>
84
+ */
85
+
86
+ import { readFileSync, existsSync, statSync } from "node:fs";
87
+ import { execFileSync } from "node:child_process";
88
+ import { resolve, join, relative, basename } from "node:path";
89
+
90
+ import { parseFlags } from "./lib/args.mjs";
91
+ import {
92
+ DEFAULT_FIRST_PARTY_OWNER,
93
+ parseUsesLine,
94
+ classifyUses,
95
+ isSha40,
96
+ } from "./lib/uses-pins.mjs";
97
+ import { listWorkflowFiles, listActionFiles } from "./lib/walk.mjs";
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Arg parsing
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /**
104
+ * Parse the CLI argv slice (everything AFTER `node script.mjs`) into an
105
+ * options object. Throws on an unknown flag so a typo fails loudly rather than
106
+ * silently disabling half the check.
107
+ *
108
+ * @param {string[]} argv
109
+ * @returns {{workflowsDir: string, actionsDir: string, firstPartyOwner: string, cwd: string, ref: string, help: boolean}}
110
+ */
111
+ export function parseArgs(argv) {
112
+ return parseFlags(argv, {
113
+ flags: {
114
+ "--workflows-dir": { type: "string", dest: "workflowsDir", default: ".github/workflows" },
115
+ "--actions-dir": { type: "string", dest: "actionsDir", default: ".github/actions" },
116
+ "--first-party-owner": {
117
+ type: "string",
118
+ dest: "firstPartyOwner",
119
+ default: DEFAULT_FIRST_PARTY_OWNER,
120
+ },
121
+ "--cwd": { type: "string", dest: "cwd", default: process.cwd() },
122
+ "--ref": { type: "string", dest: "ref", default: "HEAD" },
123
+ "--help": { type: "boolean", dest: "help", value: true, default: false },
124
+ },
125
+ aliases: { "-h": "--help" },
126
+ onUnknown: "throw",
127
+ });
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Pin collection (pure text)
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Scan a file's TEXT for first-party `uses:` references and split them into
136
+ * the SHA-pinned refs this checker classifies and the non-SHA refs it can only
137
+ * note. Third-party, local (`./path`) and `docker://` references are dropped
138
+ * here and can therefore never reach the report.
139
+ *
140
+ * A bare `owner/repo@ref` self-reference is skipped: with no subpath there is
141
+ * no manifest to resolve. Whole-line `#` comments never match (the doc-example
142
+ * `uses:` lines every action.yml carries in its header are comments).
143
+ *
144
+ * @param {string} content
145
+ * @param {string} displayFile Path as it should appear in the report.
146
+ * @param {string} [firstPartyOwner]
147
+ * @returns {{pins: Array<{file: string, line: number, subpath: string, sha: string}>, unpinnedRefs: Array<{file: string, line: number, subpath: string, ref: string}>}}
148
+ */
149
+ export function collectPinnedRefs(
150
+ content,
151
+ displayFile,
152
+ firstPartyOwner = DEFAULT_FIRST_PARTY_OWNER
153
+ ) {
154
+ const pins = [];
155
+ const unpinnedRefs = [];
156
+ const lines = String(content).split(/\r?\n/);
157
+ for (let i = 0; i < lines.length; i++) {
158
+ const bareRef = parseUsesLine(lines[i]);
159
+ if (bareRef === null) continue;
160
+ const cls = classifyUses(bareRef, firstPartyOwner);
161
+ if (cls.kind !== "first-party") continue;
162
+ if (!cls.subpath) continue; // bare owner/repo self-ref — no manifest to resolve
163
+ const record = { file: displayFile, line: i + 1, subpath: cls.subpath };
164
+ if (isSha40(cls.ref)) pins.push({ ...record, sha: cls.ref });
165
+ else unpinnedRefs.push({ ...record, ref: cls.ref });
166
+ }
167
+ return { pins, unpinnedRefs };
168
+ }
169
+
170
+ /**
171
+ * Resolve the manifest a `uses:` subpath points at, relative to a repo root.
172
+ * A directory subpath resolves to its `action.yml` / `action.yaml`; a `.yml` /
173
+ * `.yaml` subpath resolves to itself. Returns null when the subpath does not
174
+ * exist in the working tree or holds no manifest.
175
+ *
176
+ * @param {string} repoRoot
177
+ * @param {string} subpath
178
+ * @returns {{path: string, kind: "action" | "workflow"} | null}
179
+ */
180
+ export function resolveManifest(repoRoot, subpath) {
181
+ const local = join(repoRoot, subpath);
182
+ let st;
183
+ try {
184
+ st = statSync(local);
185
+ } catch {
186
+ return null;
187
+ }
188
+ if (st.isDirectory()) {
189
+ for (const name of ["action.yml", "action.yaml"]) {
190
+ if (existsSync(join(local, name))) return { path: `${subpath}/${name}`, kind: "action" };
191
+ }
192
+ return null;
193
+ }
194
+ if (/\.ya?ml$/.test(subpath)) {
195
+ return { path: subpath, kind: basename(subpath).startsWith("action.") ? "action" : "workflow" };
196
+ }
197
+ return null;
198
+ }
199
+
200
+ /**
201
+ * Compare two manifest bodies for equality, tolerating line-ending drift so a
202
+ * CRLF working tree on Windows does not report every pin as stale.
203
+ *
204
+ * @param {string} a
205
+ * @param {string} b
206
+ * @returns {boolean}
207
+ */
208
+ export function manifestsMatch(a, b) {
209
+ const norm = (s) => String(s).replace(/\r\n/g, "\n");
210
+ return norm(a) === norm(b);
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Git seam
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /**
218
+ * Build the narrow git interface `runCheck` needs, bound to a repo root. Every
219
+ * method is failure-tolerant: a missing object or an unreadable path answers
220
+ * "no" rather than throwing, so a broken pin is reported as a finding instead
221
+ * of crashing the lint.
222
+ *
223
+ * @param {string} repoRoot
224
+ */
225
+ export function createGit(repoRoot) {
226
+ const run = (args) =>
227
+ execFileSync("git", args, {
228
+ cwd: repoRoot,
229
+ encoding: "utf8",
230
+ stdio: ["ignore", "pipe", "ignore"],
231
+ maxBuffer: 16 * 1024 * 1024,
232
+ });
233
+
234
+ return {
235
+ /** True when `repoRoot` sits inside a git work tree. */
236
+ isRepo() {
237
+ try {
238
+ run(["rev-parse", "--is-inside-work-tree"]);
239
+ return true;
240
+ } catch {
241
+ return false;
242
+ }
243
+ },
244
+ /** True when the clone is shallow (history truncated — cannot decide). */
245
+ isShallow() {
246
+ try {
247
+ return run(["rev-parse", "--is-shallow-repository"]).trim() === "true";
248
+ } catch {
249
+ return false;
250
+ }
251
+ },
252
+ /** Abbreviated SHA of a ref, or null when it does not resolve. */
253
+ resolveRef(ref) {
254
+ try {
255
+ return run(["rev-parse", ref]).trim();
256
+ } catch {
257
+ return null;
258
+ }
259
+ },
260
+ /** True when `sha` is an ancestor of (or equal to) `ref`. */
261
+ isAncestor(sha, ref) {
262
+ try {
263
+ execFileSync("git", ["merge-base", "--is-ancestor", sha, ref], {
264
+ cwd: repoRoot,
265
+ stdio: ["ignore", "ignore", "ignore"],
266
+ });
267
+ return true;
268
+ } catch {
269
+ // Exit 1 = not an ancestor; exit 128 = unknown object. Both mean the
270
+ // pin is not reachable from the checked-out ref.
271
+ return false;
272
+ }
273
+ },
274
+ /** `git show <sha>:<path>` → content, or null when unresolvable. */
275
+ show(sha, path) {
276
+ try {
277
+ return run(["show", `${sha}:${path}`]);
278
+ } catch {
279
+ return null;
280
+ }
281
+ },
282
+ };
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Check
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /**
290
+ * Classify every first-party SHA pin under the repo's workflow and action
291
+ * trees. Returns a structured result; formatting and the exit code live in
292
+ * {@link runCli}.
293
+ *
294
+ * `git` is injectable so a caller can drive the classification against a
295
+ * substitute history; it defaults to the real git bound to `opts.cwd`.
296
+ *
297
+ * @param {{cwd?: string, workflowsDir?: string, actionsDir?: string, firstPartyOwner?: string, ref?: string}} opts
298
+ * @param {ReturnType<typeof createGit>} [git]
299
+ * @returns {{ok: boolean, fatal: string|null, stale: object[], unreachable: object[], unpinnedRefs: object[], scanned: number, files: string[], headSha: string|null}}
300
+ */
301
+ export function runCheck(opts = {}, git = createGit(resolve(opts.cwd || process.cwd()))) {
302
+ const repoRoot = resolve(opts.cwd || process.cwd());
303
+ const ref = opts.ref || "HEAD";
304
+ const firstPartyOwner = opts.firstPartyOwner || DEFAULT_FIRST_PARTY_OWNER;
305
+ const wfDir = resolve(repoRoot, opts.workflowsDir || ".github/workflows");
306
+ const acDir = resolve(repoRoot, opts.actionsDir || ".github/actions");
307
+
308
+ const empty = {
309
+ ok: false,
310
+ fatal: null,
311
+ stale: [],
312
+ unreachable: [],
313
+ unpinnedRefs: [],
314
+ scanned: 0,
315
+ files: [],
316
+ headSha: null,
317
+ };
318
+
319
+ if (!git.isRepo()) {
320
+ return {
321
+ ...empty,
322
+ fatal:
323
+ "not a git repository — this check resolves each pinned manifest from " +
324
+ "git history and cannot run without it",
325
+ };
326
+ }
327
+ if (git.isShallow()) {
328
+ return {
329
+ ...empty,
330
+ fatal:
331
+ "shallow clone — pinned manifests and ancestry are unresolvable. Run " +
332
+ "the checkout with `fetch-depth: 0`",
333
+ };
334
+ }
335
+ const headSha = git.resolveRef(ref);
336
+ if (headSha === null) {
337
+ return { ...empty, fatal: `ref "${ref}" does not resolve in this repository` };
338
+ }
339
+
340
+ const files = [...listWorkflowFiles(wfDir), ...listActionFiles(acDir)];
341
+ const stale = [];
342
+ const unreachable = [];
343
+ const unpinnedRefs = [];
344
+ let scanned = 0;
345
+
346
+ for (const file of files) {
347
+ let content;
348
+ try {
349
+ content = readFileSync(file, "utf8");
350
+ } catch {
351
+ continue;
352
+ }
353
+ const display = relative(repoRoot, file) || file;
354
+ const collected = collectPinnedRefs(content, display, firstPartyOwner);
355
+ unpinnedRefs.push(...collected.unpinnedRefs);
356
+
357
+ for (const pin of collected.pins) {
358
+ scanned++;
359
+
360
+ // Reachability first: an unknown or off-branch object cannot be compared
361
+ // in the first place, and its remedy (re-pin to the squashed commit)
362
+ // differs from a bump.
363
+ if (!git.isAncestor(pin.sha, ref)) {
364
+ unreachable.push({
365
+ ...pin,
366
+ reason:
367
+ `pinned SHA is not an ancestor of ${ref} — a dangling or pre-squash ` +
368
+ `branch commit that breaks every consumer once it is garbage-collected`,
369
+ });
370
+ continue;
371
+ }
372
+
373
+ const manifest = resolveManifest(repoRoot, pin.subpath);
374
+ if (manifest === null) {
375
+ stale.push({
376
+ ...pin,
377
+ reason: `no manifest at ${pin.subpath} in the working tree — the pin references a path that no longer exists`,
378
+ });
379
+ continue;
380
+ }
381
+
382
+ const pinnedBody = git.show(pin.sha, manifest.path);
383
+ if (pinnedBody === null) {
384
+ stale.push({
385
+ ...pin,
386
+ reason: `${manifest.path} does not exist at the pinned SHA — the pin predates the manifest`,
387
+ });
388
+ continue;
389
+ }
390
+
391
+ let workingBody;
392
+ try {
393
+ workingBody = readFileSync(join(repoRoot, manifest.path), "utf8");
394
+ } catch {
395
+ stale.push({ ...pin, reason: `cannot read the working-tree ${manifest.path}` });
396
+ continue;
397
+ }
398
+
399
+ if (!manifestsMatch(pinnedBody, workingBody)) {
400
+ stale.push({
401
+ ...pin,
402
+ manifest: manifest.path,
403
+ reason: `the manifest at the pinned SHA differs from the working-tree ${manifest.path} — the pinned revision is what actually runs`,
404
+ });
405
+ }
406
+ }
407
+ }
408
+
409
+ return {
410
+ ok: stale.length === 0 && unreachable.length === 0,
411
+ fatal: null,
412
+ stale,
413
+ unreachable,
414
+ unpinnedRefs,
415
+ scanned,
416
+ files,
417
+ headSha,
418
+ };
419
+ }
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // CLI
423
+ // ---------------------------------------------------------------------------
424
+
425
+ const USAGE =
426
+ "Usage: node scripts/check-first-party-pin-freshness.mjs " +
427
+ "[--cwd <dir>] [--ref <git-ref>] [--workflows-dir <dir>] [--actions-dir <dir>] " +
428
+ "[--first-party-owner <owner/repo>]";
429
+
430
+ /**
431
+ * Format one finding as a two-line report entry naming the referencing file,
432
+ * the line, the subpath and the full pinned SHA (the four facts needed to act
433
+ * on it without re-deriving anything).
434
+ */
435
+ function formatFinding(f, cls) {
436
+ return (
437
+ ` • ${f.file}:${f.line} — ${f.subpath}@${f.sha} [${cls}]\n` +
438
+ ` ${f.reason}`
439
+ );
440
+ }
441
+
442
+ /**
443
+ * Run the check and return a POSIX exit code. `log` / `err` are injectable so
444
+ * the sibling node:test suite can capture output without touching the real
445
+ * streams.
446
+ *
447
+ * @param {string[]} argv
448
+ * @param {{log?: Function, err?: Function}} [io]
449
+ * @returns {number}
450
+ */
451
+ export function runCli(argv, { log = console.log, err = console.error } = {}) {
452
+ let opts;
453
+ try {
454
+ opts = parseArgs(argv);
455
+ } catch (e) {
456
+ err(`[pin-freshness] ❌ ${e.message}`);
457
+ err(USAGE);
458
+ return 1;
459
+ }
460
+ if (opts.help) {
461
+ log(USAGE);
462
+ return 0;
463
+ }
464
+
465
+ const result = runCheck(opts);
466
+
467
+ if (result.fatal !== null) {
468
+ err(`[pin-freshness] ❌ ${result.fatal}.`);
469
+ return 1;
470
+ }
471
+
472
+ if (result.unreachable.length > 0) {
473
+ err(
474
+ `[pin-freshness] ❌ ${result.unreachable.length} unreachable first-party pin(s) — ` +
475
+ `not an ancestor of ${opts.ref}:`
476
+ );
477
+ for (const f of result.unreachable) err(formatFinding(f, "unreachable"));
478
+ err(
479
+ "[pin-freshness] Re-pin each to the squashed commit on the default branch. " +
480
+ "These resolve today only because the pre-squash commit has not been " +
481
+ "garbage-collected yet."
482
+ );
483
+ }
484
+
485
+ if (result.stale.length > 0) {
486
+ err(
487
+ `[pin-freshness] ❌ ${result.stale.length} stale first-party pin(s) — ` +
488
+ `the pinned manifest lags the working tree:`
489
+ );
490
+ for (const f of result.stale) err(formatFinding(f, "stale"));
491
+ err(
492
+ "[pin-freshness] Bump each pin to a commit on the default branch whose " +
493
+ "manifest matches the working-tree copy. Every call site for a given " +
494
+ "subpath must move together (check-action-pins.mjs enforces the " +
495
+ "single-pin invariant per subpath)."
496
+ );
497
+ }
498
+
499
+ if (!result.ok) {
500
+ err(
501
+ "[pin-freshness] A first-party fix that no call site pins is inert: the " +
502
+ "pinned revision is what runs. See docs/reusable-workflows.md " +
503
+ "(First-party self-pin freshness) for the land-then-bump sequence."
504
+ );
505
+ return 1;
506
+ }
507
+
508
+ if (result.unpinnedRefs.length > 0) {
509
+ log(
510
+ `[pin-freshness] ℹ️ ${result.unpinnedRefs.length} first-party ref(s) are not SHA-pinned ` +
511
+ `(freshness undecidable — reported, not failed):`
512
+ );
513
+ for (const u of result.unpinnedRefs) {
514
+ log(` ${u.file}:${u.line} — ${u.subpath}@${u.ref}`);
515
+ }
516
+ }
517
+
518
+ log(
519
+ `[pin-freshness] ✅ all ${result.scanned} first-party pin(s) resolve to a manifest ` +
520
+ `matching the working tree and reachable from ${opts.ref} (${result.headSha.slice(0, 7)}); ` +
521
+ `${result.files.length} file(s) scanned.`
522
+ );
523
+ return 0;
524
+ }
525
+
526
+ // Only run when executed directly, not when imported by the test suite.
527
+ const invokedDirectly =
528
+ process.argv[1] &&
529
+ resolve(process.argv[1]).endsWith("check-first-party-pin-freshness.mjs");
530
+ if (invokedDirectly) {
531
+ process.exit(runCli(process.argv.slice(2)));
532
+ }