@event4u/agent-config 12.0.0 → 13.0.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 (148) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +273 -0
  3. package/README.md +1 -1
  4. package/agents/templates/.ai-council.yml.example +46 -3
  5. package/dist/agent-src/commands/cost/report.md +23 -0
  6. package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
  7. package/dist/agent-src/contexts/model-recommendations.md +6 -4
  8. package/dist/agent-src/contexts/subagent-configuration.md +69 -7
  9. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  10. package/dist/agent-src/rules/design-fidelity.md +1 -1
  11. package/dist/agent-src/rules/design-review-after-ui-write.md +10 -5
  12. package/dist/agent-src/rules/evaluator-independence.md +27 -5
  13. package/dist/agent-src/rules/git-history-discipline.md +5 -1
  14. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
  15. package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
  16. package/dist/agent-src/rules/ui-audit-gate.md +9 -4
  17. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  18. package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
  19. package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -1
  20. package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
  21. package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
  22. package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
  23. package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
  24. package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
  25. package/dist/agent-src/skills/roadmap-writing/SKILL.md +11 -11
  26. package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
  27. package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
  28. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  29. package/dist/agent-src/templates/roadmaps.md +41 -3
  30. package/dist/cli/registry.js +1 -0
  31. package/dist/cli/registry.js.map +1 -1
  32. package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-3B72LSZL.js} +123 -2338
  33. package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
  34. package/dist/cli-delegate/chunk-N7FEST6D.js +3895 -0
  35. package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-XURJ4AQC.js} +566 -298
  36. package/dist/cli-delegate/cmd_conformance.js +4 -4
  37. package/dist/cli-delegate/cmd_doctor.js +2 -2
  38. package/dist/cli-delegate/cmd_export.js +2 -2
  39. package/dist/cli-delegate/cmd_handoff.js +1 -1
  40. package/dist/cli-delegate/cmd_prune.js +2 -2
  41. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  42. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  43. package/dist/cli-delegate/cmd_sync.js +2 -2
  44. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  45. package/dist/cli-delegate/cmd_validate.js +2 -2
  46. package/dist/discovery/deprecation-report.md +1 -1
  47. package/dist/discovery/discovery-manifest.json +44 -23
  48. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  49. package/dist/discovery/discovery-manifest.summary.md +3 -3
  50. package/dist/discovery/dormancy-report.md +1 -1
  51. package/dist/discovery/orphan-report.md +1 -1
  52. package/dist/discovery/packs.json +6 -5
  53. package/dist/discovery/trust-report.md +2 -2
  54. package/dist/discovery/workspaces.json +5 -4
  55. package/dist/hooks/dispatch.js +10625 -8541
  56. package/dist/install/install.mjs +754 -484
  57. package/dist/mcp/registry-manifest.json +3 -3
  58. package/dist/mcp/server.json +2 -2
  59. package/dist/mcp/server.mjs +1302 -167
  60. package/dist/router.json +1 -1
  61. package/dist/scripts/_lib/model_tier.js +9 -0
  62. package/dist/scripts/_lib/model_tier.js.map +1 -1
  63. package/dist/scripts/condense.js +50 -0
  64. package/dist/scripts/condense.js.map +1 -1
  65. package/dist/server/schemas/settings.js +2 -2
  66. package/dist/server/schemas/settings.js.map +1 -1
  67. package/docs/MIGRATION.md +20 -1
  68. package/docs/guidelines/design-fidelity-mechanics.md +21 -0
  69. package/docs/guidelines/design-handover-extraction.md +87 -0
  70. package/docs/guidelines/prompt-templates.md +24 -1
  71. package/package.json +1 -1
  72. package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  73. package/src/agent-src/scripts/roadmap_gates.ts +30 -2
  74. package/src/agent-src/scripts/update_roadmap_progress.ts +47 -1
  75. package/src/config/evaluator-budgets.json +9 -8
  76. package/src/config/gate-coverage.yml +63 -11
  77. package/src/config/gate-violation-baselines.json +12 -0
  78. package/src/config/hook-token-budget.json +15 -2
  79. package/src/config/preamble-payload-budget.json +28 -0
  80. package/src/scripts/_dispatch.bash +10 -0
  81. package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
  82. package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
  83. package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
  84. package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
  85. package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
  86. package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
  87. package/src/scripts/_lib/git_common_dir.ts +173 -0
  88. package/src/scripts/_lib/model_tier.ts +9 -0
  89. package/src/scripts/_lib/release_highlights.ts +108 -7
  90. package/src/scripts/_lib/skill_catalogue.ts +1115 -0
  91. package/src/scripts/_lib/tier_budget_routing.ts +31 -331
  92. package/src/scripts/ai_council/clients.ts +197 -18
  93. package/src/scripts/ai_council/config.ts +16 -1
  94. package/src/scripts/ai_council/low_impact.ts +14 -1
  95. package/src/scripts/ai_council/orchestrator.ts +14 -1
  96. package/src/scripts/ai_council/transport_resolver.ts +32 -2
  97. package/src/scripts/bench_ab_v2_complexity.ts +252 -0
  98. package/src/scripts/bench_ab_v2_run.ts +3 -42
  99. package/src/scripts/bench_ab_v2_safety.ts +296 -0
  100. package/src/scripts/bench_ab_v2_search.ts +343 -0
  101. package/src/scripts/bench_ab_v2_stats.ts +191 -0
  102. package/src/scripts/build_archive_index.ts +383 -0
  103. package/src/scripts/capture_skill_catalogue.ts +279 -317
  104. package/src/scripts/check_branch_freshness.ts +7 -1
  105. package/src/scripts/check_release_published.ts +6 -2
  106. package/src/scripts/check_release_trunk_sync.ts +18 -2
  107. package/src/scripts/check_source_size_budget.ts +428 -0
  108. package/src/scripts/check_suppression_hygiene.ts +5 -1
  109. package/src/scripts/condense.ts +50 -1
  110. package/src/scripts/cost/backfill_rates.mjs +298 -0
  111. package/src/scripts/cost/budget.mjs +12 -32
  112. package/src/scripts/cost_summary.ts +25 -0
  113. package/src/scripts/dispatch_r2_reviewer.ts +15 -5
  114. package/src/scripts/evidence_report.ts +15 -4
  115. package/src/scripts/external_sources_denylist.json +1 -0
  116. package/src/scripts/hook_manifest.yaml +75 -15
  117. package/src/scripts/hooks/concern_registry.ts +4 -0
  118. package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
  119. package/src/scripts/hooks/skill_route_hook.ts +226 -0
  120. package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
  121. package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
  122. package/src/scripts/hot_context_hook.ts +13 -1
  123. package/src/scripts/install.ts +129 -0
  124. package/src/scripts/janitor.ts +77 -2
  125. package/src/scripts/lint_design_antipattern_parity.ts +40 -6
  126. package/src/scripts/lint_harvest_provenance.ts +35 -1
  127. package/src/scripts/lint_plan_risk_register.ts +22 -9
  128. package/src/scripts/lint_roadmap_blockers.ts +109 -6
  129. package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
  130. package/src/scripts/lint_scheduled_deprecations.ts +738 -0
  131. package/src/scripts/lint_skill_router_head.ts +21 -0
  132. package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
  133. package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
  134. package/src/scripts/mcp_server/tools.ts +87 -0
  135. package/src/scripts/migration_status.ts +17 -1
  136. package/src/scripts/probe_review_binding_drift.ts +731 -0
  137. package/src/scripts/release.ts +100 -6
  138. package/src/scripts/routing_doctor.ts +50 -13
  139. package/src/scripts/schemas/command.schema.json +3 -3
  140. package/src/scripts/schemas/skill.schema.json +20 -2
  141. package/src/scripts/schemas/subagent.schema.json +2 -2
  142. package/src/scripts/session_eol_report.ts +81 -0
  143. package/src/scripts/trigger_coverage.ts +224 -3
  144. package/src/scripts/workspace_doctor.ts +464 -0
  145. package/src/scripts/worktree_cleanup_check.ts +2 -1
  146. package/src/server/schemas/settings.ts +2 -2
  147. package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
  148. package/src/config/budget-routing.json +0 -7
