@event4u/agent-config 14.17.0 → 14.18.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.
Files changed (74) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +170 -79
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/pr/merge.md +35 -44
  7. package/dist/agent-src/skills/accessibility-auditor/SKILL.md +2 -0
  8. package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +27 -1
  9. package/dist/agent-src/skills/code-intelligence/SKILL.md +60 -12
  10. package/dist/agent-src/skills/complexity-first-planning/SKILL.md +27 -0
  11. package/dist/agent-src/skills/design-review/SKILL.md +2 -0
  12. package/dist/agent-src/skills/feature-planning/SKILL.md +34 -0
  13. package/dist/agent-src/skills/git-workflow/SKILL.md +37 -12
  14. package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +176 -0
  15. package/dist/agent-src/skills/laravel-mail/SKILL.md +44 -0
  16. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +40 -0
  17. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +12 -0
  18. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +53 -8
  19. package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +3 -1
  20. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/dist/cli-delegate/cmd_conformance.js +335 -659
  22. package/dist/discovery/deprecation-report.md +1 -1
  23. package/dist/discovery/discovery-manifest.json +15 -15
  24. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  25. package/dist/discovery/discovery-manifest.summary.md +1 -1
  26. package/dist/discovery/dormancy-report.md +1 -1
  27. package/dist/discovery/orphan-report.md +1 -1
  28. package/dist/discovery/packs.json +2 -2
  29. package/dist/discovery/trust-report.md +1 -1
  30. package/dist/discovery/workspaces.json +2 -2
  31. package/dist/hooks/dispatch.js +959 -1304
  32. package/dist/mcp/registry-manifest.json +2 -2
  33. package/dist/mcp/server.json +2 -2
  34. package/dist/mcp/server.mjs +671 -1002
  35. package/dist/shared/settingsConsent.js +1 -1
  36. package/package.json +1 -1
  37. package/src/config/conformance-claim-baseline.json +9 -0
  38. package/src/config/gate-violation-baselines.json +3 -3
  39. package/src/config/release-gate-locality.yml +129 -0
  40. package/src/scripts/_lib/release_highlights.ts +172 -1
  41. package/src/scripts/_lib/release_material.ts +66 -0
  42. package/src/scripts/_lib/repeated_run.ts +77 -0
  43. package/src/scripts/_lib/tamper_vocabulary.ts +242 -0
  44. package/src/scripts/_lib/unified_diff.ts +121 -0
  45. package/src/scripts/check_finding_dispositions.ts +179 -3
  46. package/src/scripts/check_installed_hooks_fresh.ts +381 -0
  47. package/src/scripts/check_release_highlights.ts +25 -13
  48. package/src/scripts/check_suppression_hygiene.ts +11 -0
  49. package/src/scripts/code_graph/build.ts +230 -33
  50. package/src/scripts/code_graph/extract.ts +168 -33
  51. package/src/scripts/code_graph/types.ts +37 -2
  52. package/src/scripts/code_graph/validate.ts +12 -1
  53. package/src/scripts/conformance_scan.ts +2 -2
  54. package/src/scripts/detect_silent_catch.ts +403 -0
  55. package/src/scripts/detect_verification_tampering.ts +538 -0
  56. package/src/scripts/git_authorization_hook.ts +153 -17
  57. package/src/scripts/hook_manifest.json +1 -1
  58. package/src/scripts/hook_manifest.yaml +33 -19
  59. package/src/scripts/hooks/block_no_verify.ts +3 -3
  60. package/src/scripts/hooks/concern_registry.ts +2 -2
  61. package/src/scripts/hooks/evidence_independence.ts +21 -2
  62. package/src/scripts/hooks/{block_unauthorized_git.ts → git_command_classifier.ts} +25 -369
  63. package/src/scripts/hooks/push_settle_hook.ts +250 -0
  64. package/src/scripts/hooks/ship_diff_volume_hook.ts +1 -1
  65. package/src/scripts/install-hooks.sh +134 -1
  66. package/src/scripts/language_mirror_hook.ts +27 -5
  67. package/src/scripts/release.ts +31 -143
  68. package/src/scripts/release_drill.ts +71 -1
  69. package/src/scripts/release_publication.ts +243 -3
  70. package/src/scripts/release_verify.ts +220 -0
  71. package/src/scripts/schemas/review-findings.schema.json +1 -1
  72. package/src/scripts/schemas/skill.schema.json +11 -1
  73. package/src/scripts/self_review_gate.ts +10 -1
  74. package/src/shared/settingsConsent.ts +1 -1
