mandrel-platform 1.1.0 → 1.3.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.
@@ -30,7 +30,7 @@
30
30
 
31
31
  import assert from "node:assert/strict";
32
32
  import { test } from "node:test";
33
- import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
33
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
34
34
  import { tmpdir } from "node:os";
35
35
  import { join } from "node:path";
36
36
 
@@ -43,6 +43,9 @@ import {
43
43
  parseArgs,
44
44
  loadAllowlist,
45
45
  runCli,
46
+ isBoundedOverride,
47
+ findUnboundedOverrides,
48
+ lintOverrides,
46
49
  } from "./audit-check.mjs";
47
50
 
48
51
  const TODAY = "2026-07-02";
@@ -456,3 +459,381 @@ test("loadAllowlist: non-array throws", () => {
456
459
  rmSync(dir, { recursive: true, force: true });
457
460
  }
458
461
  });
462
+
463
+ // ---------------------------------------------------------------------------
464
+ // Unbounded dependency overrides (Story #365)
465
+ //
466
+ // The failure these close: nothing lints for an unbounded override. Written as
467
+ // a bare lower bound it rewrites a dependent's range open-endedly, leaving the
468
+ // committed lockfile as the only pin — so any fresh resolution re-picks the
469
+ // newest release and can cross a major. Two consumers derived this rule
470
+ // independently after a major jump silently emptied a test suite, and both
471
+ // then found further already-escaped overrides.
472
+ // ---------------------------------------------------------------------------
473
+
474
+ test("isBoundedOverride: a bare lower bound is unbounded; a capped range is not", () => {
475
+ for (const unbounded of [">=1.2.3", "> 1.2.3", ">=0", "*", "x", "latest", "", " "]) {
476
+ assert.equal(isBoundedOverride(unbounded), false, `${JSON.stringify(unbounded)} is unbounded`);
477
+ }
478
+ for (const bounded of ["1.2.3", "^1.2.3", "~1.2.3", "1.2.x", ">=1.2.3 <2.0.0", "<2.0.0"]) {
479
+ assert.equal(isBoundedOverride(bounded), true, `${JSON.stringify(bounded)} is bounded`);
480
+ }
481
+ });
482
+
483
+ // The wildcard truth table (Story #375).
484
+ //
485
+ // The shipped regex enumerated only some wildcard spellings, so `x.x.x` and
486
+ // `x.x` — the exact shape the lint exists to catch, and what npm reads as
487
+ // plain `*` — fell through the catch-all and were reported bounded. The
488
+ // deciding question is the MAJOR position: a wildcard there is open to every
489
+ // future release, while a wildcard below it (`1.2.x`, `1.*`) stays inside its
490
+ // major and is genuinely bounded. Every spelling is enumerated so the next
491
+ // regex tweak has to keep answering all of them.
492
+ const WILDCARD_TRUTH_TABLE = [
493
+ ["*", false],
494
+ ["x", false],
495
+ ["X", false],
496
+ ["*.*", false],
497
+ ["x.x", false],
498
+ ["X.X", false],
499
+ ["*.*.*", false],
500
+ ["x.x.x", false],
501
+ ["X.X.X", false],
502
+ ["x.*", false],
503
+ ["*.x.x", false],
504
+ ["x.2.3", false],
505
+ ["*.x", false],
506
+ ["latest", false],
507
+ ["next", false],
508
+ ["LATEST", false],
509
+ ["NEXT", false],
510
+ ["1.x", true],
511
+ ["1.X", true],
512
+ ["1.*", true],
513
+ ["1.2.x", true],
514
+ ["1.2.*", true],
515
+ ];
516
+
517
+ test("AC-1/AC-4: every wildcard spelling is judged on its major position", () => {
518
+ for (const [spec, expected] of WILDCARD_TRUTH_TABLE) {
519
+ assert.equal(
520
+ isBoundedOverride(spec),
521
+ expected,
522
+ `${JSON.stringify(spec)} should be bounded=${expected}`,
523
+ );
524
+ }
525
+ });
526
+
527
+ test("AC-2: a non-registry specifier expresses no upper bound", () => {
528
+ // Each of these re-resolves to whatever the source holds at install time —
529
+ // a branch head, a workspace sibling, a path — so none of them is a range
530
+ // the lint can call bounded.
531
+ for (const spec of [
532
+ "github:owner/repo",
533
+ "github:owner/repo#v1.2.3",
534
+ "git+https://github.com/owner/repo.git",
535
+ "git+ssh://git@github.com/owner/repo.git#main",
536
+ "git://github.com/owner/repo.git",
537
+ "workspace:*",
538
+ "workspace:^",
539
+ "file:../local-pkg",
540
+ "link:../local-pkg",
541
+ "https://example.com/pkg-1.2.3.tgz",
542
+ ]) {
543
+ assert.equal(
544
+ isBoundedOverride(spec),
545
+ false,
546
+ `${JSON.stringify(spec)} pins nothing`,
547
+ );
548
+ }
549
+ });
550
+
551
+ test("AC-2: a git specifier carrying #semver: is judged on that range", () => {
552
+ assert.equal(
553
+ isBoundedOverride("git+https://github.com/owner/repo.git#semver:^1.2.3"),
554
+ true,
555
+ );
556
+ assert.equal(isBoundedOverride("github:owner/repo#semver:>=1.2.3"), false);
557
+ });
558
+
559
+ test("AC-3: a range carrying an explicit upper bound stays bounded", () => {
560
+ for (const bounded of [
561
+ "1.2.3",
562
+ "=1.2.3",
563
+ "^1.2.3",
564
+ "~1.2.3",
565
+ ">=1.2.3 <2.0.0",
566
+ "<2.0.0",
567
+ "1.2.3 - 2.0.0",
568
+ ]) {
569
+ assert.equal(
570
+ isBoundedOverride(bounded),
571
+ true,
572
+ `${JSON.stringify(bounded)} is bounded`,
573
+ );
574
+ }
575
+ });
576
+
577
+ test("AC-3: a wildcard LOWER end still counts as bounded when the range caps it", () => {
578
+ // The wildcard test asks about the major position, so it may only be applied
579
+ // to a single bare token. Applied to a compound range it read the first
580
+ // dot-segment of the whole string and called `x.x <2.0.0` unbounded — a
581
+ // range that plainly carries an upper bound — while the equivalent
582
+ // `* <2.0.0` passed, because its first segment is `* <2`. That
583
+ // self-inconsistency is the tell: it was an artifact of check ordering.
584
+ for (const bounded of [
585
+ "x.x <2.0.0",
586
+ "x.x.x <2.0.0",
587
+ "* <2.0.0",
588
+ "x.x.x - 2.0.0",
589
+ "x - 2.0.0",
590
+ "* - 2.0.0",
591
+ ]) {
592
+ assert.equal(
593
+ isBoundedOverride(bounded),
594
+ true,
595
+ `${JSON.stringify(bounded)} is bounded`,
596
+ );
597
+ }
598
+ });
599
+
600
+ // Residual fail-opens left open by the dotted-wildcard fix. Both classes reach
601
+ // the catch-all `return true` and so read as bounded while pinning nothing.
602
+ //
603
+ // Class 1 — an operator in front of the wildcard. The wildcard test is gated on
604
+ // a single BARE token, so any leading range operator defeats it: `^x.x.x` is
605
+ // `^` applied to "any version", which is still any version.
606
+ const OPERATOR_PREFIXED_WILDCARDS = [
607
+ ["^x.x.x", false],
608
+ ["^x.x", false],
609
+ ["^x", false],
610
+ ["^*", false],
611
+ ["~*", false],
612
+ ["~x.x.x", false],
613
+ ["=x.x.x", false],
614
+ ["=*", false],
615
+ ["=X.X", false],
616
+ ["^latest", false],
617
+ // The same operators over a real version are untouched — this is the
618
+ // near-miss the fix must not break.
619
+ ["^1.2.3", true],
620
+ ["~1.2.3", true],
621
+ ["=1.2.3", true],
622
+ ["^1.x", true],
623
+ ["~1.2.x", true],
624
+ ];
625
+
626
+ test("operator-prefixed wildcards are unbounded, real versions unaffected", () => {
627
+ for (const [spec, expected] of OPERATOR_PREFIXED_WILDCARDS) {
628
+ assert.equal(
629
+ isBoundedOverride(spec),
630
+ expected,
631
+ `${JSON.stringify(spec)} should be bounded=${expected}`,
632
+ );
633
+ }
634
+ });
635
+
636
+ test("a compound whose every term is a wildcard or bare lower bound is unbounded", () => {
637
+ // Class 2 — a wildcard lower with no upper bound anywhere. `x.x >=1.0.0`
638
+ // reads as a range only because it is spelled like one; both terms are open
639
+ // above, so the resolved set is still "every future release".
640
+ for (const unbounded of [
641
+ "x.x >=1.0.0",
642
+ "x.x.x >=1.0.0",
643
+ "* >=1.0.0",
644
+ "x.x >1.0.0",
645
+ ">=1.0.0 x.x",
646
+ "x.x - x.x",
647
+ "x.x.x - *",
648
+ "* - x",
649
+ "1.0.0 - x.x.x",
650
+ ]) {
651
+ assert.equal(
652
+ isBoundedOverride(unbounded),
653
+ false,
654
+ `${JSON.stringify(unbounded)} carries no upper bound`,
655
+ );
656
+ }
657
+ });
658
+
659
+ test("a malformed hyphen range carrying a comparator still fails closed", () => {
660
+ // A real hyphen range takes plain versions on both sides. Reading the
661
+ // right-hand side of `>=1.0.0 - 2.0.0` as the cap answers a range npm never
662
+ // agreed to parse, and turns a spec that failed closed into one that passes
663
+ // — the exact fail-open direction this lint exists to prevent.
664
+ for (const unbounded of [">=1.0.0 - 2.0.0", ">=1.0.0 - ^2.0.0", ">1.0.0 - 2.0.0"]) {
665
+ assert.equal(
666
+ isBoundedOverride(unbounded),
667
+ false,
668
+ `${JSON.stringify(unbounded)} is not a hyphen range and must fail closed`,
669
+ );
670
+ }
671
+ });
672
+
673
+ test("a real upper bound still closes a range with a wildcard lower", () => {
674
+ // The regression guard for the trap the dotted-wildcard fix already hit once:
675
+ // testing the wildcard against the whole specifier called these unbounded.
676
+ // An upper bound is an upper bound regardless of how loose the lower end is.
677
+ for (const bounded of [
678
+ "x.x <2.0.0",
679
+ "x.x.x - 2.0.0",
680
+ "* <=2.0.0",
681
+ "x.x >=1.0.0 <2.0.0",
682
+ "^x.x.x - 2.0.0",
683
+ ]) {
684
+ assert.equal(
685
+ isBoundedOverride(bounded),
686
+ true,
687
+ `${JSON.stringify(bounded)} carries a real upper bound`,
688
+ );
689
+ }
690
+ });
691
+
692
+ test("isBoundedOverride: a || union is only as bounded as its loosest arm", () => {
693
+ assert.equal(isBoundedOverride("^1.0.0 || ^2.0.0"), true);
694
+ assert.equal(isBoundedOverride("^1.0.0 || >=2.0.0"), false);
695
+ });
696
+
697
+ test("isBoundedOverride: an npm: alias is judged on the range it carries", () => {
698
+ assert.equal(isBoundedOverride("npm:other-pkg@^1.2.3"), true);
699
+ assert.equal(isBoundedOverride("npm:@scope/other@^1.2.3"), true);
700
+ assert.equal(isBoundedOverride("npm:other-pkg@>=1.2.3"), false);
701
+ // An alias with no range at all pins nothing.
702
+ assert.equal(isBoundedOverride("npm:other-pkg"), false);
703
+ });
704
+
705
+ test("AC-5: findUnboundedOverrides names the package and the bound", () => {
706
+ const findings = findUnboundedOverrides({
707
+ pnpm: { overrides: { "left-pad": ">=1.3.0", semver: "^7.5.2" } },
708
+ });
709
+ assert.equal(findings.length, 1);
710
+ assert.equal(findings[0].package, "left-pad");
711
+ assert.equal(findings[0].bound, ">=1.3.0");
712
+ assert.equal(findings[0].field, "pnpm.overrides");
713
+ });
714
+
715
+ test("AC-5: every override field is checked, npm and pnpm and yarn alike", () => {
716
+ const findings = findUnboundedOverrides({
717
+ overrides: { a: ">=1.0.0" },
718
+ resolutions: { b: "*" },
719
+ pnpm: { overrides: { c: ">2" } },
720
+ });
721
+ assert.deepEqual(
722
+ findings.map((f) => `${f.field}.${f.package}`).sort(),
723
+ ["overrides.a", "pnpm.overrides.c", "resolutions.b"],
724
+ );
725
+ });
726
+
727
+ test("AC-5: a nested (dependent-scoped) override is named by its full path", () => {
728
+ const findings = findUnboundedOverrides({
729
+ overrides: { "some-dep": { "left-pad": ">=1.3.0" } },
730
+ });
731
+ assert.equal(findings.length, 1);
732
+ assert.equal(findings[0].package, "some-dep.left-pad");
733
+ assert.equal(findings[0].bound, ">=1.3.0");
734
+ });
735
+
736
+ test("AC-6: an override that pins a bounded range passes", () => {
737
+ assert.deepEqual(
738
+ findUnboundedOverrides({
739
+ overrides: { a: "^1.2.3", b: "~2.0.0", c: "3.1.4", d: ">=1.0.0 <2.0.0" },
740
+ pnpm: { overrides: { e: "1.2.x" } },
741
+ }),
742
+ [],
743
+ );
744
+ });
745
+
746
+ test("findUnboundedOverrides: a package.json with no overrides at all is clean", () => {
747
+ assert.deepEqual(findUnboundedOverrides({ name: "x", dependencies: { a: ">=1.0.0" } }), []);
748
+ assert.deepEqual(findUnboundedOverrides(null), []);
749
+ assert.deepEqual(findUnboundedOverrides("not-an-object"), []);
750
+ });
751
+
752
+ test("AC-5: runCli fails on an unbounded override before pnpm ever runs", () => {
753
+ const dir = mkdtempSync(join(tmpdir(), "audit-check-override-"));
754
+ try {
755
+ const packageJsonPath = join(dir, "package.json");
756
+ writeFileSync(
757
+ packageJsonPath,
758
+ JSON.stringify({ name: "fixture", pnpm: { overrides: { "left-pad": ">=1.3.0" } } }),
759
+ );
760
+ // An absent allowlist keeps this case about the override and nothing else.
761
+ const exit = runCli([
762
+ "--package-json",
763
+ packageJsonPath,
764
+ "--allowlist",
765
+ join(dir, "no-such-allowlist.json"),
766
+ ]);
767
+ assert.equal(exit, 1);
768
+ } finally {
769
+ rmSync(dir, { recursive: true, force: true });
770
+ }
771
+ });
772
+
773
+ test("AC-6: the override gate passes a bounded override, reaching the audit", () => {
774
+ // Executes the clean path rather than asserting around it. lintOverrides is
775
+ // the gate runCli delegates to, split out precisely so a PASS is provable
776
+ // without `pnpm audit` (which needs a real lockfile and a network).
777
+ const dir = mkdtempSync(join(tmpdir(), "audit-check-bounded-"));
778
+ try {
779
+ const packageJsonPath = join(dir, "package.json");
780
+ writeFileSync(
781
+ packageJsonPath,
782
+ JSON.stringify({
783
+ name: "fixture",
784
+ overrides: { a: "^1.3.0", b: ">=1.0.0 <2.0.0" },
785
+ pnpm: { overrides: { "left-pad": "~1.3.0" } },
786
+ }),
787
+ );
788
+ assert.equal(lintOverrides(packageJsonPath), 0);
789
+ assert.equal(parseArgs(["--package-json", packageJsonPath]).packageJsonPath, packageJsonPath);
790
+ } finally {
791
+ rmSync(dir, { recursive: true, force: true });
792
+ }
793
+ });
794
+
795
+ test("AC-5: the override gate blocks an unbounded override", () => {
796
+ const dir = mkdtempSync(join(tmpdir(), "audit-check-lint-"));
797
+ try {
798
+ const packageJsonPath = join(dir, "package.json");
799
+ writeFileSync(packageJsonPath, JSON.stringify({ overrides: { "left-pad": ">=1.3.0" } }));
800
+ assert.equal(lintOverrides(packageJsonPath), 1);
801
+ } finally {
802
+ rmSync(dir, { recursive: true, force: true });
803
+ }
804
+ });
805
+
806
+ test("the override gate is a no-op when there is no package.json to read", () => {
807
+ assert.equal(lintOverrides(join(tmpdir(), "audit-check-absent-xyz", "package.json")), 0);
808
+ });
809
+
810
+ test("this repo's own package.json passes the override gate", () => {
811
+ // The lint ships enabled by default; a false positive here would red the
812
+ // platform's own required check on every PR.
813
+ assert.deepEqual(findUnboundedOverrides(JSON.parse(readFileSync("package.json", "utf8"))), []);
814
+ });
815
+
816
+ test("runCli: an unparseable package.json is a hard error, not a skipped check", () => {
817
+ const dir = mkdtempSync(join(tmpdir(), "audit-check-badpkg-"));
818
+ try {
819
+ const packageJsonPath = join(dir, "package.json");
820
+ writeFileSync(packageJsonPath, "{ not json");
821
+ assert.equal(
822
+ runCli([
823
+ "--package-json",
824
+ packageJsonPath,
825
+ "--allowlist",
826
+ join(dir, "no-such-allowlist.json"),
827
+ ]),
828
+ 1,
829
+ );
830
+ } finally {
831
+ rmSync(dir, { recursive: true, force: true });
832
+ }
833
+ });
834
+
835
+ test("parseArgs defaults the package.json path alongside the allowlist path", () => {
836
+ const { allowlistPath, packageJsonPath } = parseArgs([], "/tmp/proj");
837
+ assert.equal(allowlistPath, "/tmp/proj/audit-allowlist.json");
838
+ assert.equal(packageJsonPath, "/tmp/proj/package.json");
839
+ });
@@ -27,13 +27,29 @@
27
27
  * `pnpm/action-setup`.) A non-SHA ref (a tag like `v4`, a branch, a short
28
28
  * SHA) FAILS the lint.