@@ -0,0 +1,738 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Gate: the scheduled-deprecations table in `docs/MIGRATION.md` is checked by
4
+ * arithmetic, not by somebody remembering to read it.
5
+ *
6
+ * WHY THIS EXISTS. The table records commitments due at a *future* major, and
7
+ * the release runbook's pre-flight sends the releaser to it with a manual
8
+ * checkbox whose own text says "A row left unread is how a removal commitment
9
+ * becomes folklore". The row for the `code_graph` engine committed its removal
10
+ * to the major after the one after 9.x — 11.0 — and `package.json` reads
11
+ * 12.0.0. The row existed, was correct, and was missed anyway. So the defect is
12
+ * the absence of a check, not the absence of a row, and a second manual reminder
13
+ * would repeat the failure at a higher frequency.
14
+ *
15
+ * WHAT IT DOES NOT DO. It never removes anything. Acting on an overdue row is a
16
+ * public-surface change gated by `downstream-changes` and `scope-control` and
17
+ * lands at a major cut; this gate reports and, at a cut, refuses. It also does
18
+ * not invent a due version: a cell it cannot resolve is reported as
19
+ * *unresolved* and reds, because a table whose format drifted silently is
20
+ * exactly the shape "a gate that scans nothing exits green" describes.
21
+ *
22
+ * EXIT CONTRACT
23
+ * 0 nothing due, or due surfaces reported on an ordinary branch
24
+ * 1 a row could not be parsed / resolved, the table is missing or empty, or
25
+ * a due surface was found while `--cutting <version>` was passed
26
+ * 2 usage error, or an environment failure that leaves the gate with no
27
+ * comparand at all (missing / malformed `package.json`)
28
+ *
29
+ * `--cutting <X.Y.Z>` is the major-cut switch: an ordinary branch gets the
30
+ * report, a release gets the refusal. That asymmetry is deliberate — an overdue
31
+ * row can be a considered deferral, and a hard refusal on every branch would
32
+ * turn a judgement into an outage. A deliberate deferral is expressible in the
33
+ * table itself (a `permanent keep` row), which is what keeps the refusal honest
34
+ * at the cut.
35
+ *
36
+ * **THE COMPARAND IS THE TARGET, NOT THE SHIPPED VERSION, AND THE DIFFERENCE IS
37
+ * THE WHOLE POINT.** An earlier version of this gate compared against
38
+ * `package.json` in both modes. At the cut to N, `package.json` still reads
39
+ * N-1, so a row committed to N resolved to "one major early" and passed — the
40
+ * refusal could only ever fire on a row that was ALREADY a major late, which is
41
+ * precisely the lateness this gate exists to prevent. `--cutting` takes the
42
+ * target so the cut that would CREATE the miss is the one that gets refused.
43
+ *
44
+ * SCOPE BOUND, stated because the runbook checkbox above it is wider: only the
45
+ * **Removal due** column is compared. The Deprecation-notice-due cell is parsed
46
+ * solely as the anchor for a relative removal cell and is never checked against
47
+ * anything, because a shipped notice is written as a date rather than a version
48
+ * and has no comparand. The "ship the notice" half of the pre-flight obligation
49
+ * therefore remains a human read.
50
+ */
51
+ import * as fs from 'node:fs';
52
+ import * as os from 'node:os';
53
+ import * as path from 'node:path';
54
+ import { fileURLToPath, pathToFileURL } from 'node:url';
55
+
56
+ import { GateLedger } from './_lib/gate_ledger.js';
57
+ import { runGateCli, runSelfTest, type SelfTestCase } from './_lib/gate_self_test.js';
58
+ import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
59
+
60
+ const MIGRATION_PATH = path.join('docs', 'MIGRATION.md');
61
+ const PACKAGE_PATH = 'package.json';
62
+
63
+ /** The heading the table lives under. Matched case-insensitively on the stem. */
64
+ const SECTION_MARKER = /^##\s+scheduled deprecations\b/i;
65
+
66
+ /**
67
+ * A row whose removal date is deliberately not pinned. The marker is the
68
+ * table's own wording, so a maintainer-owned removal date stays expressible
69
+ * without becoming an unresolved parse.
70
+ */
71
+ const UNPINNED_MARKER = /not pinned/i;
72
+
73
+ /** A row explicitly recorded as staying — the "documented keep" state. */
74
+ const PERMANENT_KEEP_MARKER = /permanent keep|no removal scheduled/i;
75
+
76
+ /**
77
+ * A commitment WITHDRAWN with its reason recorded, rather than re-dated.
78
+ *
79
+ * The third tracked state, and it exists because the table grew one the day
80
+ * after this gate shipped: the `code_graph` commitment was withdrawn rather
81
+ * than moved, on the argument that the payload it existed for had already
82
+ * landed. That is a legitimate outcome — the table's own prose calls a
83
+ * withdrawn commitment recorded "not the folklore this table exists to
84
+ * prevent" — so the gate must read it as tracked, not red it as unresolvable.
85
+ * The table is the contract; a gate that forces the table into its own grammar
86
+ * has the dependency backwards.
87
+ */
88
+ const WITHDRAWN_MARKER = /no removal date|commitment withdrawn|withdrawn \d{4}-\d{2}-\d{2}/i;
89
+
90
+ export interface DeprecationRow {
91
+ /** Raw first cell — the surface, as written. */
92
+ surface: string;
93
+ /** Short name for reporting: the first backticked token, else a truncation. */
94
+ name: string;
95
+ /** Raw "Deprecation notice due" cell. */
96
+ noticeDueRaw: string;
97
+ /** Raw "Removal due" cell. */
98
+ removalDueRaw: string;
99
+ /** Path-shaped tokens found in the surface cell (contain a `/`). */
100
+ paths: string[];
101
+ /** 1-based row number within the table, for error messages. */
102
+ index: number;
103
+ }
104
+
105
+ export type Resolution =
106
+ | { kind: 'major'; major: number }
107
+ | { kind: 'unpinned' }
108
+ | { kind: 'keep' }
109
+ | { kind: 'withdrawn' }
110
+ | { kind: 'unresolved'; cell: string };
111
+
112
+ /**
113
+ * Resolve a due-version cell to a concrete major.
114
+ *
115
+ * The grammar is deliberately small, and every form below appears in the table
116
+ * today. A form outside it resolves to `unresolved` and reds rather than being
117
+ * guessed at — an invented due version is worse than a parse failure, because
118
+ * it would silently move a commitment.
119
+ *
120
+ * `11.0` / `11.0.0` / `11` → 11
121
+ * `next major after 9.x` → 10
122
+ * `the major after that` → (notice major) + 1
123
+ * `next major after the notice` → (notice major) + 1
124
+ * `… not pinned …` → unpinned (maintainer-owned date)
125
+ * `… permanent keep …` → keep (documented, not scheduled)
126
+ * `… no removal date …` → withdrawn (commitment withdrawn, reason recorded)
127
+ *
128
+ * @param cell the raw table cell
129
+ * @param noticeMajor the already-resolved notice major, when one exists — the
130
+ * relative forms are only resolvable against it
131
+ */
132
+ export function resolveDueMajor(cell: string, noticeMajor?: number): Resolution {
133
+ const text = cell.trim();
134
+ if (text === '') return { kind: 'unresolved', cell };
135
+
136
+ // Checked before the relative forms on purpose: the unpinned row also
137
+ // contains the words "next major after the notice", and the explicit
138
+ // not-pinned statement is the load-bearing half of that cell.
139
+ if (UNPINNED_MARKER.test(text)) return { kind: 'unpinned' };
140
+ if (PERMANENT_KEEP_MARKER.test(text)) return { kind: 'keep' };
141
+ if (WITHDRAWN_MARKER.test(text)) return { kind: 'withdrawn' };
142
+
143
+ const relativeAfterVersion = /next major after\s+(?:v)?(\d+)(?:\.(?:x|\d+))?/i.exec(text);
144
+ const relativeToNotice = /(?:the major after that|next major after the notice)/i.test(text);
145
+
146
+ if (relativeAfterVersion) {
147
+ return { kind: 'major', major: Number(relativeAfterVersion[1]) + 1 };
148
+ }
149
+ if (relativeToNotice) {
150
+ if (noticeMajor === undefined) return { kind: 'unresolved', cell };
151
+ return { kind: 'major', major: noticeMajor + 1 };
152
+ }
153
+ // ANCHORED, not "a digit run somewhere in the cell". The loose form matched
154
+ // any number and so resolved `shipped 2026-07-29 — dormant by default` to
155
+ // major 2026 — a confidently wrong commitment, which is the one outcome the
156
+ // contract at the top of this file rules out. The whole cell (bare of
157
+ // markdown emphasis) must BE a version, or the cell is unresolved.
158
+ const explicit = /^\**\s*v?(\d+)(?:\.(?:\d+|x)){0,2}\s*\**$/.exec(text);
159
+ if (explicit) {
160
+ return { kind: 'major', major: Number(explicit[1]) };
161
+ }
162
+ return { kind: 'unresolved', cell };
163
+ }
164
+
165
+ /**
166
+ * The table's header no longer carries a `Removal due` column.
167
+ *
168
+ * Its own error, rather than N per-row "cell not resolvable" findings: a
169
+ * renamed column has exactly one cause and one fix, and reporting it per row
170
+ * buries that under noise proportional to the table's length.
171
+ */
172
+ export class HeaderMismatchError extends Error {
173
+ constructor(readonly header: readonly string[]) {
174
+ super(
175
+ 'lint_scheduled_deprecations: the scheduled-deprecations table has no ' +
176
+ `"Removal due" column — header reads [${header.join(' | ')}]. Either the ` +
177
+ 'column was renamed (update this gate in the same change) or the heading ' +
178
+ 'now sits above a different table.',
179
+ );
180
+ this.name = 'HeaderMismatchError';
181
+ }
182
+ }
183
+
184
+ /** Split one markdown table line into trimmed cells, dropping the outer pipes. */
185
+ function splitRow(line: string): string[] {
186
+ const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
187
+ return trimmed.split('|').map((c) => c.trim());
188
+ }
189
+
190
+ /** A separator line — `|---|---|` and its alignment variants. */
191
+ function isSeparator(line: string): boolean {
192
+ return /^\|?[\s:|-]+\|[\s:|-]*$/.test(line.trim()) && line.includes('-');
193
+ }
194
+
195
+ /**
196
+ * Extract the scheduled-deprecations table from a `MIGRATION.md` body.
197
+ *
198
+ * Reads only the first table under the section heading and stops at the next
199
+ * `##`, so the version sections below it — every one of which records a change
200
+ * that already shipped — can never be parsed as commitments.
201
+ */
202
+ export function parseRows(markdown: string): DeprecationRow[] {
203
+ const lines = markdown.split('\n');
204
+ let inSection = false;
205
+ let header: string[] | undefined;
206
+ // Resolved ONCE when the header row is read, not per data row. Recomputing
207
+ // them inside the loop made a renamed column red every row independently
208
+ // with an empty cell quoted, burying the one true cause — a header that no
209
+ // longer matches — under N identical findings.
210
+ let idxNotice = -1;
211
+ let idxRemoval = -1;
212
+ const rows: DeprecationRow[] = [];
213
+
214
+ for (const line of lines) {
215
+ // ANY heading closes the span, not just a sibling `## `. The table now
216
+ // has a `### Row status` subsection under it; with a `^##\s` close the
217
+ // span ran past it, so a table added there would have been appended as
218
+ // data rows with its header parsed as one.
219
+ if (/^#{1,6}\s/.test(line)) {
220
+ if (inSection) break;
221
+ inSection = SECTION_MARKER.test(line);
222
+ continue;
223
+ }
224
+ if (!inSection) continue;
225
+ if (!line.trim().startsWith('|')) continue;
226
+ if (isSeparator(line)) continue;
227
+
228
+ const cells = splitRow(line);
229
+ if (header === undefined) {
230
+ header = cells.map((c) => c.toLowerCase());
231
+ idxNotice = header.findIndex((h) => h.includes('notice due'));
232
+ idxRemoval = header.findIndex((h) => h.includes('removal due'));
233
+ if (idxRemoval < 0) {
234
+ throw new HeaderMismatchError(header);
235
+ }
236
+ continue;
237
+ }
238
+
239
+ const surface = cells[0] ?? '';
240
+ const backticked = [...surface.matchAll(/`([^`]+)`/g)].map((m) => m[1] ?? '');
241
+
242
+ rows.push({
243
+ surface,
244
+ name: backticked[0] ?? surface.slice(0, 60),
245
+ noticeDueRaw: idxNotice >= 0 ? (cells[idxNotice] ?? '') : '',
246
+ removalDueRaw: idxRemoval >= 0 ? (cells[idxRemoval] ?? '') : '',
247
+ paths: backticked.filter((t) => t.includes('/')),
248
+ index: rows.length + 1,
249
+ });
250
+ }
251
+ return rows;
252
+ }
253
+
254
+ export interface Finding {
255
+ row: DeprecationRow;
256
+ /**
257
+ * `overdue` — the removal is due at or before the comparand major.
258
+ * `unresolved` — the removal-due cell could not be resolved to a version.
259
+ */
260
+ kind: 'overdue' | 'unresolved';
261
+ /**
262
+ * `comparand − dueMajor`. Positive = late by that many majors; **zero =
263
+ * due exactly at the major being cut**, which is a refusal at a cut and
264
+ * silent on an ordinary branch, since a row due at the shipped major is
265
+ * being acted on right now rather than missed.
266
+ */
267
+ overdueBy: number;
268
+ /** Path tokens from the row that still exist on disk. */
269
+ livePaths: string[];
270
+ }
271
+
272
+ /**
273
+ * Compare every row's removal-due major against `comparand`.
274
+ *
275
+ * `comparand` is the SHIPPED major on an ordinary branch and the TARGET major
276
+ * at a cut — the distinction the gate's header paragraph explains, and the
277
+ * reason this parameter is not simply read from `package.json` in here.
278
+ */
279
+ export function evaluate(
280
+ rows: readonly DeprecationRow[],
281
+ comparand: number,
282
+ root: string,
283
+ ledger?: GateLedger,
284
+ /**
285
+ * `>= 0` at a cut (a row due AT the target must be acted on now), `> 0` on
286
+ * an ordinary branch (a row due at the shipped major is being worked, not
287
+ * missed). Without the split, a row committed to the current major warned
288
+ * on every branch and every CI run for a whole release cycle — a standing
289
+ * notice for something nobody is late on, which is the habituation this
290
+ * gate exists to avoid producing.
291
+ */
292
+ dueAtComparandCounts = false,
293
+ ): Finding[] {
294
+ const findings: Finding[] = [];
295
+ ledger?.plan(rows.map((r) => `row-${String(r.index)}:${r.name}`));
296
+
297
+ for (const row of rows) {
298
+ const key = `row-${String(row.index)}:${row.name}`;
299
+ const notice = resolveDueMajor(row.noticeDueRaw);
300
+ const noticeMajor = notice.kind === 'major' ? notice.major : undefined;
301
+ const removal = resolveDueMajor(row.removalDueRaw, noticeMajor);
302
+
303
+ if (removal.kind === 'unpinned' || removal.kind === 'keep' || removal.kind === 'withdrawn') {
304
+ // A tracked state carries no comparable version, so the arithmetic
305
+ // never runs on it. Recorded as SKIPPED rather than completed: a row
306
+ // that skipped the comparison must not read the same as one that
307
+ // passed it, which is the false-green shape this whole gate is about
308
+ // — and today all three shipped rows are tracked states, so a
309
+ // `complete()` here meant the gate printed green over zero
310
+ // comparisons.
311
+ ledger?.skip(key, 'not_applicable_kind');
312
+ continue;
313
+ }
314
+ if (removal.kind === 'unresolved') {
315
+ findings.push({ row, kind: 'unresolved', overdueBy: 0, livePaths: [] });
316
+ ledger?.fail(key, `removal-due cell not resolvable: ${removal.cell}`);
317
+ continue;
318
+ }
319
+
320
+ const overdueBy = comparand - removal.major;
321
+ if (overdueBy > 0 || (overdueBy === 0 && dueAtComparandCounts)) {
322
+ const livePaths = row.paths.filter((p) => fs.existsSync(path.join(root, p)));
323
+ findings.push({ row, kind: 'overdue', overdueBy, livePaths });
324
+ ledger?.fail(
325
+ key,
326
+ `removal due at ${String(removal.major)}.0, comparand major is ${String(comparand)}`,
327
+ );
328
+ continue;
329
+ }
330
+ ledger?.complete(key);
331
+ }
332
+ return findings;
333
+ }
334
+
335
+ function _isDir(p: string): boolean {
336
+ try {
337
+ return fs.statSync(p).isDirectory();
338
+ } catch {
339
+ return false;
340
+ }
341
+ }
342
+
343
+ /** Walk up from CWD until a dir containing `docs/MIGRATION.md` is found. */
344
+ function repoRoot(): string {
345
+ let cur = process.cwd();
346
+ const chain = [cur];
347
+ for (;;) {
348
+ const parent = path.dirname(cur);
349
+ if (parent === cur) break;
350
+ chain.push(parent);
351
+ cur = parent;
352
+ }
353
+ for (const candidate of chain) {
354
+ if (_isDir(path.join(candidate, 'docs')) && fs.existsSync(path.join(candidate, MIGRATION_PATH))) {
355
+ return candidate;
356
+ }
357
+ }
358
+ return process.cwd();
359
+ }
360
+
361
+ /** The major from `package.json`'s `version`. */
362
+ export function shippedMajor(root: string): number {
363
+ const raw = fs.readFileSync(path.join(root, PACKAGE_PATH), 'utf-8');
364
+ const parsed: unknown = JSON.parse(raw);
365
+ const version =
366
+ typeof parsed === 'object' && parsed !== null && 'version' in parsed
367
+ ? (parsed as { version?: unknown }).version
368
+ : undefined;
369
+ if (typeof version !== 'string') {
370
+ throw new Error(`${PACKAGE_PATH}: no string \`version\` field`);
371
+ }
372
+ const major = /^(\d+)\./.exec(version);
373
+ if (!major) {
374
+ throw new Error(`${PACKAGE_PATH}: version "${version}" has no leading major`);
375
+ }
376
+ return Number(major[1]);
377
+ }
378
+
379
+ /** Floors for `--self-test`, declared here so a truncation is a visible diff. */
380
+ const SELF_TEST_MIN_CASES = 10;
381
+ const SELF_TEST_MIN_REJECT = 6;
382
+
383
+ /**
384
+ * Prove, on demand, that this gate's rejections still fire against its own CLI.
385
+ *
386
+ * The unit suite covers the same ground against imported functions; this covers
387
+ * the binary a contributor actually runs, including argv parsing and the entry
388
+ * guard — the layers that have silently no-opped in this repository before.
389
+ */
390
+ function selfTest(): number {
391
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lsd-selftest-'));
392
+ const repo = repoRoot();
393
+ const script = path.join('src', 'scripts', 'lint_scheduled_deprecations.ts');
394
+
395
+ const header = [
396
+ '# Migration Guide',
397
+ '',
398
+ '## Scheduled deprecations (forward-looking)',
399
+ '',
400
+ '| Surface | Committed | Deprecation notice due | Removal due | Reversal condition |',
401
+ '|---|---|---|---|---|',
402
+ ].join('\n');
403
+
404
+ /** Build a fixture tree and run the real CLI against it. */
405
+ const fixture = (name: string, major: number, table: string, args: string[] = []): number => {
406
+ const dir = path.join(root, name);
407
+ fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
408
+ fs.writeFileSync(
409
+ path.join(dir, 'package.json'),
410
+ JSON.stringify({ name: 'fixture', version: `${String(major)}.0.0` }),
411
+ 'utf8',
412
+ );
413
+ fs.writeFileSync(path.join(dir, 'docs', 'MIGRATION.md'), `${header}\n${table}\n`, 'utf8');
414
+ return runGateCli(repo, script, args, dir);
415
+ };
416
+
417
+ const cases: SelfTestCase[] = [
418
+ {
419
+ name: 'overdue row at a major cut is refused',
420
+ expect: 'reject',
421
+ run: () =>
422
+ fixture(
423
+ 'overdue',
424
+ 12,
425
+ '| `legacy` | 2026-01-01 | 10.0 | 11.0 | none |',
426
+ ['--cutting', '13.0.0'],
427
+ ),
428
+ },
429
+ {
430
+ // THE CASE THE FIRST VERSION OF THIS GATE MISSED. A row committed
431
+ // to the major being cut is due NOW; measured against the shipped
432
+ // version it reads as one major early and passes, so the cut that
433
+ // creates the miss goes through. This case is what pins the
434
+ // comparand to the target.
435
+ name: 'a row due AT the major being cut is refused, not waved through',
436
+ expect: 'reject',
437
+ run: () =>
438
+ fixture(
439
+ 'due-at-cut',
440
+ 12,
441
+ '| `due_now` | 2026-01-01 | 12.0 | 13.0 | none |',
442
+ ['--cutting', '13.0.0'],
443
+ ),
444
+ },
445
+ {
446
+ name: 'the same row is silent on an ordinary branch — it is not late yet',
447
+ expect: 'accept',
448
+ run: () => fixture('due-at-cut-branch', 12, '| `due_now` | 2026-01-01 | 12.0 | 13.0 | none |'),
449
+ },
450
+ {
451
+ // due == SHIPPED, which is the case the contract calls silent on a
452
+ // branch and neither the earlier fixtures nor the self-test covered
453
+ // (both used due 13 against shipped 12, i.e. due == TARGET).
454
+ name: 'a row due at the SHIPPED major is silent on an ordinary branch',
455
+ expect: 'accept',
456
+ run: () => fixture('due-at-shipped', 12, '| `due_at_shipped` | 2026-01-01 | 11.0 | 12.0 | none |'),
457
+ },
458
+ {
459
+ name: 'the same row IS refused at the very next cut',
460
+ expect: 'reject',
461
+ run: () =>
462
+ fixture(
463
+ 'due-at-shipped-cut',
464
+ 12,
465
+ '| `due_at_shipped` | 2026-01-01 | 11.0 | 12.0 | none |',
466
+ ['--cutting', '13.0.0'],
467
+ ),
468
+ },
469
+ {
470
+ name: 'a withdrawn commitment is tracked, not an unresolvable cell',
471
+ expect: 'accept',
472
+ run: () =>
473
+ fixture(
474
+ 'withdrawn',
475
+ 12,
476
+ '| `withdrawn_thing` | 2026-01-01 | 10.0 | **REVISED 2026-08-15 — no removal date; see below** | none |',
477
+ ['--cutting', '13.0.0'],
478
+ ),
479
+ },
480
+ {
481
+ name: 'a header with no Removal due column reds once, naming the header',
482
+ expect: 'reject',
483
+ run: () => {
484
+ const dir = path.join(root, 'renamed-header');
485
+ fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
486
+ fs.writeFileSync(
487
+ path.join(dir, 'package.json'),
488
+ JSON.stringify({ name: 'fixture', version: '12.0.0' }),
489
+ 'utf8',
490
+ );
491
+ fs.writeFileSync(
492
+ path.join(dir, 'docs', 'MIGRATION.md'),
493
+ `${header.replace('| Removal due ', '| Retirement date ')}\n` +
494
+ '| `a` | 2026-01-01 | 10.0 | 11.0 | none |\n' +
495
+ '| `b` | 2026-01-01 | 10.0 | 11.0 | none |\n',
496
+ 'utf8',
497
+ );
498
+ return runGateCli(repo, script, [], dir);
499
+ },
500
+ },
501
+ {
502
+ name: 'unresolvable removal-due cell reds on an ordinary branch',
503
+ expect: 'reject',
504
+ run: () => fixture('vague', 12, '| `vague` | 2026-01-01 | soon | eventually | none |'),
505
+ },
506
+ {
507
+ name: 'a table parsing to zero rows reds rather than exiting green',
508
+ expect: 'reject',
509
+ run: () => {
510
+ const dir = path.join(root, 'dead');
511
+ fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
512
+ fs.writeFileSync(
513
+ path.join(dir, 'package.json'),
514
+ JSON.stringify({ name: 'fixture', version: '12.0.0' }),
515
+ 'utf8',
516
+ );
517
+ fs.writeFileSync(
518
+ path.join(dir, 'docs', 'MIGRATION.md'),
519
+ '# Migration Guide\n\n## Planned removals\n\n| Surface | Removal due |\n|---|---|\n| `x` | 1.0 |\n',
520
+ 'utf8',
521
+ );
522
+ return runGateCli(repo, script, [], dir);
523
+ },
524
+ },
525
+ {
526
+ name: 'a future-dated row passes at a major cut',
527
+ expect: 'accept',
528
+ run: () =>
529
+ fixture(
530
+ 'future',
531
+ 12,
532
+ '| `later` | 2026-01-01 | 13.0 | 14.0 | none |',
533
+ ['--cutting', '13.0.0'],
534
+ ),
535
+ },
536
+ ];
537
+
538
+ try {
539
+ return runSelfTest({
540
+ gate: 'lint_scheduled_deprecations',
541
+ cases,
542
+ minCases: SELF_TEST_MIN_CASES,
543
+ minRejectCases: SELF_TEST_MIN_REJECT,
544
+ });
545
+ } finally {
546
+ fs.rmSync(root, { recursive: true, force: true });
547
+ }
548
+ }
549
+
550
+ export function main(argv: readonly string[]): number {
551
+ if (argv.includes('--self-test')) return selfTest();
552
+ const quiet = argv.includes('--quiet');
553
+ const root = repoRoot();
554
+
555
+ // `--cutting <X.Y.Z>` — the version being released. Present = refusal mode.
556
+ const cutIdx = argv.indexOf('--cutting');
557
+ let cutting: string | undefined;
558
+ if (cutIdx >= 0) {
559
+ cutting = argv[cutIdx + 1];
560
+ if (cutting === undefined || cutting.startsWith('--')) {
561
+ process.stderr.write('usage: lint_scheduled_deprecations [--cutting <X.Y.Z>] [--quiet]\n');
562
+ return 2;
563
+ }
564
+ }
565
+
566
+ // Shape-checked BEFORE any file work: a usage error cannot need the tree,
567
+ // and validating it after the parse let a HeaderMismatchError mask it with
568
+ // exit 1 where the caller had simply mistyped the version.
569
+ let cutMajor: number | undefined;
570
+ if (cutting !== undefined) {
571
+ const m = /^v?(\d+)\./.exec(cutting.trim());
572
+ if (!m) {
573
+ process.stderr.write(
574
+ `usage: --cutting expects a bare version (X.Y.Z), got "${cutting}"\n`,
575
+ );
576
+ return 2;
577
+ }
578
+ cutMajor = Number(m[1]);
579
+ }
580
+
581
+ const migration = path.join(root, MIGRATION_PATH);
582
+ if (!fs.existsSync(migration)) {
583
+ process.stderr.write(`❌ lint_scheduled_deprecations: ${MIGRATION_PATH} not found under ${root}\n`);
584
+ return 1;
585
+ }
586
+
587
+ let rows: DeprecationRow[];
588
+ try {
589
+ rows = parseRows(fs.readFileSync(migration, 'utf-8'));
590
+ } catch (exc) {
591
+ if (exc instanceof HeaderMismatchError) {
592
+ process.stderr.write(`❌ ${exc.message}\n`);
593
+ return 1;
594
+ }
595
+ throw exc;
596
+ }
597
+
598
+ // A missing or malformed package.json is an environment error, not a
599
+ // finding: without it the gate has no comparand at all. Reported as exit 2
600
+ // per the contract at the top of this file, rather than as an unlisted
601
+ // code and a stack trace.
602
+ let shipped: number;
603
+ try {
604
+ shipped = shippedMajor(root);
605
+ } catch (exc) {
606
+ process.stderr.write(
607
+ `❌ lint_scheduled_deprecations: cannot read the shipped major (environment, not a finding) — ` +
608
+ `${exc instanceof Error ? exc.message : String(exc)}\n`,
609
+ );
610
+ return 2;
611
+ }
612
+ const comparand = cutMajor ?? shipped;
613
+
614
+ const ledger = new GateLedger('lint_scheduled_deprecations');
615
+ const findings = evaluate(rows, comparand, root, ledger, cutting !== undefined);
616
+ const tally = ledger.finalize();
617
+
618
+ // Anti-vacuity. Zero rows means the heading moved or the table's format
619
+ // drifted — the failure this gate is about, so it is never a pass. No
620
+ // `allowEmpty`: an empty scheduled-deprecations table is not a success
621
+ // state while `MIGRATION.md` itself instructs that a promise without a row
622
+ // is untracked.
623
+ const compared = tally.completed + tally.failed;
624
+ try {
625
+ // The SCOPE assertion is on rows parsed: zero means the heading moved or
626
+ // the table's format drifted, which is the dead-scan-root case. How many
627
+ // of those rows the arithmetic actually reached is a different number,
628
+ // published beside it below — conflating the two is what let this gate
629
+ // report green over zero comparisons.
630
+ reportScanned({
631
+ gate: 'lint_scheduled_deprecations',
632
+ scanned: tally.planned,
633
+ units: 'scheduled-deprecation row(s)',
634
+ roots: [MIGRATION_PATH],
635
+ });
636
+ } catch (exc) {
637
+ if (exc instanceof DeadScopeError) {
638
+ process.stderr.write(`❌ ${exc.message}\n`);
639
+ return 1;
640
+ }
641
+ throw exc;
642
+ }
643
+
644
+ const unresolved = findings.filter((f) => f.kind === 'unresolved');
645
+ const overdue = findings.filter((f) => f.kind === 'overdue');
646
+
647
+ for (const f of unresolved) {
648
+ process.stderr.write(
649
+ `❌ lint_scheduled_deprecations: row ${String(f.row.index)} (${f.row.name}) — ` +
650
+ `removal-due cell not resolvable: "${f.row.removalDueRaw}"\n` +
651
+ ' The grammar is documented at the top of this gate. Either write a\n' +
652
+ ' concrete major, or record the row as not pinned, a permanent keep, or a\n' +
653
+ ' withdrawn commitment with its reason.\n' +
654
+ ' NOTE: the relative form ("the major after that") resolves against the\n' +
655
+ ' NOTICE cell, and a notice is written as a date once it ships — so a row\n' +
656
+ ' using it must be rewritten to a concrete major on notice day.\n',
657
+ );
658
+ }
659
+
660
+ const against =
661
+ cutting === undefined
662
+ ? `shipped ${String(shipped)}.x`
663
+ : `the ${String(comparand)}.0 cut (shipped is ${String(shipped)}.x)`;
664
+
665
+ for (const f of overdue) {
666
+ const late =
667
+ f.overdueBy === 0
668
+ ? 'comes due AT'
669
+ : `is ${String(f.overdueBy)} major(s) overdue against`;
670
+ process.stdout.write(
671
+ `⚠️ lint_scheduled_deprecations: ${f.row.name} — removal ${late} ${against}\n` +
672
+ ` removal due: ${f.row.removalDueRaw}\n`,
673
+ );
674
+ if (f.livePaths.length > 0) {
675
+ for (const p of f.livePaths) {
676
+ process.stdout.write(` still on disk: ${p}\n`);
677
+ }
678
+ } else {
679
+ process.stdout.write(
680
+ ' no path token in the row — the surface is named but its runtime\n' +
681
+ ' paths are not, so this gate cannot say whether they still ship.\n',
682
+ );
683
+ }
684
+ }
685
+
686
+ if (unresolved.length > 0) return 1;
687
+
688
+ if (overdue.length > 0) {
689
+ if (cutting !== undefined) {
690
+ process.stderr.write(
691
+ `\n❌ lint_scheduled_deprecations: ${String(overdue.length)} surface(s) due at or before ` +
692
+ `the ${String(comparand)}.0 cut.\n` +
693
+ ' Act on each row: perform the removal in its own change, or revise the\n' +
694
+ " row's commitment and record why the surface stays. A deliberate\n" +
695
+ ' deferral belongs in the table, not in a release decision nobody can read.\n',
696
+ );
697
+ return 1;
698
+ }
699
+ if (!quiet) {
700
+ process.stdout.write(
701
+ '\n Reported, not refused — this is an ordinary branch. `--cutting <X.Y.Z>`\n' +
702
+ ' turns the same finding into a refusal, measured against the TARGET major.\n',
703
+ );
704
+ }
705
+ return 0;
706
+ }
707
+
708
+ if (!quiet) {
709
+ ledger.report();
710
+ process.stdout.write(
711
+ `✅ lint_scheduled_deprecations: ${String(tally.planned)} row(s) parsed, ` +
712
+ `${String(compared)} compared against ${against}, none due` +
713
+ (compared === 0
714
+ ? ' — every row is a tracked state (not pinned / permanent keep / withdrawn),\n' +
715
+ ' so NO arithmetic ran this time. Green here means "nothing to compare",\n' +
716
+ ' not "the comparison passed".\n'
717
+ : '.\n'),
718
+ );
719
+ }
720
+ return 0;
721
+ }
722
+
723
+ function isCliEntry(): boolean {
724
+ if (process.argv[1] === undefined) return false;
725
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
726
+ if (import.meta.url === argvUrl) return true;
727
+ try {
728
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
729
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
730
+ return here === argv;
731
+ } catch {
732
+ return false;
733
+ }
734
+ }
735
+
736
+ if (isCliEntry()) {
737
+ process.exitCode = main(process.argv.slice(2));
738
+ }