@event4u/agent-config 9.22.0 → 9.24.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 (90) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +84 -238
  3. package/README.md +2 -2
  4. package/dist/agent-src/commands/agent-handoff.md +10 -3
  5. package/dist/agent-src/commands/analyze/inbox.md +190 -0
  6. package/dist/agent-src/commands/analyze.md +4 -3
  7. package/dist/agent-src/commands/optimize/deep.md +13 -0
  8. package/dist/agent-src/commands/pr/create.md +6 -6
  9. package/dist/agent-src/contexts/execution/cheap-question-mechanics.md +12 -2
  10. package/dist/agent-src/contexts/execution/mandated-lines.md +180 -0
  11. package/dist/agent-src/rules/context-hygiene.md +20 -0
  12. package/dist/agent-src/rules/downstream-changes.md +26 -0
  13. package/dist/agent-src/rules/think-before-action.md +25 -0
  14. package/dist/agent-src/rules/token-budget-discipline.md +46 -3
  15. package/dist/agent-src/rules/token-efficiency.md +13 -0
  16. package/dist/agent-src/rules/ui-audit-gate.md +33 -2
  17. package/dist/agent-src/skills/adversarial-review/SKILL.md +18 -4
  18. package/dist/agent-src/skills/decision-review/SKILL.md +49 -0
  19. package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +5 -1
  20. package/dist/agent-src/skills/judge-bug-hunter/SKILL.md +16 -0
  21. package/dist/agent-src/skills/judge-synthesis/SKILL.md +14 -1
  22. package/dist/agent-src/skills/rule-writing/SKILL.md +33 -0
  23. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +31 -0
  24. package/dist/agent-src/skills/skill-reviewer/SKILL.md +1 -1
  25. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  26. package/dist/cli/registry.js +1 -0
  27. package/dist/cli/registry.js.map +1 -1
  28. package/dist/cli-delegate/chunk-DVRS2KVR.js +4602 -0
  29. package/dist/cli-delegate/{chunk-ELMFOQPW.js → chunk-SJQMJI7M.js} +8 -4597
  30. package/dist/cli-delegate/cmd_export.js +2 -1
  31. package/dist/cli-delegate/cmd_prune.js +2 -1
  32. package/dist/cli-delegate/cmd_settings_migrate.js +2 -1
  33. package/dist/cli-delegate/cmd_settings_set.js +3370 -0
  34. package/dist/cli-delegate/cmd_sync.js +2 -1
  35. package/dist/cli-delegate/cmd_uninstall.js +2 -1
  36. package/dist/cli-delegate/cmd_validate.js +2 -1
  37. package/dist/discovery/deprecation-report.md +1 -1
  38. package/dist/discovery/discovery-manifest.json +54 -27
  39. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  40. package/dist/discovery/discovery-manifest.summary.md +3 -3
  41. package/dist/discovery/orphan-report.md +1 -1
  42. package/dist/discovery/packs.json +6 -5
  43. package/dist/discovery/trust-report.md +2 -2
  44. package/dist/discovery/workspaces.json +5 -4
  45. package/dist/hooks/dispatch.js +79 -1
  46. package/dist/mcp/registry-manifest.json +3 -3
  47. package/dist/mcp/server.json +2 -2
  48. package/dist/scripts/condense.js +47 -2
  49. package/dist/scripts/condense.js.map +1 -1
  50. package/dist/server/routes/settings.js +82 -0
  51. package/dist/server/routes/settings.js.map +1 -1
  52. package/dist/shared/settingsClasses.js +191 -0
  53. package/dist/shared/settingsClasses.js.map +1 -0
  54. package/docs/contracts/settings-classes.md +342 -0
  55. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +23 -2
  56. package/docs/guidelines/agent-infra/false-green.md +187 -0
  57. package/docs/guidelines/agent-infra/gate-authoring.md +177 -0
  58. package/package.json +3 -1
  59. package/src/config/ci-local-parity.yml +12 -0
  60. package/src/config/evaluator-budgets.json +4 -4
  61. package/src/config/gate-coverage.yml +54 -12
  62. package/src/config/gate-violation-baselines.json +6 -0
  63. package/src/config/pack-size-budget.json +4 -3
  64. package/src/scripts/_cli/cmd_settings_set.ts +509 -0
  65. package/src/scripts/_dispatch.bash +16 -0
  66. package/src/scripts/_lib/gate_ledger.ts +295 -0
  67. package/src/scripts/_lib/gate_result.ts +101 -0
  68. package/src/scripts/_lib/gate_self_test.ts +129 -0
  69. package/src/scripts/_lib/measured_render.ts +97 -0
  70. package/src/scripts/_lib/ratchet_base_ref.ts +373 -0
  71. package/src/scripts/check_gate_completeness.ts +244 -0
  72. package/src/scripts/check_gate_coverage.ts +35 -6
  73. package/src/scripts/check_references.ts +125 -15
  74. package/src/scripts/check_review_dispositions.ts +14 -1
  75. package/src/scripts/check_suppression_hygiene.ts +503 -0
  76. package/src/scripts/condense.ts +47 -2
  77. package/src/scripts/hook_manifest.yaml +23 -0
  78. package/src/scripts/hooks/dispatch_hook.ts +65 -1
  79. package/src/scripts/hooks/host_semantics.ts +140 -0
  80. package/src/scripts/lint_framework_leakage.ts +147 -15
  81. package/src/scripts/lint_mandated_lines.ts +339 -0
  82. package/src/scripts/lint_roadmap_complexity.ts +102 -11
  83. package/src/scripts/lint_roadmap_family_cap.ts +191 -0
  84. package/src/scripts/lint_settings_classes.ts +397 -0
  85. package/src/scripts/lint_token_budget_discipline.ts +109 -1
  86. package/src/scripts/measure_rule_absoluta.ts +231 -0
  87. package/src/scripts/report_imperative_density.ts +229 -0
  88. package/src/scripts/verify_physical_move.ts +10 -1
  89. package/src/server/io/yamlIO.ts +173 -0
  90. package/src/shared/settingsClasses.ts +235 -0
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Shrink-only, enforced against the base ref instead of against a count.
3
+ *
4
+ * WHY THIS EXISTS. Every ratchet in this tree is shrink-only *by convention*
5
+ * plus a count comparison — and a count comparison permits
6
+ * swap-one-out-add-one-in. Removing a real entry and adding a fresh suppression
7
+ * in the same change keeps the number flat, passes the gate, and moves the
8
+ * estate backwards. The count cannot see it, because the count is the same.
9
+ *
10
+ * The fix is to compare the entry *sets*, and to read the "before" side from
11
+ * the base ref rather than from a checked-in number that the same commit can
12
+ * edit. `git show <baseRef>:<path>` is the only reading of the baseline that
13
+ * the change under review cannot rewrite.
14
+ *
15
+ * **Renames are read from git.** Moving a file must not read as growth: an
16
+ * entry keyed on a path that git reports as a rename of a path present at base
17
+ * is the same entry, relocated. Without this, the first legitimate refactor
18
+ * that moves an allowlisted file blocks itself, the gate gets a reputation for
19
+ * false reds, and someone turns it off — the gate-fatigue failure this
20
+ * repository has already recorded.
21
+ *
22
+ * **A deliberate reset is reported, never silent.** A real tooling change can
23
+ * legitimately re-baseline. That path exists ({@link RATCHET_RESET_KEY}) and
24
+ * costs a visible one-line diff carrying a reason; what it does not do is let
25
+ * the reset pass unannounced.
26
+ */
27
+ import { spawnSync } from 'node:child_process';
28
+ import * as fs from 'node:fs';
29
+ import * as path from 'node:path';
30
+ import process from 'node:process';
31
+
32
+ /**
33
+ * Opt-in key that marks a baseline as deliberately re-based.
34
+ *
35
+ * Present with a non-empty string reason ⇒ growth is reported rather than
36
+ * thrown. The key lives in the baseline JSON itself so it shows up in the diff
37
+ * of the very commit that performs the reset.
38
+ */
39
+ export const RATCHET_RESET_KEY = '__ratchet_reset__';
40
+
41
+ /**
42
+ * Find a usable "before" ref, in descending order of trustworthiness.
43
+ *
44
+ * This ladder exists because the obvious answer — `origin/main` — is the one
45
+ * that is NOT available where it matters most. `actions/checkout` performs a
46
+ * shallow PR-merge fetch, so a PR build has no `origin/main` remote-tracking
47
+ * ref, and this repository has already recorded that `git fetch origin <base>`
48
+ * can race with the configured auth extraheader on some runners. The merge
49
+ * commit's first parent IS the base on exactly those builds, and it needs no
50
+ * network at all.
51
+ *
52
+ * Returns `null` when nothing resolves; callers decide, and the honest default
53
+ * is to fail rather than to compare against an assumed-empty base.
54
+ */
55
+ export function resolveBaseRef(
56
+ repoRoot: string,
57
+ env: NodeJS.ProcessEnv = process.env,
58
+ git: (args: readonly string[], cwd: string) => { ok: boolean; stdout: string; stderr: string } = runGit,
59
+ ): string | null {
60
+ const resolves = (ref: string): boolean =>
61
+ git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], repoRoot).ok;
62
+
63
+ const explicit = env['RATCHET_BASE_REF'];
64
+ if (explicit !== undefined && explicit !== '' && resolves(explicit)) {
65
+ return explicit;
66
+ }
67
+ const ghBase = env['GITHUB_BASE_REF'];
68
+ if (ghBase !== undefined && ghBase !== '') {
69
+ for (const candidate of [`origin/${ghBase}`, ghBase]) {
70
+ if (resolves(candidate)) {
71
+ return candidate;
72
+ }
73
+ }
74
+ }
75
+ // A PR-merge checkout: HEAD is the synthetic merge, HEAD^1 is the base tip.
76
+ //
77
+ // Gated on GITHUB_ACTIONS because "HEAD has two parents" is NOT specific to
78
+ // a PR-merge checkout: a feature branch that merged main back in has a merge
79
+ // commit at its tip too, and taking HEAD^1 there compares the branch against
80
+ // its own previous commit — a base so recent that every real change looks
81
+ // like growth. Observed on the first run of this resolver.
82
+ if (env['GITHUB_ACTIONS'] === 'true') {
83
+ const parents = git(['rev-list', '--parents', '-n', '1', 'HEAD'], repoRoot);
84
+ if (parents.ok && parents.stdout.trim().split(/\s+/).length >= 3 && resolves('HEAD^1')) {
85
+ return 'HEAD^1';
86
+ }
87
+ }
88
+ for (const candidate of ['origin/main', 'main']) {
89
+ if (resolves(candidate)) {
90
+ return candidate;
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /** Raised when the working copy carries entries the base ref does not. */
97
+ export class RatchetGrowthError extends Error {
98
+ readonly baselinePath: string;
99
+ readonly added: readonly string[];
100
+
101
+ constructor(baselinePath: string, baseRef: string, added: readonly string[]) {
102
+ const shown = added.slice(0, 20);
103
+ const more = added.length - shown.length;
104
+ super(
105
+ `${baselinePath}: ${String(added.length)} entry(ies) present in the working copy and ` +
106
+ `absent at ${baseRef} — this baseline is shrink-only, and a flat count is not ` +
107
+ 'evidence of that (removing one entry while adding another keeps the number ' +
108
+ `identical). Added: ${shown.join(', ')}${more > 0 ? ` … and ${String(more)} more` : ''}. ` +
109
+ `If this is a deliberate re-baseline, add "${RATCHET_RESET_KEY}": "<reason>" to the ` +
110
+ 'file in the same commit so the reset is reviewable instead of silent.',
111
+ );
112
+ this.name = 'RatchetGrowthError';
113
+ this.baselinePath = baselinePath;
114
+ this.added = added;
115
+ }
116
+ }
117
+
118
+ /** Raised when the base ref cannot be read, so no honest comparison exists. */
119
+ export class BaseRefUnavailableError extends Error {
120
+ constructor(baseRef: string, detail: string) {
121
+ super(
122
+ `cannot read the baseline at ${baseRef}: ${detail}. A ratchet that cannot see its ` +
123
+ '"before" has not verified anything — fetch the base ref (CI needs more than a ' +
124
+ 'depth-1 clone) rather than treating the missing side as empty.',
125
+ );
126
+ this.name = 'BaseRefUnavailableError';
127
+ }
128
+ }
129
+
130
+ export type RatchetVerdict = 'ok' | 'growth' | 'reset' | 'new_baseline';
131
+
132
+ export interface RatchetComparison {
133
+ verdict: RatchetVerdict;
134
+ /** Entries in the working copy that are absent at the base ref. */
135
+ added: string[];
136
+ /** Entries at the base ref that are absent in the working copy — the wanted direction. */
137
+ removed: string[];
138
+ /** Entries counted as unchanged because git reports the path as a rename. */
139
+ renamed: Array<{ from: string; to: string }>;
140
+ baseCount: number;
141
+ headCount: number;
142
+ /** Present only when the baseline carries {@link RATCHET_RESET_KEY}. */
143
+ resetReason?: string;
144
+ }
145
+
146
+ export interface RatchetBaseRefOptions {
147
+ /** Repo-relative path to the baseline file. */
148
+ baselinePath: string;
149
+ /** Ref to read the "before" side from, e.g. `origin/main`. */
150
+ baseRef: string;
151
+ /** Absolute repo root. */
152
+ repoRoot: string;
153
+ /**
154
+ * Extract the comparable entry keys from a parsed baseline.
155
+ *
156
+ * Defaults to {@link defaultEntriesOf}, which covers the two shapes this
157
+ * repo actually uses: a JSON array of strings, and a JSON object keyed by
158
+ * entry. A baseline with a nested shape passes its own extractor.
159
+ */
160
+ entriesOf?: (parsed: unknown) => string[];
161
+ /**
162
+ * Accept a baseline that does not exist at `baseRef` as genuinely new.
163
+ *
164
+ * Off by default: absent-at-base and mistyped-path look identical, and the
165
+ * silent-pass consequence of guessing wrong is the failure this module is
166
+ * built against. Turn it on for exactly the one change that introduces the
167
+ * baseline, then remove it.
168
+ */
169
+ allowNewBaseline?: boolean;
170
+ /** Injection seam for tests; defaults to a real `git` invocation. */
171
+ git?: (args: readonly string[], cwd: string) => { ok: boolean; stdout: string; stderr: string };
172
+ }
173
+
174
+ /** Run git, capturing output without throwing on a non-zero exit. */
175
+ function runGit(args: readonly string[], cwd: string): { ok: boolean; stdout: string; stderr: string } {
176
+ const res = spawnSync('git', [...args], { cwd, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 });
177
+ if (res.error) {
178
+ return { ok: false, stdout: '', stderr: res.error.message };
179
+ }
180
+ return { ok: res.status === 0, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
181
+ }
182
+
183
+ /**
184
+ * Entry keys for the two baseline shapes in this repository.
185
+ *
186
+ * - array of strings → the strings themselves;
187
+ * - array of objects → each object's `path`, `id`, `key`, `entry`, or `file`
188
+ * field, whichever it carries first, falling back to its stable JSON form;
189
+ * - object → its own keys.
190
+ *
191
+ * {@link RATCHET_RESET_KEY} is never an entry.
192
+ */
193
+ export function defaultEntriesOf(parsed: unknown): string[] {
194
+ if (Array.isArray(parsed)) {
195
+ return parsed.map((item) => {
196
+ if (typeof item === 'string') {
197
+ return item;
198
+ }
199
+ if (item !== null && typeof item === 'object') {
200
+ const rec = item as Record<string, unknown>;
201
+ for (const field of ['path', 'id', 'key', 'entry', 'file']) {
202
+ const v = rec[field];
203
+ if (typeof v === 'string' && v !== '') {
204
+ return v;
205
+ }
206
+ }
207
+ }
208
+ return JSON.stringify(item);
209
+ });
210
+ }
211
+ if (parsed !== null && typeof parsed === 'object') {
212
+ return Object.keys(parsed as Record<string, unknown>).filter((k) => k !== RATCHET_RESET_KEY);
213
+ }
214
+ return [];
215
+ }
216
+
217
+ function resetReasonOf(parsed: unknown): string | undefined {
218
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
219
+ const v = (parsed as Record<string, unknown>)[RATCHET_RESET_KEY];
220
+ if (typeof v === 'string' && v.trim() !== '') {
221
+ return v.trim();
222
+ }
223
+ }
224
+ return undefined;
225
+ }
226
+
227
+ /**
228
+ * Map every renamed path at HEAD back to its name at `baseRef`.
229
+ *
230
+ * `-M` is what makes a moved allowlist entry read as the same entry rather
231
+ * than as one deletion plus one addition.
232
+ */
233
+ function renameMap(
234
+ baseRef: string,
235
+ repoRoot: string,
236
+ git: NonNullable<RatchetBaseRefOptions['git']>,
237
+ ): Map<string, string> {
238
+ const out = new Map<string, string>();
239
+ const res = git(['diff', '--name-status', '-M', `${baseRef}...HEAD`], repoRoot);
240
+ if (!res.ok) {
241
+ return out;
242
+ }
243
+ for (const line of res.stdout.split('\n')) {
244
+ const parts = line.split('\t');
245
+ const status = parts[0];
246
+ if (status === undefined || !status.startsWith('R')) {
247
+ continue;
248
+ }
249
+ const from = parts[1];
250
+ const to = parts[2];
251
+ if (from !== undefined && to !== undefined) {
252
+ out.set(to, from);
253
+ }
254
+ }
255
+ return out;
256
+ }
257
+
258
+ /**
259
+ * Compare a baseline's entry set against the same file at `baseRef`.
260
+ *
261
+ * @throws {BaseRefUnavailableError} when the base ref cannot be read.
262
+ */
263
+ export function compareToBaseRef(opts: RatchetBaseRefOptions): RatchetComparison {
264
+ const git = opts.git ?? runGit;
265
+ const entriesOf = opts.entriesOf ?? defaultEntriesOf;
266
+ const absolute = path.join(opts.repoRoot, opts.baselinePath);
267
+
268
+ let headRaw: string;
269
+ try {
270
+ headRaw = fs.readFileSync(absolute, 'utf-8');
271
+ } catch (e) {
272
+ throw new BaseRefUnavailableError(opts.baseRef, `working-copy baseline unreadable: ${String(e)}`);
273
+ }
274
+
275
+ const refCheck = git(['rev-parse', '--verify', '--quiet', `${opts.baseRef}^{commit}`], opts.repoRoot);
276
+ if (!refCheck.ok) {
277
+ throw new BaseRefUnavailableError(opts.baseRef, 'ref does not resolve in this clone');
278
+ }
279
+
280
+ // A baseline absent at base is EITHER a genuinely new ratchet OR a typo in
281
+ // `baselinePath`. Those two are indistinguishable from here, and treating
282
+ // the missing side as an empty set would make a mistyped path pass forever
283
+ // while comparing against nothing — the dead-scan-root shape, one level up.
284
+ // So it is fail-closed, and introducing a new baseline is an explicit
285
+ // opt-in the reviewer sees at the call site.
286
+ const show = git(['show', `${opts.baseRef}:${opts.baselinePath}`], opts.repoRoot);
287
+ if (!show.ok && opts.allowNewBaseline !== true) {
288
+ throw new BaseRefUnavailableError(
289
+ opts.baseRef,
290
+ `no such file at that ref (${opts.baselinePath}). If the baseline is genuinely new in ` +
291
+ 'this change, pass allowNewBaseline; if it is not, the path is wrong and this gate ' +
292
+ 'would otherwise compare against an empty set forever',
293
+ );
294
+ }
295
+ const baseEntries = show.ok ? entriesOf(JSON.parse(show.stdout)) : [];
296
+
297
+ const headParsed: unknown = JSON.parse(headRaw);
298
+ const headEntries = entriesOf(headParsed);
299
+
300
+ const renames = renameMap(opts.baseRef, opts.repoRoot, git);
301
+ const baseSet = new Set(baseEntries);
302
+ const headSet = new Set(headEntries);
303
+
304
+ const added: string[] = [];
305
+ const renamed: Array<{ from: string; to: string }> = [];
306
+ for (const entry of headEntries) {
307
+ if (baseSet.has(entry)) {
308
+ continue;
309
+ }
310
+ const previous = renames.get(entry);
311
+ if (previous !== undefined && baseSet.has(previous)) {
312
+ renamed.push({ from: previous, to: entry });
313
+ continue;
314
+ }
315
+ added.push(entry);
316
+ }
317
+ const removed = baseEntries.filter((e) => !headSet.has(e) && !renamed.some((r) => r.from === e));
318
+
319
+ const resetReason = resetReasonOf(headParsed);
320
+ let verdict: RatchetVerdict;
321
+ if (!show.ok) {
322
+ verdict = 'new_baseline';
323
+ } else if (added.length === 0) {
324
+ verdict = 'ok';
325
+ } else {
326
+ verdict = resetReason !== undefined ? 'reset' : 'growth';
327
+ }
328
+
329
+ const comparison: RatchetComparison = {
330
+ verdict,
331
+ added,
332
+ removed,
333
+ renamed,
334
+ baseCount: baseEntries.length,
335
+ headCount: headEntries.length,
336
+ };
337
+ if (resetReason !== undefined) {
338
+ comparison.resetReason = resetReason;
339
+ }
340
+ return comparison;
341
+ }
342
+
343
+ /**
344
+ * Assert a baseline gained no entries relative to `baseRef`.
345
+ *
346
+ * @throws {RatchetGrowthError} on growth without a declared reset.
347
+ * @throws {BaseRefUnavailableError} when the base ref cannot be read.
348
+ */
349
+ export function assertNoNewEntries(opts: RatchetBaseRefOptions): RatchetComparison {
350
+ const comparison = compareToBaseRef(opts);
351
+ if (comparison.verdict === 'growth') {
352
+ throw new RatchetGrowthError(opts.baselinePath, opts.baseRef, comparison.added);
353
+ }
354
+ return comparison;
355
+ }
356
+
357
+ /**
358
+ * One-line human summary of a comparison, for a gate's green path.
359
+ *
360
+ * A reset is stated out loud, because a re-baseline that reads like a normal
361
+ * pass is the same silence this module removes.
362
+ */
363
+ export function describeComparison(baselinePath: string, c: RatchetComparison): string {
364
+ const base = `${baselinePath}: ${String(c.headCount)} entry(ies), ${String(c.baseCount)} at base`;
365
+ const moved = c.renamed.length > 0 ? `, ${String(c.renamed.length)} renamed` : '';
366
+ if (c.verdict === 'new_baseline') {
367
+ return `${base}${moved} — NEW BASELINE (absent at base; nothing to ratchet against yet)`;
368
+ }
369
+ if (c.verdict === 'reset') {
370
+ return `${base}${moved} — RE-BASELINED (+${String(c.added.length)}): ${c.resetReason ?? ''}`;
371
+ }
372
+ return `${base}${moved}, ${String(c.removed.length)} removed since base`;
373
+ }
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Ledger adoption across the CI-registered gate estate.
4
+ *
5
+ * `_lib/gate_ledger.ts` makes a gate's per-target accounting explicit, so that
6
+ * "found nothing" and "looked at almost nothing" stop being the same green
7
+ * line. A library nobody adopts changes nothing, and a 220-gate sweep in one
8
+ * change is exactly the unreviewable diff that gets rubber-stamped — so
9
+ * adoption is a **ratchet**, not a deadline: today's un-adopted count is
10
+ * recorded, and it may only go down.
11
+ *
12
+ * A gate satisfies this check by either
13
+ *
14
+ * 1. importing `./_lib/gate_ledger.js`, or
15
+ * 2. carrying a `// ledger-exempt: <reason>` marker naming why per-target
16
+ * accounting does not apply to it.
17
+ *
18
+ * The marker is deliberately cheap to write and expensive to write *badly*: an
19
+ * empty or near-empty reason is a hard failure rather than an exemption,
20
+ * because a marker that explains nothing is a suppression wearing a
21
+ * justification's clothes — the gate-fatigue failure this repository has
22
+ * already recorded.
23
+ *
24
+ * **Gaming risk** (per the authoring discipline this roadmap introduces): the
25
+ * cheapest degenerate pass is to sprinkle `// ledger-exempt:` markers with
26
+ * plausible-sounding boilerplate, which drops the count without improving a
27
+ * single gate. Mitigations, both partial and stated as such: the minimum
28
+ * reason length below rejects the laziest form, and the marker text lands in
29
+ * the diff where a reviewer sees it. What this gate CANNOT do is judge whether
30
+ * a reason is true; that stays a human read.
31
+ *
32
+ * **Lifecycle.** This gate lands ADVISORY over its existing population: the 217
33
+ * un-adopted gates are recorded as a baseline, not as 217 reds. It is already
34
+ * ENFORCED against growth from day one — a new gate must adopt or exempt.
35
+ * Promote to error over the whole population when the
36
+ * `check_gate_completeness` entry in `src/config/gate-violation-baselines.json`
37
+ * reaches 0.
38
+ *
39
+ * CLI contract: exit 0 = at or under the ratchet, 1 = regressed, marker
40
+ * malformed, or scope dead. `--quiet` suppresses the per-gate listing.
41
+ */
42
+ import * as fs from 'node:fs';
43
+ import * as path from 'node:path';
44
+ import { fileURLToPath, pathToFileURL } from 'node:url';
45
+
46
+ import { checkRatchet } from './_lib/gate_baseline.js';
47
+ import { GateLedger } from './_lib/gate_ledger.js';
48
+ import { renderCoverage } from './_lib/measured_render.js';
49
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
50
+ import { load_tasks, local_closure } from './check_ci_local_parity.js';
51
+
52
+ const _HERE = path.resolve(fileURLToPath(import.meta.url));
53
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
54
+ const QUIET = process.argv.slice(2).includes('--quiet');
55
+
56
+ /** Task roots whose transitive closure defines "registered in the CI task list". */
57
+ const TASK_ROOTS = ['ci', 'consistency'] as const;
58
+
59
+ /** The marker that opts a gate out of per-target accounting. */
60
+ export const EXEMPT_MARKER = '// ledger-exempt:';
61
+
62
+ /**
63
+ * Shortest reason accepted after the marker.
64
+ *
65
+ * Not a quality bar — no length can be one. It rejects the specific degenerate
66
+ * form this check invites: `// ledger-exempt: n/a`.
67
+ */
68
+ export const MIN_REASON_CHARS = 20;
69
+
70
+ export interface GateCompletenessRow {
71
+ id: string;
72
+ status: 'ledgered' | 'exempt' | 'unledgered' | 'malformed_exemption';
73
+ reason?: string;
74
+ }
75
+
76
+ const LEDGER_IMPORT_RE = /^import\s.*from\s+'\.\/_lib\/gate_ledger\.js';/m;
77
+ /**
78
+ * The marker must OPEN its own line.
79
+ *
80
+ * An unanchored match reads the marker out of prose — this gate's own
81
+ * docblock, which names the marker to explain it, exempted this gate from
82
+ * itself on the first run. A checker that its own documentation can silence is
83
+ * the false-green shape it exists to remove, so the anchor is load-bearing and
84
+ * is pinned by a fixture.
85
+ */
86
+ const EXEMPT_RE = /^[ \t]*\/\/[ \t]*ledger-exempt:(.*)$/m;
87
+
88
+ /** Classify one gate source. */
89
+ export function classifyGateSource(id: string, source: string): GateCompletenessRow {
90
+ if (LEDGER_IMPORT_RE.test(source)) {
91
+ return { id, status: 'ledgered' };
92
+ }
93
+ const m = EXEMPT_RE.exec(source);
94
+ if (m === null) {
95
+ return { id, status: 'unledgered' };
96
+ }
97
+ const reason = (m[1] ?? '').trim();
98
+ if (reason.length < MIN_REASON_CHARS) {
99
+ return { id, status: 'malformed_exemption', reason };
100
+ }
101
+ return { id, status: 'exempt', reason };
102
+ }
103
+
104
+ /** Every gate id reachable from the CI task roots, script present or not. */
105
+ export function registeredGateIds(repoRoot = REPO_ROOT): string[] {
106
+ return [...local_closure(TASK_ROOTS, load_tasks(repoRoot))].sort();
107
+ }
108
+
109
+ /**
110
+ * Classify every registered gate, accounting for the ones with no script.
111
+ *
112
+ * This gate dogfoods the ledger it enforces. The `.ts`-exists filter used to
113
+ * live in {@link registeredGateIds} as a silent `filter`, which meant a task
114
+ * pointing at a deleted script simply vanished from the denominator — the same
115
+ * invisible-skip shape the ledger exists to name. Now it is an out-of-scope
116
+ * outcome with a reason, and it prints.
117
+ */
118
+ export function auditGateCompleteness(repoRoot = REPO_ROOT): {
119
+ rows: GateCompletenessRow[];
120
+ ledger: GateLedger;
121
+ } {
122
+ const ledger = new GateLedger('check_gate_completeness');
123
+ const ids = registeredGateIds(repoRoot);
124
+ ledger.plan(ids);
125
+ const rows: GateCompletenessRow[] = [];
126
+ for (const id of ids) {
127
+ const file = path.join(repoRoot, 'src', 'scripts', `${id}.ts`);
128
+ if (!fs.existsSync(file)) {
129
+ ledger.outOfScope(id, 'manifest_absent');
130
+ continue;
131
+ }
132
+ const row = classifyGateSource(id, fs.readFileSync(file, 'utf-8'));
133
+ rows.push(row);
134
+ if (row.status === 'malformed_exemption') {
135
+ ledger.fail(id, 'exemption marker carries no usable reason');
136
+ } else {
137
+ ledger.complete(id);
138
+ }
139
+ }
140
+ return { rows, ledger };
141
+ }
142
+
143
+ export function main(): number {
144
+ const { rows, ledger } = auditGateCompleteness();
145
+
146
+ try {
147
+ assertScanned({
148
+ gate: 'check_gate_completeness',
149
+ scanned: rows.length,
150
+ units: 'registered gate(s)',
151
+ roots: ['Taskfile.yml → src/scripts'],
152
+ });
153
+ } catch (exc) {
154
+ if (exc instanceof DeadScopeError) {
155
+ process.stderr.write(`❌ ${exc.message}\n`);
156
+ return 1;
157
+ }
158
+ throw exc;
159
+ }
160
+
161
+ const malformed = rows.filter((r) => r.status === 'malformed_exemption');
162
+ const unledgered = rows.filter((r) => r.status === 'unledgered');
163
+ const exempt = rows.filter((r) => r.status === 'exempt');
164
+ const ledgered = rows.filter((r) => r.status === 'ledgered');
165
+
166
+ // The denominator on the green path — auditable without waiting for a red —
167
+ // and the GAP LIST beside it. A coverage number published alone lets the
168
+ // reader assume the remainder is small and boring; naming it costs one line.
169
+ process.stdout.write(
170
+ `gate completeness: ${String(ledgered.length)} ledgered · ${String(exempt.length)} exempt · ` +
171
+ `${String(unledgered.length)} un-adopted · ${String(rows.length)} registered\n`,
172
+ );
173
+ process.stdout.write(
174
+ `${renderCoverage({
175
+ label: ' ledger coverage',
176
+ covered: ledgered.length + exempt.length,
177
+ total: rows.length,
178
+ gaps: unledgered.map((r) => r.id),
179
+ maxGaps: 6,
180
+ })}\n`,
181
+ );
182
+ ledger.report();
183
+ process.stdout.write(`scanned: ${String(rows.length)}\n`);
184
+
185
+ if (malformed.length > 0) {
186
+ process.stderr.write(
187
+ `\n❌ ${String(malformed.length)} exemption marker(s) carry no usable reason ` +
188
+ `(minimum ${String(MIN_REASON_CHARS)} characters). An exemption that explains ` +
189
+ 'nothing is a suppression:\n',
190
+ );
191
+ for (const row of malformed) {
192
+ process.stderr.write(` - ${row.id}: "${row.reason ?? ''}"\n`);
193
+ }
194
+ return 1;
195
+ }
196
+
197
+ const verdict = checkRatchet({
198
+ gate: 'check_gate_completeness',
199
+ actual: unledgered.length,
200
+ repoRoot: REPO_ROOT,
201
+ });
202
+
203
+ if (!QUIET && unledgered.length > 0 && verdict.status !== 'within') {
204
+ process.stdout.write('\nun-adopted gates:\n');
205
+ for (const row of unledgered) {
206
+ process.stdout.write(` - ${row.id}\n`);
207
+ }
208
+ }
209
+
210
+ if (verdict.ok) {
211
+ if (!QUIET) {
212
+ process.stdout.write(`\n⚠️ ${verdict.message}\n`);
213
+ }
214
+ return 0;
215
+ }
216
+ process.stderr.write(`\n❌ ${verdict.message}\n`);
217
+ return 1;
218
+ }
219
+
220
+ function _isCliEntry(): boolean {
221
+ if (process.argv[1] === undefined) {
222
+ return false;
223
+ }
224
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
225
+ if (import.meta.url === argvUrl) {
226
+ return true;
227
+ }
228
+ // A symlinked invocation (installed projection, or macOS /var → /private/var
229
+ // temp dirs) makes the raw URLs differ: compare realpaths so the entry guard
230
+ // still fires instead of silently no-opping.
231
+ try {
232
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
233
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
234
+ return here === argv;
235
+ } catch {
236
+ return false;
237
+ }
238
+ }
239
+
240
+ if (_isCliEntry() || process.argv[1] === _HERE) {
241
+ process.exitCode = main();
242
+ }
243
+
244
+ export { QUIET, TASK_ROOTS };