29
29
  *
30
- * • FIRST-PARTY self-references — `dsj1984/mandrel-platform/...@<ref>` — are
31
- * EXEMPT from this ratchet. They are this repo's OWN reusable workflows /
32
- * composite actions, governed by the cross-repo portability lint's pin-lag
33
- * guard (`check-workflow-portability.mjs`, Rule 3), and they carry a
34
- * release-tag shape at publish time. The first-party owner is overridable
35
- * via `--first-party-owner` for a fork. They ARE, however, subject to the
36
- * single-pin invariant below.
30
+ * • FIRST-PARTY self-references — `dsj1984/mandrel-platform/...@<ref>` — MUST
31
+ * ALSO be a 40-char hex SHA, and are reported as their own violation class.
32
+ * They were exempt until Story #354's audit: the exemption's stated
33
+ * justification was that `check-workflow-portability.mjs` Rule 3 governs
34
+ * them, but Rule 3's `collectInternalPins` skips any ref that is not
35
+ * already a 40-hex SHA (`if (!isSha40(cls.ref)) return`), so a
36
+ * branch-pinned self-reference was validated by NOTHING. The two other
37
+ * first-party guards had the same hole — the single-pin invariant below
38
+ * compares whatever refs it finds without requiring a SHA, and
39
+ * `check-first-party-pin-freshness.mjs` files a non-SHA ref under an
40
+ * informational `unpinnedRefs` note that never fails. So
41
+ * `…/gitleaks-scan@main` was green on all three.
42
+ *
43
+ * A moving self-ref is the same supply-chain risk the third-party ratchet
44
+ * exists to close, with a wider blast radius: `pr-quality.yml` is inherited
45
+ * by every consumer. It is also invisible to `platform-sync.mjs`, whose
46
+ * rewrite regex matches `@[0-9a-fA-F]{40}` only — a branch-pinned consumer
47
+ * workflow is silently skipped on every platform bump.
48
+ *
49
+ * The land-then-bump flow is unaffected: it always pins full SHAs (see
50
+ * docs/reusable-workflows.md § First-party self-pin freshness). The
51
+ * first-party owner is overridable via `--first-party-owner` for a fork,
52
+ * and first-party refs remain subject to the single-pin invariant below.
37
53
  *