@@ -0,0 +1,242 @@
1
+ /**
2
+ * The closed vocabulary the verification-tampering detector speaks.
3
+ *
4
+ * Declared in its own module, and declared BEFORE the detector, because the
5
+ * roadmap step that asks for it (`road-to-deterministic-defect-detectors` 1.1)
6
+ * asks for exactly that ordering: a detector that invents its ids while it is
7
+ * being written ends up with the ids its implementation found easy, not the
8
+ * ids the failure classes need. Every id below has a fixture in
9
+ * `tests/fixtures/tamper-corpus/`, committed one commit earlier.
10
+ *
11
+ * WHY THE OUTCOME TYPE EXTENDS `GateOutcome` INSTEAD OF REPLACING IT.
12
+ *
13
+ * `_lib/gate_result.ts` already separates "the check ran and found something"
14
+ * (`violations`) from "the check could not run" (`crashed`) from "the
15
+ * measurement is void" (`estate_invalid`). That distinction is the one a tamper
16
+ * detector most needs — a detector that crashed reporting `clean` is precisely
17
+ * the phantom green it exists to catch — so it is reused verbatim rather than
18
+ * re-spelled.
19
+ *
20
+ * Exactly ONE member is added, and only because `GateOutcome` cannot express
21
+ * it: `stale_verdict`. A runner that exited 0 before the last edit produces a
22
+ * green exit code over a tree that no longer exists. `clean` would launder it;
23
+ * `violations` would claim a finding nobody made; `crashed` would claim the
24
+ * runner failed when it succeeded — at the wrong time. The state is "it ran,
25
+ * it passed, and it is not evidence", and none of the four existing members
26
+ * says that. Adding a fifth member is a smaller change than a second
27
+ * vocabulary, which is what 1.1 forbids.
28
+ *
29
+ * SEVERITY, AND THE ONE CONTESTED CALL.
30
+ *
31
+ * Six ids are `block`. `verification-skipped` is `warn`, and that is not a
32
+ * confidence statement about the detection — an added `it.skip` is as
33
+ * mechanically certain as an added `.only`. It is a statement about the
34
+ * ACT: the source this roadmap came from is split on whether adding a skip
35
+ * on the way to green is tampering or triage, and this repository does not
36
+ * promote a class to blocking on a split. It ships `warn` with the corpus as
37
+ * the evidence a later promotion would need.
38
+ *
39
+ * THE ESCAPE HATCH IS SCOPED TO ONE ID, DELIBERATELY.
40
+ *
41
+ * A blanket `tamper-allow` in a commit message would suppress every detector at
42
+ * once, which turns one honest exception into a general amnesty. The grammar
43
+ * therefore names the id: `tamper-allow: <detector-id> — <reason>`. The reason
44
+ * floor mirrors `check_gate_completeness.MIN_REASON_CHARS` (20) and exists for
45
+ * the same reason stated there — it rejects `n/a`, and it cannot judge whether
46
+ * a reason is true, which stays a human read.
47
+ */
48
+ import type { GateOutcome } from './gate_result.js';
49
+
50
+ /** Every tampering class the detector can name. Closed set. */
51
+ export type TamperDetectorId =
52
+ | 'assertion-removed'
53
+ | 'assertion-weakened'
54
+ | 'verification-skipped'
55
+ | 'expectation-rewritten'
56
+ | 'early-exit-injected'
57
+ | 'test-file-deleted'
58
+ | 'subject-mocked-away';
59
+
60
+ export type TamperSeverity = 'block' | 'warn';
61
+
62
+ export interface TamperDetectorSpec {
63
+ id: TamperDetectorId;
64
+ severity: TamperSeverity;
65
+ /** What shape in the diff fires this id. */
66
+ what: string;
67
+ /** Why that shape is a weakened verification rather than ordinary work. */
68
+ why: string;
69
+ }
70
+
71
+ export const TAMPER_DETECTORS: readonly TamperDetectorSpec[] = [
72
+ {
73
+ id: 'assertion-removed',
74
+ severity: 'block',
75
+ what: 'a removed assertion line with no assertion added in the same hunk',
76
+ why: 'the check that could fail is gone; what remains passes for a wider set of outputs',
77
+ },
78
+ {
79
+ id: 'assertion-weakened',
80
+ severity: 'block',
81
+ what: 'a removed assertion replaced in the same hunk by one lower on the strength ladder',
82
+ why: 'an exact-value check became an existence check, which passes for the broken output too',
83
+ },
84
+ {
85
+ id: 'verification-skipped',
86
+ severity: 'warn',
87
+ what: 'an added skip / xfail / only / ignore marker on a test',
88
+ why: 'the assertion survives the diff and stops running, which is the same thing at verdict time',
89
+ },
90
+ {
91
+ id: 'expectation-rewritten',
92
+ severity: 'block',
93
+ what: 'the same assertion call on both sides of a hunk with a different literal expected value',
94
+ why: 'the expectation moved onto whatever the code now emits, so the test can no longer disagree with it',
95
+ },
96
+ {
97
+ id: 'early-exit-injected',
98
+ severity: 'block',
99
+ what: 'an added unconditional return or exit above surviving assertions in a test body',
100
+ why: 'the test reports green having executed none of the verification below it',
101
+ },
102
+ {
103
+ id: 'test-file-deleted',
104
+ severity: 'block',
105
+ what: 'a test file deleted outright by the diff',
106
+ why: 'the fastest route to a green suite is the one that verifies nothing',
107
+ },
108
+ {
109
+ id: 'subject-mocked-away',
110
+ severity: 'block',
111
+ what: 'an added mock or stub whose target also appears on a removed line of the same file',
112
+ why: 'the function under test is replaced by a stub, so the test now asserts against its own fixture',
113
+ },
114
+ ] as const;
115
+
116
+ /** Ids in declaration order — the canonical listing order for output. */
117
+ export const TAMPER_DETECTOR_IDS: readonly TamperDetectorId[] = TAMPER_DETECTORS.map((d) => d.id);
118
+
119
+ const BY_ID = new Map<string, TamperDetectorSpec>(TAMPER_DETECTORS.map((d) => [d.id, d]));
120
+
121
+ /** True when `id` is a member of the closed set. */
122
+ export function isTamperDetectorId(id: string): id is TamperDetectorId {
123
+ return BY_ID.has(id);
124
+ }
125
+
126
+ export function detectorSpec(id: TamperDetectorId): TamperDetectorSpec {
127
+ const spec = BY_ID.get(id);
128
+ /* c8 ignore next 3 -- unreachable while the map is built from the same array */
129
+ if (spec === undefined) {
130
+ throw new Error(`unknown tamper detector id: ${id}`);
131
+ }
132
+ return spec;
133
+ }
134
+
135
+ /**
136
+ * The loop's outcome vocabulary: `GateOutcome` plus the one state it cannot say.
137
+ *
138
+ * See the module header for why this is an extension of four members rather
139
+ * than a parallel set of five.
140
+ */
141
+ export type LoopOutcome = GateOutcome | 'stale_verdict';
142
+
143
+ /**
144
+ * One finding. `file` and `line` are mandatory and are checked at runtime by
145
+ * {@link assertWellFormed} — a finding without a location is a claim a reader
146
+ * cannot check, which is the shape of report this tree already refuses.
147
+ */
148
+ export interface TamperFinding {
149
+ id: TamperDetectorId;
150
+ severity: TamperSeverity;
151
+ /** Repo-relative path as it appears in the diff header. */
152
+ file: string;
153
+ /** 1-based line number in the side of the diff the evidence lives on. */
154
+ line: number;
155
+ /** Which side `line` indexes — a removal has no post-image line. */
156
+ side: 'added' | 'removed';
157
+ /** The offending line, trimmed. Never empty. */
158
+ evidence: string;
159
+ }
160
+
161
+ /**
162
+ * Reject a finding that cannot be located.
163
+ *
164
+ * Deliberately a throw and not a filter: a detector that silently drops its own
165
+ * malformed findings under-reports, and under-reporting is the failure mode the
166
+ * whole detector exists to close.
167
+ */
168
+ export function assertWellFormed(finding: TamperFinding): TamperFinding {
169
+ if (finding.file.trim() === '') {
170
+ throw new Error(`tamper finding ${finding.id} carries no file`);
171
+ }
172
+ if (!Number.isInteger(finding.line) || finding.line < 1) {
173
+ throw new Error(
174
+ `tamper finding ${finding.id} in ${finding.file} carries no usable line ` +
175
+ `(got ${String(finding.line)}); every finding must be locatable as file:line`,
176
+ );
177
+ }
178
+ if (finding.evidence.trim() === '') {
179
+ throw new Error(`tamper finding ${finding.id} at ${finding.file}:${String(finding.line)} carries no evidence text`);
180
+ }
181
+ return finding;
182
+ }
183
+
184
+ /** `path/to/file.ts:12` — the one location format the detector prints. */
185
+ export function locate(finding: TamperFinding): string {
186
+ return `${finding.file}:${String(finding.line)}`;
187
+ }
188
+
189
+ /**
190
+ * Grammar of the scoped escape hatch, read from a commit message.
191
+ *
192
+ * Both an em dash and a plain hyphen are accepted as the separator: the
193
+ * repository writes em dashes in prose, and a contributor typing this into
194
+ * `git commit` will reach for `-`. Rejecting the hyphen would make the hatch
195
+ * fail in the direction that hides a legitimate change.
196
+ */
197
+ export const TAMPER_ALLOW_RE = /^\s*tamper-allow:\s*([a-z-]+)\s*(?:—|--|-)\s*(.+)$/gim;
198
+
199
+ /** Mirrors `check_gate_completeness.MIN_REASON_CHARS` — rejects `n/a`, judges nothing. */
200
+ export const MIN_ALLOW_REASON_CHARS = 20;
201
+
202
+ export interface AllowDeclaration {
203
+ id: TamperDetectorId;
204
+ reason: string;
205
+ }
206
+
207
+ export interface AllowParse {
208
+ allowed: Set<TamperDetectorId>;
209
+ /** Declarations that named an unknown id or gave too short a reason. */
210
+ malformed: string[];
211
+ }
212
+
213
+ /**
214
+ * Parse every `tamper-allow:` line out of a commit message.
215
+ *
216
+ * A malformed declaration does NOT suppress anything and is reported instead:
217
+ * a typo'd id that silently allowed nothing would read to its author as an
218
+ * accepted exception, and a typo'd id that silently allowed everything would be
219
+ * the amnesty this grammar exists to prevent.
220
+ */
221
+ export function parseAllowDeclarations(message: string): AllowParse {
222
+ const allowed = new Set<TamperDetectorId>();
223
+ const malformed: string[] = [];
224
+ TAMPER_ALLOW_RE.lastIndex = 0;
225
+ for (const match of message.matchAll(TAMPER_ALLOW_RE)) {
226
+ const rawId = (match[1] ?? '').trim();
227
+ const reason = (match[2] ?? '').trim();
228
+ if (!isTamperDetectorId(rawId)) {
229
+ malformed.push(`tamper-allow names an unknown detector id: "${rawId}"`);
230
+ continue;
231
+ }
232
+ if (reason.length < MIN_ALLOW_REASON_CHARS) {
233
+ malformed.push(
234
+ `tamper-allow for "${rawId}" gives a ${String(reason.length)}-character reason; ` +
235
+ `at least ${String(MIN_ALLOW_REASON_CHARS)} are required`,
236
+ );
237
+ continue;
238
+ }
239
+ allowed.add(rawId);
240
+ }
241
+ return { allowed, malformed };
242
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * A minimal unified-diff reader.
3
+ *
4
+ * Written rather than pulled in: nothing in `src/scripts/_lib/` parses a diff
5
+ * (`grep -rl "diff --git" src/scripts/_lib/*.ts` returns nothing), and the two
6
+ * consumers here need three facts a full patch library would surround with
7
+ * fifty more — which side a line is on, what line number it lands on, and
8
+ * whether the file was deleted outright.
9
+ *
10
+ * Deliberately tolerant in one direction only: an unparseable line is skipped
11
+ * rather than thrown on, because a diff carrying an unexpected header must not
12
+ * make the detector crash into a green. Deliberately strict in the other: a
13
+ * hunk header that does not parse aborts the file, because line numbers that
14
+ * are wrong are worse than absent — every finding this feeds is a `file:line`
15
+ * claim a reader will try to open.
16
+ */
17
+
18
+ export interface DiffLine {
19
+ side: 'added' | 'removed' | 'context';
20
+ /** 1-based line number on the side this line belongs to. */
21
+ line: number;
22
+ /** Line content with the leading +/-/space removed. */
23
+ text: string;
24
+ }
25
+
26
+ export interface DiffHunk {
27
+ header: string;
28
+ lines: DiffLine[];
29
+ }
30
+
31
+ export interface DiffFile {
32
+ /** Path as it appears after `b/`, or after `a/` for a deleted file. */
33
+ path: string;
34
+ /** True when the diff removes the file outright. */
35
+ deleted: boolean;
36
+ /** True when the diff creates the file. */
37
+ added: boolean;
38
+ hunks: DiffHunk[];
39
+ }
40
+
41
+ const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
42
+ const GIT_HEADER_RE = /^diff --git a\/(.+?) b\/(.+)$/;
43
+
44
+ /** Parse a unified diff into files and hunks. Never throws on content. */
45
+ export function parseUnifiedDiff(text: string): DiffFile[] {
46
+ const files: DiffFile[] = [];
47
+ let current: DiffFile | null = null;
48
+ let hunk: DiffHunk | null = null;
49
+ let oldNo = 0;
50
+ let newNo = 0;
51
+
52
+ for (const raw of text.split('\n')) {
53
+ const gitHeader = GIT_HEADER_RE.exec(raw);
54
+ if (gitHeader !== null) {
55
+ current = { path: gitHeader[2] ?? '', deleted: false, added: false, hunks: [] };
56
+ files.push(current);
57
+ hunk = null;
58
+ continue;
59
+ }
60
+ if (current === null) continue;
61
+
62
+ if (raw.startsWith('deleted file mode')) {
63
+ current.deleted = true;
64
+ continue;
65
+ }
66
+ if (raw.startsWith('new file mode')) {
67
+ current.added = true;
68
+ continue;
69
+ }
70
+ if (raw.startsWith('+++ ')) {
71
+ // `+++ /dev/null` is the other spelling of a deletion; some
72
+ // producers emit it without the `deleted file mode` line.
73
+ if (raw.slice(4).trim() === '/dev/null') current.deleted = true;
74
+ continue;
75
+ }
76
+ if (raw.startsWith('--- ')) {
77
+ if (raw.slice(4).trim() === '/dev/null') current.added = true;
78
+ continue;
79
+ }
80
+ if (raw.startsWith('index ') || raw.startsWith('similarity index') || raw.startsWith('rename ')) {
81
+ continue;
82
+ }
83
+
84
+ const hunkHeader = HUNK_RE.exec(raw);
85
+ if (hunkHeader !== null) {
86
+ oldNo = Number(hunkHeader[1]);
87
+ newNo = Number(hunkHeader[3]);
88
+ hunk = { header: raw, lines: [] };
89
+ current.hunks.push(hunk);
90
+ continue;
91
+ }
92
+ if (hunk === null) continue;
93
+
94
+ if (raw.startsWith('+')) {
95
+ hunk.lines.push({ side: 'added', line: newNo, text: raw.slice(1) });
96
+ newNo += 1;
97
+ } else if (raw.startsWith('-')) {
98
+ hunk.lines.push({ side: 'removed', line: oldNo, text: raw.slice(1) });
99
+ oldNo += 1;
100
+ } else if (raw.startsWith(' ') || raw === '') {
101
+ hunk.lines.push({ side: 'context', line: newNo, text: raw.slice(1) });
102
+ oldNo += 1;
103
+ newNo += 1;
104
+ }
105
+ // `` and anything else: skipped on purpose.
106
+ }
107
+ return files;
108
+ }
109
+
110
+ /** Every added line across the whole diff, with its file. */
111
+ export function addedLines(files: readonly DiffFile[]): Array<{ file: DiffFile; line: DiffLine }> {
112
+ const out: Array<{ file: DiffFile; line: DiffLine }> = [];
113
+ for (const file of files) {
114
+ for (const h of file.hunks) {
115
+ for (const l of h.lines) {
116
+ if (l.side === 'added') out.push({ file, line: l });
117
+ }
118
+ }
119
+ }
120
+ return out;
121
+ }
@@ -24,8 +24,39 @@
24
24
  * self_review_gate --findings-out) into the ledger with an empty
