mandrel-platform 0.18.0 → 0.19.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.
@@ -0,0 +1,93 @@
1
+ /**
2
+ * scripts/lib/args.mjs
3
+ *
4
+ * The single argv-parsing seam shared by the pin-tooling CLIs
5
+ * (`check-action-pins.mjs`, `check-workflow-portability.mjs`, …). Each of
6
+ * those scripts had grown its own hand-rolled `parseArgs` — one throwing on
7
+ * an unknown flag, one silently ignoring it, and both re-implementing the
8
+ * same "take the next argv slot as this flag's value" dance. They had already
9
+ * drifted (different alias support, different unknown-flag policy), which is
10
+ * exactly the duplication Story #203 consolidates.
11
+ *
12
+ * `parseFlags(argv, spec)` is a tiny, dependency-free flag reader driven by a
13
+ * declarative spec. It intentionally does NOT try to be a full getopt: it
14
+ * supports the two shapes the pin tooling actually uses —
15
+ *
16
+ * • `string` flags — `--workflows-dir <value>` (optionally with aliases,
17
+ * e.g. `-w`), consuming the next argv slot as the value.
18
+ * • `boolean` flags — `--no-pin-check`, `--help` (present ⇒ the configured
19
+ * boolean value, default the inverse).
20
+ *
21
+ * The unknown-flag policy is a per-call knob (`onUnknown`) so a strict lint
22
+ * (fail loudly on a typo'd flag) and a lenient CLI (ignore stray args) can
23
+ * share one parser without either losing its behavior.
24
+ *
25
+ * This module reads no environment and performs no I/O, so the sibling
26
+ * `args.test.mjs` suite exercises it entirely offline.
27
+ */
28
+
29
+ /**
30
+ * @typedef {Object} FlagSpec
31
+ * @property {"string" | "boolean"} type How to consume the flag.
32
+ * @property {string} dest The result key to write.
33
+ * @property {*} [default] Default value when the flag is absent.
34
+ * @property {boolean} [value] For a boolean flag, the value to set
35
+ * when the flag IS present (default true).
36
+ */
37
+
38
+ /**
39
+ * Parse an argv slice (the array AFTER `node script.mjs`) into an options
40
+ * object driven by `spec`.
41
+ *
42
+ * @param {string[]} argv
43
+ * @param {Object} spec
44
+ * @param {Record<string, FlagSpec>} spec.flags Map of canonical flag token
45
+ * (e.g. `"--workflows-dir"`) to its {@link FlagSpec}.
46
+ * @param {Record<string, string>} [spec.aliases] Map of alias token
47
+ * (e.g. `"-w"`) to a canonical flag token present in `spec.flags`.
48
+ * @param {"throw" | "ignore"} [spec.onUnknown] What to do with an argument
49
+ * that is not a known flag or alias. `"throw"` (default) fails loudly;
50
+ * `"ignore"` skips it.
51
+ * @returns {Record<string, *>} The resolved options, seeded from each flag's
52
+ * `default`.
53
+ */
54
+ export function parseFlags(argv, spec) {
55
+ const flags = spec?.flags ?? {};
56
+ const aliases = spec?.aliases ?? {};
57
+ const onUnknown = spec?.onUnknown ?? "throw";
58
+
59
+ // Seed the result with every flag's declared default.
60
+ const opts = {};
61
+ for (const def of Object.values(flags)) {
62
+ opts[def.dest] = "default" in def ? def.default : undefined;
63
+ }
64
+
65
+ const canonical = (arg) => {
66
+ if (Object.prototype.hasOwnProperty.call(flags, arg)) return arg;
67
+ if (Object.prototype.hasOwnProperty.call(aliases, arg)) return aliases[arg];
68
+ return null;
69
+ };
70
+
71
+ for (let i = 0; i < argv.length; i++) {
72
+ const arg = argv[i];
73
+ const key = canonical(arg);
74
+ if (key === null) {
75
+ if (onUnknown === "ignore") continue;
76
+ throw new Error(`unknown argument "${arg}"`);
77
+ }
78
+ const def = flags[key];
79
+ if (def.type === "boolean") {
80
+ opts[def.dest] = "value" in def ? def.value : true;
81
+ continue;
82
+ }
83
+ // string flag: consume the next argv slot as the value.
84
+ const next = argv[i + 1];
85
+ if (next === undefined || next.startsWith("--")) {
86
+ throw new Error(`missing value for "${arg}"`);
87
+ }
88
+ opts[def.dest] = next;
89
+ i++;
90
+ }
91
+
92
+ return opts;
93
+ }
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * args.test.mjs — node:test suite for the shared argv parser
4
+ * (`scripts/lib/args.mjs`, Story #203).
5
+ *
6
+ * Covers the two flag shapes the pin tooling uses (string-with-value and
7
+ * boolean-present), alias resolution, default seeding, and both unknown-flag
8
+ * policies (throw for the strict ratchet, ignore for the lenient portability
9
+ * CLI). Pure — no I/O, fully offline.
10
+ *
11
+ * Run: node --test scripts/lib/args.test.mjs
12
+ */
13
+
14
+ import assert from "node:assert/strict";
15
+ import { test } from "node:test";
16
+
17
+ import { parseFlags } from "./args.mjs";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Defaults
21
+ // ---------------------------------------------------------------------------
22
+
23
+ test("parseFlags seeds each flag's declared default when absent", () => {
24
+ const opts = parseFlags([], {
25
+ flags: {
26
+ "--dir": { type: "string", dest: "dir", default: ".github/workflows" },
27
+ "--flag": { type: "boolean", dest: "flag", default: false },
28
+ },
29
+ });
30
+ assert.deepEqual(opts, { dir: ".github/workflows", flag: false });
31
+ });
32
+
33
+ test("parseFlags leaves dest undefined when a flag has no default", () => {
34
+ const opts = parseFlags([], {
35
+ flags: { "--dir": { type: "string", dest: "dir" } },
36
+ });
37
+ assert.equal("dir" in opts, true);
38
+ assert.equal(opts.dir, undefined);
39
+ });
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // String flags
43
+ // ---------------------------------------------------------------------------
44
+
45
+ test("parseFlags reads a string flag's value from the next slot", () => {
46
+ const opts = parseFlags(["--dir", "wf"], {
47
+ flags: { "--dir": { type: "string", dest: "dir", default: null } },
48
+ });
49
+ assert.equal(opts.dir, "wf");
50
+ });
51
+
52
+ test("parseFlags throws when a string flag is missing its value", () => {
53
+ assert.throws(
54
+ () =>
55
+ parseFlags(["--dir"], {
56
+ flags: { "--dir": { type: "string", dest: "dir" } },
57
+ }),
58
+ /missing value for "--dir"/
59
+ );
60
+ });
61
+
62
+ test("parseFlags treats a following --flag as a missing value, not the value", () => {
63
+ assert.throws(
64
+ () =>
65
+ parseFlags(["--dir", "--other"], {
66
+ flags: {
67
+ "--dir": { type: "string", dest: "dir" },
68
+ "--other": { type: "boolean", dest: "other" },
69
+ },
70
+ }),
71
+ /missing value for "--dir"/
72
+ );
73
+ });
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Boolean flags
77
+ // ---------------------------------------------------------------------------
78
+
79
+ test("parseFlags sets a boolean flag to its configured value when present", () => {
80
+ const opts = parseFlags(["--no-pin-check"], {
81
+ flags: {
82
+ "--no-pin-check": { type: "boolean", dest: "pinCheck", value: false, default: true },
83
+ },
84
+ });
85
+ assert.equal(opts.pinCheck, false);
86
+ });
87
+
88
+ test("parseFlags defaults a boolean present-value to true", () => {
89
+ const opts = parseFlags(["--help"], {
90
+ flags: { "--help": { type: "boolean", dest: "help", default: false } },
91
+ });
92
+ assert.equal(opts.help, true);
93
+ });
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Aliases
97
+ // ---------------------------------------------------------------------------
98
+
99
+ test("parseFlags resolves an alias to its canonical flag", () => {
100
+ const opts = parseFlags(["-w", "wf", "-h"], {
101
+ flags: {
102
+ "--workflows-dir": { type: "string", dest: "workflowsDir", default: null },
103
+ "--help": { type: "boolean", dest: "help", default: false },
104
+ },
105
+ aliases: { "-w": "--workflows-dir", "-h": "--help" },
106
+ });
107
+ assert.equal(opts.workflowsDir, "wf");
108
+ assert.equal(opts.help, true);
109
+ });
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Unknown-flag policy
113
+ // ---------------------------------------------------------------------------
114
+
115
+ test("parseFlags throws on an unknown flag by default", () => {
116
+ assert.throws(
117
+ () => parseFlags(["--nope"], { flags: {} }),
118
+ /unknown argument "--nope"/
119
+ );
120
+ });
121
+
122
+ test("parseFlags ignores unknown args when onUnknown is 'ignore'", () => {
123
+ const opts = parseFlags(["--nope", "--dir", "wf", "stray"], {
124
+ flags: { "--dir": { type: "string", dest: "dir", default: null } },
125
+ onUnknown: "ignore",
126
+ });
127
+ assert.equal(opts.dir, "wf");
128
+ });
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Realistic combined spec (mirrors check-action-pins.mjs)
132
+ // ---------------------------------------------------------------------------
133
+
134
+ test("parseFlags handles a full mixed spec end-to-end", () => {
135
+ const spec = {
136
+ flags: {
137
+ "--workflows-dir": { type: "string", dest: "workflowsDir", default: ".github/workflows" },
138
+ "--actions-dir": { type: "string", dest: "actionsDir", default: ".github/actions" },
139
+ "--first-party-owner": { type: "string", dest: "firstPartyOwner", default: "dsj1984/mandrel-platform" },
140
+ "--no-single-pin": { type: "boolean", dest: "singlePin", value: false, default: true },
141
+ },
142
+ onUnknown: "throw",
143
+ };
144
+ const opts = parseFlags(
145
+ ["--first-party-owner", "x/y", "--workflows-dir", "wf", "--no-single-pin"],
146
+ spec
147
+ );
148
+ assert.equal(opts.firstPartyOwner, "x/y");
149
+ assert.equal(opts.workflowsDir, "wf");
150
+ assert.equal(opts.actionsDir, ".github/actions");
151
+ assert.equal(opts.singlePin, false);
152
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * scripts/lib/gh-json.mjs
3
+ *
4
+ * The single GitHub-access seam shared by the pin-drift dashboard
5
+ * (`check-pin-drift.mjs`) and the pin-repair loop (`platform-repair.mjs`).
6
+ * Extracted from `check-pin-drift.mjs` (Story #198) so both consumers run
7
+ * `gh api` through one thin, injectable runner and one JSON parser.
8
+ *
9
+ * GitHub access is via the `gh` CLI (`gh api`), so callers inherit the
10
+ * environment's auth (a `GH_TOKEN`/`GITHUB_TOKEN` in CI, or `gh auth`
11
+ * locally). No secrets are read or printed here.
12
+ *
13
+ * ## Fail-closed HTTP-status surfacing (Story #198)
14
+ *
15
+ * `gh api` exits non-zero on any HTTP error and writes a line like
16
+ * `gh: Not Found (HTTP 404)` to stderr. `execFileSync` turns that into a
17
+ * thrown Error whose `.stderr` / `.message` carries the `(HTTP <code>)`
18
+ * marker. Historically the per-consumer fetchers in the dashboard caught
19
+ * *every* such error and returned an "absent" sentinel — so a transient 500,
20
+ * a network blip, or an auth failure all read as "no drift" and the
21
+ * `--strict` gate silently exited 0 (a fail-OPEN). That is the bug this
22
+ * module exists to close.
23
+ *
24
+ * `httpStatusOf(err)` parses the HTTP status back out of a thrown `gh` error,
25
+ * and `isNotFound(err)` is the ONLY predicate a fetcher may use to justify
26
+ * swallowing an error into an "absent" result. Every other error must
27
+ * propagate so the caller's per-consumer catch records an `error` row (which
28
+ * `hasDrift` counts and `--strict` fails on) — the gate fails CLOSED.
29
+ */
30
+
31
+ import { execFileSync } from "node:child_process";
32
+
33
+ /**
34
+ * Default gh runner — shells out to the `gh` CLI.
35
+ *
36
+ * @param {string[]} args
37
+ * @returns {string}
38
+ */
39
+ export function defaultGhRunner(args) {
40
+ return execFileSync("gh", args, {
41
+ encoding: "utf-8",
42
+ maxBuffer: 32 * 1024 * 1024,
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Extract the HTTP status code from an error thrown by a `gh api` invocation.
48
+ * `gh` reports failures as `gh: <message> (HTTP <code>)` on stderr, which
49
+ * `execFileSync` surfaces on the thrown error's `.stderr` (a Buffer/string)
50
+ * and, for some shells, folded into `.message`. Also handles a structured
51
+ * error that already carries a numeric `.httpStatus` / `.status` HTTP field.
52
+ *
53
+ * @param {unknown} err
54
+ * @returns {number | null} The HTTP status, or null when none can be parsed.
55
+ */
56
+ export function httpStatusOf(err) {
57
+ if (!err || typeof err !== "object") return null;
58
+ // A pre-tagged HTTP status wins (set by ghApiJson on rethrow, or by a caller
59
+ // that already classified the error). Guard against `execFileSync`'s own
60
+ // numeric `.status` (that is a PROCESS exit code, not an HTTP status), so we
61
+ // only trust an explicit `.httpStatus`.
62
+ const tagged = /** @type {{ httpStatus?: unknown }} */ (err).httpStatus;
63
+ if (typeof tagged === "number" && Number.isInteger(tagged)) return tagged;
64
+
65
+ const parts = [];
66
+ const e = /** @type {{ stderr?: unknown, stdout?: unknown, message?: unknown }} */ (err);
67
+ if (e.stderr != null) parts.push(String(e.stderr));
68
+ if (e.stdout != null) parts.push(String(e.stdout));
69
+ if (typeof e.message === "string") parts.push(e.message);
70
+ const haystack = parts.join("\n");
71
+ // `gh` form: "(HTTP 404)". REST body form: "\"status\":\"404\"".
72
+ const m = /\(HTTP\s+(\d{3})\)/.exec(haystack) || /"status"\s*:\s*"(\d{3})"/.exec(haystack);
73
+ return m ? Number.parseInt(m[1], 10) : null;
74
+ }
75
+
76
+ /**
77
+ * Is `err` a GitHub 404 (Not Found)? This is the ONLY error class a fetcher
78
+ * may legitimately swallow into an "absent" sentinel — a 404 genuinely means
79
+ * "this resource does not exist" (a consumer with no `package.json`, no
80
+ * `.github/workflows/` dir, no release). Every other status (403, 429, 5xx,
81
+ * or an unparseable transport failure) must fail CLOSED.
82
+ *
83
+ * @param {unknown} err
84
+ * @returns {boolean}
85
+ */
86
+ export function isNotFound(err) {
87
+ return httpStatusOf(err) === 404;
88
+ }
89
+
90
+ /**
91
+ * Run `gh api <path>` and parse the JSON response. On failure, the thrown
92
+ * error is re-thrown with its HTTP status tagged on `.httpStatus` (parsed via
93
+ * `httpStatusOf`) so downstream `catch` blocks can distinguish a 404 (safe to
94
+ * treat as "absent") from every other error (must fail closed). The status is
95
+ * surfaced, never swallowed here — the swallow/rethrow policy lives in the
96
+ * per-consumer fetchers.
97
+ *
98
+ * @param {string} apiPath e.g. "repos/owner/repo/releases/latest".
99
+ * @param {(args: string[]) => string} runGh Injectable runner.
100
+ * @returns {unknown}
101
+ */
102
+ export function ghApiJson(apiPath, runGh) {
103
+ let raw;
104
+ try {
105
+ raw = runGh(["api", apiPath, "-H", "Accept: application/vnd.github+json"]);
106
+ } catch (err) {
107
+ const status = httpStatusOf(err);
108
+ if (status !== null && err && typeof err === "object" && !("httpStatus" in err)) {
109
+ try {
110
+ /** @type {{ httpStatus?: number }} */ (err).httpStatus = status;
111
+ } catch {
112
+ // Non-extensible error object — the parseable status still lives in
113
+ // `.stderr`/`.message`, so httpStatusOf(err) recovers it downstream.
114
+ }
115
+ }
116
+ throw err;
117
+ }
118
+ return JSON.parse(raw);
119
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * scripts/lib/semver-duration.mjs
3
+ *
4
+ * The semver + Renovate-duration parsers shared by the pin-drift dashboard
5
+ * (`check-pin-drift.mjs`) and the pin-repair loop (`platform-repair.mjs`).
6
+ * Extracted from `check-pin-drift.mjs` (Story #198) so both consumers parse
7
+ * release tags, dependency specs, and `minimumReleaseAge` windows through one
8
+ * SSOT rather than a monolith import.
9
+ *
10
+ * These are pure functions — no I/O, no GitHub access.
11
+ */
12
+
13
+ /**
14
+ * Extract a comparable `x.y.z` semver core from a release tag or version spec.
15
+ * The platform tags releases as `mandrel-platform-v<semver>`; consumer specs
16
+ * may carry a range prefix (`^0.11.3`, `~0.11.3`). Returns the dotted triple
17
+ * or null when no numeric semver core is present (`workspace:*`, `latest`, a
18
+ * git URL).
19
+ *
20
+ * @param {unknown} value
21
+ * @returns {string | null}
22
+ */
23
+ export function parseSemver(value) {
24
+ if (typeof value !== "string") return null;
25
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(value);
26
+ return m ? `${m[1]}.${m[2]}.${m[3]}` : null;
27
+ }
28
+
29
+ /**
30
+ * Compare two `x.y.z` semver cores. Returns -1 when a < b, 0 when equal, 1
31
+ * when a > b. Inputs MUST already be normalized dotted triples (see
32
+ * `parseSemver`).
33
+ *
34
+ * @param {string} a
35
+ * @param {string} b
36
+ * @returns {-1 | 0 | 1}
37
+ */
38
+ export function compareSemver(a, b) {
39
+ const pa = a.split(".").map(Number);
40
+ const pb = b.split(".").map(Number);
41
+ for (let i = 0; i < 3; i += 1) {
42
+ if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
43
+ }
44
+ return 0;
45
+ }
46
+
47
+ /**
48
+ * Parse a Renovate-style `minimumReleaseAge` duration into milliseconds. The
49
+ * preset uses human strings like `"3 days"`, `"36 hours"`, `"1 week"`; this
50
+ * accepts an integer (or float) count followed by a unit (the same units
51
+ * Renovate's `ms`-backed parser accepts). Returns null for an unparseable or
52
+ * non-positive value so the caller can fall back to "no hold window".
53
+ *
54
+ * @param {unknown} value
55
+ * @returns {number | null} Window length in ms, or null.
56
+ */
57
+ export function parseDurationMs(value) {
58
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
59
+ // Bare number is interpreted as days (the preset's unit of record).
60
+ return value * 24 * 60 * 60 * 1000;
61
+ }
62
+ if (typeof value !== "string") return null;
63
+ const m = /^\s*(\d+(?:\.\d+)?)\s*([a-z]+)\s*$/i.exec(value.trim());
64
+ if (!m) return null;
65
+ const count = Number.parseFloat(m[1]);
66
+ if (!Number.isFinite(count) || count <= 0) return null;
67
+ const unit = m[2].toLowerCase();
68
+ const units = {
69
+ minute: 60 * 1000,
70
+ minutes: 60 * 1000,
71
+ min: 60 * 1000,
72
+ mins: 60 * 1000,
73
+ hour: 60 * 60 * 1000,
74
+ hours: 60 * 60 * 1000,
75
+ hr: 60 * 60 * 1000,
76
+ hrs: 60 * 60 * 1000,
77
+ day: 24 * 60 * 60 * 1000,
78
+ days: 24 * 60 * 60 * 1000,
79
+ week: 7 * 24 * 60 * 60 * 1000,
80
+ weeks: 7 * 24 * 60 * 60 * 1000,
81
+ };
82
+ const factor = units[unit];
83
+ return factor ? count * factor : null;
84
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * scripts/lib/uses-pins.mjs
3
+ *
4
+ * The single home for `uses:`-line parsing, reference classification, and
5
+ * 40-hex-SHA validation shared by the pin-tooling scripts. These primitives
6
+ * had been copy-pasted into `check-action-pins.mjs` (the SHA-pin ratchet) and
7
+ * partially re-implemented as ad-hoc regexes in `check-workflow-portability.mjs`
8
+ * (the internal-pin collector) — the exact drift Story #203 consolidates.
9
+ *
10
+ * Nothing here reads the filesystem or the environment: every function is a
11
+ * pure text transform, so the sibling `uses-pins.test.mjs` runs fully offline.
12
+ *
13
+ * ## Vocabulary
14
+ *
15
+ * • A raw `uses:` VALUE is whatever follows `uses:` on a workflow/action
16
+ * line, possibly quoted and possibly carrying a trailing `# vX.Y.Z` tag
17
+ * note. {@link stripUsesValue} reduces it to the bare reference.
18
+ * • A bare REFERENCE is `owner/repo[/subpath]@gitref`, `./local/path`, or
19
+ * `docker://image`. {@link classifyUses} sorts it into first-party /
20
+ * third-party / local / docker / unparseable.
21
+ * • A git REF is a full 40-char commit SHA, a tag, a branch, or a short SHA.
22
+ * {@link isSha40} is the SHA-pin predicate the ratchet enforces.
23
+ *
24
+ * ## Single-pin invariant (Story #203)
25
+ *
26
+ * {@link findSinglePinViolations} adds an INTRA-repo check absent from the
27
+ * cross-repo pin-drift dashboard: within one repo's `.github/workflows/`, two
28
+ * first-party `uses:` refs to the SAME subpath MUST carry the SAME SHA. Two
29
+ * workflows pinning `owner/repo/.github/actions/foo` at different commits is a
30
+ * silent split-brain — one workflow runs the fixed action, the other the
31
+ * stale one. This check makes that state fail CI.
32
+ */
33
+
34
+ const DEFAULT_FIRST_PARTY_OWNER = "dsj1984/mandrel-platform";
35
+
36
+ /** A full git commit SHA is exactly 40 lowercase/uppercase hex characters. */
37
+ const SHA40_RE = /^[0-9a-fA-F]{40}$/;
38
+
39
+ /**
40
+ * Matches a YAML `uses:` mapping key: optional leading whitespace, an optional
41
+ * leading `- ` (sequence item), then `uses:` and the value. A `uses:` inside a
42
+ * `#` comment or a `run:` heredoc is indented past a leading `#`, so anchoring
43
+ * on the leading token avoids false hits on documentation examples.
44
+ */
45
+ const USES_LINE_RE = /^\s*(?:-\s+)?uses:\s*(\S.*)$/;
46
+
47
+ export { DEFAULT_FIRST_PARTY_OWNER, SHA40_RE, USES_LINE_RE };
48
+
49
+ /**
50
+ * Strip a trailing `# comment` (the conventional `# v4.2.2` tag note) and
51
+ * surrounding whitespace/quotes from a raw `uses:` value, returning the bare
52
+ * action reference. A `#` inside the ref itself is not valid GitHub syntax,
53
+ * so splitting on the first ` #` is safe.
54
+ *
55
+ * @param {string} raw
56
+ * @returns {string}
57
+ */
58
+ export function stripUsesValue(raw) {
59
+ let v = String(raw).trim();
60
+ // Drop a trailing comment: the first '#' that is preceded by whitespace (or
61
+ // at the start) begins a comment. GitHub action refs never contain '#'.
62
+ const hashIdx = v.search(/\s#/);
63
+ if (hashIdx !== -1) v = v.slice(0, hashIdx);
64
+ v = v.trim();
65
+ // Unwrap matched surrounding quotes.
66
+ if (
67
+ (v.startsWith('"') && v.endsWith('"')) ||
68
+ (v.startsWith("'") && v.endsWith("'"))
69
+ ) {
70
+ v = v.slice(1, -1).trim();
71
+ }
72
+ return v;
73
+ }
74
+
75
+ /**
76
+ * If a line is a YAML `uses:` mapping key, return its bare reference (comment
77
+ * and quotes stripped); otherwise return null. Whole-line `#` comments never
78
+ * match.
79
+ *
80
+ * @param {string} line
81
+ * @returns {string | null}
82
+ */
83
+ export function parseUsesLine(line) {
84
+ const raw = String(line);
85
+ if (/^\s*#/.test(raw)) return null;
86
+ const m = raw.match(USES_LINE_RE);
87
+ if (!m) return null;
88
+ return stripUsesValue(m[1]);
89
+ }
90
+
91
+ /**
92
+ * Classify a bare `uses:` reference. Returns one of:
93
+ * { kind: 'local' } — `./path` or `../path` (exempt)
94
+ * { kind: 'docker' } — `docker://image` (exempt)
95
+ * { kind: 'first-party', owner, subpath, ref } — the configured first-party
96
+ * owner (exempt from the SHA ratchet)
97
+ * { kind: 'third-party', owner, subpath, ref } — external action (MUST be
98
+ * SHA-pinned)
99
+ * { kind: 'unparseable' } — not a recognizable `uses:` reference
100
+ *
101
+ * `subpath` is the path segment after `owner/repo/` (empty string when the ref
102
+ * is the bare `owner/repo`), so single-pin comparison can key on it.
103
+ *
104
+ * @param {string} bareRef
105
+ * @param {string} [firstPartyOwner]
106
+ * @returns {{kind: string, owner?: string, subpath?: string, ref?: string, ownerRepoPath?: string}}
107
+ */
108
+ export function classifyUses(bareRef, firstPartyOwner = DEFAULT_FIRST_PARTY_OWNER) {
109
+ const ref = String(bareRef).trim();
110
+ if (ref === "") return { kind: "unparseable" };
111
+ if (ref.startsWith("./") || ref.startsWith("../")) return { kind: "local" };
112
+ if (ref.startsWith("docker://")) return { kind: "docker" };
113
+
114
+ // owner/repo[/subpath]@gitref. The git ref is everything after the LAST '@'
115
+ // (an action subpath never contains '@'; the ref does not either).
116
+ const atIdx = ref.lastIndexOf("@");
117
+ if (atIdx === -1) {
118
+ // No `@ref` at all — not a pinnable external reference (e.g. a malformed
119
+ // entry). Treat as unparseable so the caller can flag it explicitly.
120
+ return { kind: "unparseable", ownerRepoPath: ref };
121
+ }
122
+ const ownerRepoPath = ref.slice(0, atIdx);
123
+ const gitRef = ref.slice(atIdx + 1);
124
+ const segments = ownerRepoPath.split("/");
125
+ if (segments.length < 2) return { kind: "unparseable", ownerRepoPath, ref: gitRef };
126
+
127
+ const ownerRepo = `${segments[0]}/${segments[1]}`;
128
+ const subpath = segments.slice(2).join("/");
129
+ if (ownerRepo.toLowerCase() === String(firstPartyOwner).toLowerCase()) {
130
+ return { kind: "first-party", owner: ownerRepo, subpath, ref: gitRef };
131
+ }
132
+ return { kind: "third-party", owner: ownerRepo, subpath, ref: gitRef };
133
+ }
134
+
135
+ /**
136
+ * True when a git ref is a full 40-character commit SHA.
137
+ *
138
+ * @param {string} gitRef
139
+ * @returns {boolean}
140
+ */
141
+ export function isSha40(gitRef) {
142
+ return SHA40_RE.test(String(gitRef).trim());
143
+ }
144
+
145
+ /**
146
+ * Scan a file's TEXT for first-party `uses:` refs and index each by its
147
+ * `owner/repo/subpath` reference target, capturing the SHA (or non-SHA ref)
148
+ * each site pins. Returns a Map keyed by `owner/repo` + `/subpath` (the full
149
+ * reference minus `@ref`), each value an array of
150
+ * `{ file, line, ref, target }` occurrences. Only `first-party` refs with a
151
+ * non-empty subpath are collected — a bare `owner/repo@ref` self-reference has
152
+ * no subpath to disambiguate, and third-party refs are governed by the
153
+ * cross-repo pin-drift dashboard, not the intra-repo single-pin invariant.
154
+ *
155
+ * @param {string} content
156
+ * @param {string} displayFile
157
+ * @param {string} [firstPartyOwner]
158
+ * @returns {Map<string, Array<{file: string, line: number, ref: string, target: string}>>}
159
+ */
160
+ export function collectFirstPartyPins(
161
+ content,
162
+ displayFile,
163
+ firstPartyOwner = DEFAULT_FIRST_PARTY_OWNER
164
+ ) {
165
+ const byTarget = new Map();
166
+ const lines = String(content).split(/\r?\n/);
167
+ for (let i = 0; i < lines.length; i++) {
168
+ const bareRef = parseUsesLine(lines[i]);
169
+ if (bareRef === null) continue;
170
+ const cls = classifyUses(bareRef, firstPartyOwner);
171
+ if (cls.kind !== "first-party") continue;
172
+ if (!cls.subpath) continue; // bare owner/repo self-ref has no subpath to key on
173
+ const target = `${cls.owner}/${cls.subpath}`;
174
+ const occ = { file: displayFile, line: i + 1, ref: cls.ref, target };
175
+ const existing = byTarget.get(target);
176
+ if (existing) existing.push(occ);
177
+ else byTarget.set(target, [occ]);
178
+ }
179
+ return byTarget;
180
+ }
181
+
182
+ /**
183
+ * The single-pin invariant (Story #203). Given a list of `{ file, content }`
184
+ * records (the repo's workflow files), find every first-party `uses:` target
185
+ * that is pinned to MORE THAN ONE distinct SHA across the set. Returns an
186
+ * array of violations, one per drifting target:
187
+ *
188
+ * { target, shas: [...distinct refs], occurrences: [{ file, line, ref }] }
189
+ *
190
+ * A target pinned consistently (or referenced only once) yields no violation.
191
+ *
192
+ * @param {Array<{file: string, content: string}>} files
193
+ * @param {string} [firstPartyOwner]
194
+ * @returns {Array<{target: string, shas: string[], occurrences: Array<{file: string, line: number, ref: string}>}>}
195
+ */
196
+ export function findSinglePinViolations(files, firstPartyOwner = DEFAULT_FIRST_PARTY_OWNER) {
197
+ // Merge every file's per-target occurrences into one index.
198
+ const merged = new Map();
199
+ for (const { file, content } of files) {
200
+ const perFile = collectFirstPartyPins(content, file, firstPartyOwner);
201
+ for (const [target, occs] of perFile) {
202
+ const existing = merged.get(target);
203
+ if (existing) existing.push(...occs);
204
+ else merged.set(target, [...occs]);
205
+ }
206
+ }
207
+
208
+ const violations = [];
209
+ for (const [target, occs] of merged) {
210
+ const distinct = [...new Set(occs.map((o) => o.ref))];
211
+ if (distinct.length > 1) {
212
+ violations.push({
213
+ target,
214
+ shas: distinct,
215
+ occurrences: occs.map((o) => ({ file: o.file, line: o.line, ref: o.ref })),
216
+ });
217
+ }
218
+ }
219
+ return violations;
220
+ }