38
54
  * • LOCAL `./path` references and `docker://image` references are EXEMPT —
39
55
  * a local path has no upstream tag to move, and a docker ref is pinned by
@@ -125,10 +141,18 @@ export function parseArgs(argv) {
125
141
  // ---------------------------------------------------------------------------
126
142
 
127
143
  /**
128
- * Scan a single file's TEXT for `uses:` step keys and evaluate each third-party
129
- * reference. Returns { violations: [...], scanned: <count> }. A violation is
130
- * `{ file, line, ref, owner, reason }`. `file` is left as passed-in (the
131
- * caller supplies a display path).
144
+ * Scan a single file's TEXT for `uses:` step keys and evaluate every REMOTE
145
+ * reference against the 40-hex SHA ratchet. Returns
146
+ * `{ violations, scanned, firstPartyViolations, firstPartyScanned }` the two
147
+ * owner classes are counted and reported separately because their remediation
148
+ * differs (bump a vendored third-party pin vs. re-pin one of this repo's own
149
+ * call sites, which must move at every call site together to keep the
150
+ * single-pin invariant). A violation is `{ file, line, ref, owner, reason }`;
151
+ * `file` is left as passed-in (the caller supplies a display path).
152
+ *
153
+ * LOCAL (`./path`) and `docker://` references stay exempt: a local path has no
154
+ * upstream ref that can move, and a docker ref carries its own digest
155
+ * convention.
132
156
  *
133
157
  * Only lines whose first non-space token is `uses:` (a YAML mapping key) are
134
158
  * inspected — `uses:` appearing inside a comment or a `run:` heredoc never
@@ -138,13 +162,33 @@ export function parseArgs(argv) {
138
162
  */
139
163
  export function scanContent(content, displayFile, firstPartyOwner = DEFAULT_FIRST_PARTY_OWNER) {
140
164
  const violations = [];
165
+ const firstPartyViolations = [];
141
166
  let scanned = 0;
167
+ let firstPartyScanned = 0;
142
168
  const lines = String(content).split(/\r?\n/);
143
169
  for (let i = 0; i < lines.length; i++) {
144
170
  const bareRef = parseUsesLine(lines[i]);
145
171
  if (bareRef === null) continue;
146
172
  const cls = classifyUses(bareRef, firstPartyOwner);
147
- if (cls.kind !== "third-party") continue; // local/docker/first-party/unparseable → exempt
173
+
174
+ // First-party self-references (Story #354 audit). A bare `owner/repo@ref`
175
+ // carrying no subpath is ratcheted too: the hazard is the MOVING REF, and
176
+ // it moves whether or not the reference names a subpath.
177
+ if (cls.kind === "first-party") {
178
+ firstPartyScanned++;
179
+ if (!isSha40(cls.ref)) {
180
+ firstPartyViolations.push({
181
+ file: displayFile,
182
+ line: i + 1,
183
+ ref: bareRef,
184
+ owner: cls.owner,
185
+ reason: `first-party self-reference "${cls.owner}" is pinned to "${cls.ref}", not a full 40-char commit SHA`,
186
+ });
187
+ }
188
+ continue;
189
+ }
190
+
191
+ if (cls.kind !== "third-party") continue; // local/docker/unparseable → exempt
148
192
  scanned++;
149
193
  if (!isSha40(cls.ref)) {
150
194
  violations.push({
@@ -156,7 +200,7 @@ export function scanContent(content, displayFile, firstPartyOwner = DEFAULT_FIRS
156
200
  });
157
201
  }
158
202
  }
159
- return { violations, scanned };
203
+ return { violations, scanned, firstPartyViolations, firstPartyScanned };
160
204
  }
161
205
 
162
206
  // ---------------------------------------------------------------------------
@@ -178,7 +222,9 @@ export function runLint(opts) {
178
222
  const files = [...workflowFiles, ...listActionFiles(acDir)];
179
223
 
180
224
  const violations = [];
225
+ const firstPartyViolations = [];
181
226
  let scanned = 0;
227
+ let firstPartyScanned = 0;
182
228
  // Keep the raw workflow-file contents for the single-pin pass so we read
183
229
  // each file from disk once.
184
230
  const workflowRecords = [];
@@ -192,7 +238,9 @@ export function runLint(opts) {
192
238
  const display = relative(cwd, file) || file;
193
239
  const res = scanContent(content, display, opts.firstPartyOwner);
194
240
  violations.push(...res.violations);
241
+ firstPartyViolations.push(...res.firstPartyViolations);
195
242
  scanned += res.scanned;
243
+ firstPartyScanned += res.firstPartyScanned;
196
244
  if (workflowFiles.includes(file)) {
197
245
  workflowRecords.push({ file: display, content });
198
246
  }
@@ -204,9 +252,14 @@ export function runLint(opts) {
204
252
  : [];
205
253
 
206
254
  return {
207
- ok: violations.length === 0 && singlePinViolations.length === 0,
255
+ ok:
256
+ violations.length === 0 &&
257
+ firstPartyViolations.length === 0 &&
258
+ singlePinViolations.length === 0,
208
259
  violations,
260
+ firstPartyViolations,
209
261
  scanned,
262
+ firstPartyScanned,
210
263
  files,
211
264
  singlePinViolations,
212
265
  };
@@ -242,6 +295,24 @@ export function runCli(argv, { log = console.log, err = console.error } = {}) {
242
295
  );
243
296
  }
244
297
 
298
+ if (result.firstPartyViolations.length > 0) {
299
+ failed = true;
300
+ err(
301
+ `[action-pins] ❌ ${result.firstPartyViolations.length} first-party self-reference(s) pinned to a moving ref:`
302
+ );
303
+ for (const v of result.firstPartyViolations) {
304
+ err(` • ${v.file}:${v.line} — ${v.reason}`);
305
+ }
306
+ err(
307
+ "[action-pins] Pin every first-party `uses:` to a full 40-char commit SHA " +
308
+ "too (keep the `# vX.Y.Z` tag note as a comment). A branch or tag ref " +
309
+ "means the revision that runs can change with no diff here — and " +
310
+ "`pr-quality.yml` is inherited by every consumer. It is also invisible " +
311
+ "to platform-sync.mjs, whose rewrite matches 40-hex SHAs only, so it " +
312
+ "would be skipped on every platform bump."
313
+ );
314
+ }
315
+
245
316
  if (result.singlePinViolations.length > 0) {
246
317
  failed = true;
247
318
  err(
@@ -263,7 +334,8 @@ export function runCli(argv, { log = console.log, err = console.error } = {}) {
263
334
  if (failed) return 1;
264
335
 
265
336
  log(
266
- `[action-pins] ✅ all ${result.scanned} third-party action reference(s) are SHA-pinned ` +
337
+ `[action-pins] ✅ all ${result.scanned} third-party and ${result.firstPartyScanned} ` +
338
+ `first-party action reference(s) are SHA-pinned ` +
267
339
  `(${result.files.length} file(s) scanned); first-party single-pin invariant holds.`
268
340
  );
269
341
  return 0;