25
25
  * disposition, so validation is red until a human fills it.
26
26
  *
27
- * Exit codes: 0 complete · 1 missing/incomplete dispositions · 2 usage or
28
- * schema error. Deliberately NOT fail-open: this script never calls a model;
27
+ * ABSENCE IS NOT EVIDENCE OF ZERO. An absent ledger used to exit 0 for any
28
+ * version, so a RELEASED version whose findings were never ingested read as
29
+ * "no findings" — the failure this paragraph exists to stop. When the ledger
30
+ * file is missing the gate now asks whether the version has SHIPPED:
31
+ *
32
+ * released a tag for the version exists locally, or the remote says
33
+ * the release exists -> an absent ledger is a FAILURE (1)
34
+ * unreleased the local tag list is COMPLETE and lacks it, or the remote
35
+ * says so -> normal in-flight state, absent ledger is fine
36
+ * undeterminable the tag list cannot settle it and no remote answered ->
37
+ * exit 2. Refusing beats passing: a silent green here is the
38
+ * very inversion this split exists to remove
39
+ *
40
+ * THE SOURCE IS THE TAG / RELEASE, AND DELIBERATELY NOT `CHANGELOG.md`. The
41
+ * changelog heading is written by the release PR itself — gh pr diff 1836 shows
42
+ * "+## [14.16.0](...)" inside the release commit — so on branch release/X the
43
+ * changelog claims X is released before X exists. Reading it would redden every
44
+ * in-flight release branch over a file nobody could yet have written, which is
45
+ * the exact behaviour this split is here to preserve. The tag is created at
46
+ * publish, after merge, so it cannot make that mistake.
47
+ *
48
+ * A local tag MISS is not authoritative on its own: actions/checkout fetches
49
+ * depth 1 and no tags by default, and the one workflow that runs this gate does
50
+ * not override either, so in CI the local tag list is EMPTY. See releaseStatus
51
+ * for the asymmetry and the remote fallback.
52
+ *
53
+ * A future reader changing allowEmpty below is the person this is addressed to:
54
+ * the EMPTY_VALID waiver still covers a ledger that exists and is empty, and an
55
+ * unreleased version. It no longer covers a released version with no ledger.
56
+ *
57
+ * Exit codes: 0 complete · 1 missing/incomplete dispositions (an absent ledger
58
+ * for a RELEASED version is one of them) · 2 usage error, schema error, or an
59
+ * undeterminable release status. Deliberately NOT fail-open: this script never calls a model;
29
60
  * a broken ledger file is a hard error, not a neutral skip.
30
61
  */
31
62
  import { spawnSync } from 'node:child_process';
@@ -105,6 +136,124 @@ export function missing_dispositions(findings: readonly LedgerFinding[]): string
105
136
  return problems;
106
137
  }
107
138
 
139
+ export type ReleaseStatus = 'released' | 'unreleased' | 'undeterminable';
140
+ export type RemoteReleaseAnswer = 'released' | 'unreleased' | 'unavailable';
141
+
142
+ /**
143
+ * Pure core of the released-vs-unreleased discriminator. Every signal is
144
+ * INJECTED, so both directions AND the degradation are testable offline. A
145
+ * red-only test cannot catch the day this starts answering 'unreleased' for
146
+ * everything, which silently restores the fail-open it exists to replace.
147
+ *
148
+ * The asymmetry is the design, and it is the council correction to a first
149
+ * implementation that read local tags only:
150
+ *
151
+ * a local tag HIT is authoritative — the tag exists, the version shipped
152
+ * a local tag MISS is NOT authoritative — unless the tag list is COMPLETE
153
+ *
154
+ * A miss means "not in THIS checkout", which is not "never shipped".
155
+ * tagsComplete is the condition under which a miss IS authoritative: the
156
+ * repository is not shallow and carries at least one tag. That keeps a normal
157
+ * full clone offline and deterministic; only an incomplete checkout pays for a
158
+ * remote lookup.
159
+ */
160
+ export function releaseStatus(signals: {
161
+ localTagHit: boolean;
162
+ tagsComplete: boolean;
163
+ remote: RemoteReleaseAnswer;
164
+ }): ReleaseStatus {
165
+ if (signals.localTagHit) {
166
+ return 'released';
167
+ }
168
+ if (signals.tagsComplete) {
169
+ return 'unreleased';
170
+ }
171
+ if (signals.remote === 'released') {
172
+ return 'released';
173
+ }
174
+ if (signals.remote === 'unreleased') {
175
+ return 'unreleased';
176
+ }
177
+ return 'undeterminable';
178
+ }
179
+
180
+ /** Tag equality, tolerating this repo bare tags and a v prefix. */
181
+ export function localTagHit(release: string, tags: readonly string[]): boolean {
182
+ const v = release.trim();
183
+ if (!v) {
184
+ return false;
185
+ }
186
+ return tags.some((t) => {
187
+ const tt = t.trim();
188
+ return tt === v || tt === `v${v}`;
189
+ });
190
+ }
191
+
192
+ function _git(args: readonly string[]): { ok: boolean; out: string } {
193
+ const r = spawnSync('git', args as string[], {
194
+ encoding: 'utf-8',
195
+ cwd: REPO_ROOT,
196
+ maxBuffer: 16 * 1024 * 1024,
197
+ });
198
+ return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
199
+ }
200
+
201
+ /** This repository tags. A git failure is an empty list, i.e. not complete. */
202
+ export function git_tags(): string[] {
203
+ const r = _git(['tag', '--list']);
204
+ return r.ok ? r.out.split('\n').map((l) => l.trim()).filter(Boolean) : [];
205
+ }
206
+
207
+ /** True when a local tag MISS may be trusted: full clone, and tags present. */
208
+ export function tags_complete(tags: readonly string[]): boolean {
209
+ if (tags.length === 0) {
210
+ return false;
211
+ }
212
+ const r = _git(['rev-parse', '--is-shallow-repository']);
213
+ return r.ok && r.out === 'false';
214
+ }
215
+
216
+ /**
217
+ * Remote answer, used ONLY when the local tag list cannot settle it. gh first —
218
+ * this repo publishes tag and GitHub Release together, the Release object is
219
+ * publication-specific rather than a bare ref push, and --pr mode already shells
220
+ * out to gh. git ls-remote is the fallback when gh is absent.
221
+ */
222
+ export function remote_release_status(release: string): RemoteReleaseAnswer {
223
+ const gh = spawnSync('gh', ['release', 'view', release, '--json', 'tagName'], {
224
+ encoding: 'utf-8',
225
+ cwd: REPO_ROOT,
226
+ maxBuffer: 8 * 1024 * 1024,
227
+ });
228
+ if (gh.status === 0) {
229
+ return 'released';
230
+ }
231
+ if (gh.status !== null && /release not found|could not find|not found/i.test(gh.stderr ?? '')) {
232
+ return 'unreleased';
233
+ }
234
+ const ls = spawnSync(
235
+ 'git',
236
+ ['ls-remote', '--tags', 'origin', `refs/tags/${release}`, `refs/tags/v${release}`],
237
+ { encoding: 'utf-8', cwd: REPO_ROOT, maxBuffer: 8 * 1024 * 1024 },
238
+ );
239
+ if (ls.status === 0) {
240
+ return (ls.stdout ?? '').trim() ? 'released' : 'unreleased';
241
+ }
242
+ return 'unavailable';
243
+ }
244
+
245
+ /** Resolve every signal and decide. Called only when the ledger file is absent. */
246
+ export function resolve_release_status(release: string): ReleaseStatus {
247
+ const tags = git_tags();
248
+ const hit = localTagHit(release, tags);
249
+ const complete = tags_complete(tags);
250
+ return releaseStatus({
251
+ localTagHit: hit,
252
+ tagsComplete: complete,
253
+ remote: hit || complete ? 'unavailable' : remote_release_status(release),
254
+ });
255
+ }
256
+
108
257
  /** Findings the gate comment reported but the ledger never ingested. */
109
258
  export function unrecorded_findings(
110
259
  reported: ReadonlyArray<Pick<LedgerFinding, 'finding_id' | 'severity' | 'kind' | 'title'>>,
@@ -227,6 +376,30 @@ function main(argv: readonly string[]): number {
227
376
  return 0;
228
377
  }
229
378
 
379
+ if (!fs.existsSync(ledgerPath)) {
380
+ const status = resolve_release_status(release);
381
+ if (status === 'released') {
382
+ process.stderr.write(
383
+ `❌ ${release} has shipped and carries no findings ledger at `
384
+ + `${path.relative(REPO_ROOT, ledgerPath)}.\n`
385
+ + ' Absence is not evidence of zero — the release PR\'s self-review may have\n'
386
+ + ' reported findings that were never ingested. Recover the machine block and:\n'
387
+ + ` ./scripts-run src/scripts/check_finding_dispositions --ingest <findings.json> --release ${release}\n`,
388
+ );
389
+ return 1;
390
+ }
391
+ if (status === 'undeterminable') {
392
+ process.stderr.write(
393
+ `❌ cannot determine whether ${release} has shipped: this checkout tag list is`
394
+ + ' incomplete (shallow or tagless) and no remote answered.\n'
395
+ + ' Refusing to pass on an absent ledger without knowing — that silent green is\n'
396
+ + ' the defect this check replaces. Fetch tags (actions/checkout fetch-tags: true)\n'
397
+ + ' or make the gh CLI available.\n',
398
+ );
399
+ return 2;
400
+ }
401
+ }
402
+
230
403
  let ledger: Ledger = { schema_version: 1, release, findings: [] };
231
404
  if (fs.existsSync(ledgerPath)) {
232
405
  try {
@@ -253,7 +426,10 @@ function main(argv: readonly string[]): number {
253
426
  allowEmpty:
254
427
  'EMPTY_VALID: zero recorded findings IS the pass state for a release whose '
255
428
  + 'self-review reported none — the ledger file is created on first --ingest, so a '
256
- + 'clean release legitimately has no <version>.json. Un-ingested findings are '
429
+ + 'clean release legitimately has no <version>.json. NARROWED: this no longer '
430
+ + 'covers a RELEASED version with no ledger, which returns 1 above on the '
431
+ + 'released/unreleased split; the waiver now covers an empty-but-present ledger '
432
+ + 'and an unreleased version. Un-ingested findings on an in-flight release are '
257
433
  + 'caught by --pr (unrecorded_findings), never by this count.',
258
434
  });
259
435
  } catch (exc) {