dev-loops 0.7.1 → 0.7.2

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 (86) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +7 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +2 -2
  5. package/.claude/agents/review.md +2 -2
  6. package/.claude/commands/loop-enqueue.md +1 -1
  7. package/.claude/commands/loop-info.md +1 -1
  8. package/.claude/commands/loop-start-spike.md +1 -1
  9. package/.claude/skills/copilot-pr-followup/SKILL.md +37 -23
  10. package/.claude/skills/dev-loop/SKILL.md +6 -6
  11. package/.claude/skills/docs/acceptance-criteria-verification.md +3 -2
  12. package/.claude/skills/docs/anti-patterns.md +3 -2
  13. package/.claude/skills/docs/artifact-authority-contract.md +12 -11
  14. package/.claude/skills/docs/confirmation-rules.md +1 -0
  15. package/.claude/skills/docs/contract-style-guide.md +37 -0
  16. package/.claude/skills/docs/copilot-loop-operations.md +18 -14
  17. package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
  18. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  19. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  20. package/.claude/skills/docs/issue-intake-procedure.md +25 -19
  21. package/.claude/skills/docs/merge-preconditions.md +13 -11
  22. package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
  23. package/.claude/skills/docs/public-dev-loop-contract.md +59 -43
  24. package/.claude/skills/docs/retrospective-checkpoint-contract.md +18 -5
  25. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  26. package/.claude/skills/docs/stop-conditions.md +30 -15
  27. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  28. package/.claude/skills/docs/validation-policy.md +4 -5
  29. package/.claude/skills/local-implementation/SKILL.md +48 -142
  30. package/CHANGELOG.md +6 -0
  31. package/agents/dev-loop.agent.md +8 -8
  32. package/agents/fixer.agent.md +1 -0
  33. package/agents/refiner.agent.md +2 -2
  34. package/agents/review.agent.md +2 -2
  35. package/package.json +5 -4
  36. package/scripts/_core-helpers.mjs +2 -0
  37. package/scripts/claude/headless-dev-loop.mjs +1 -1
  38. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  39. package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
  40. package/scripts/github/_review-thread-mutations.mjs +5 -2
  41. package/scripts/github/create-pr.mjs +119 -10
  42. package/scripts/github/detect-checkpoint-evidence.mjs +102 -17
  43. package/scripts/github/list-review-threads.mjs +277 -0
  44. package/scripts/github/post-gate-findings.mjs +6 -3
  45. package/scripts/github/request-copilot-review.mjs +92 -26
  46. package/scripts/github/upsert-checkpoint-verdict.mjs +74 -5
  47. package/scripts/github/verify-briefing-prefixes.mjs +191 -0
  48. package/scripts/github/verify-fresh-review-context.mjs +78 -3
  49. package/scripts/github/wait-pr-checks.mjs +171 -0
  50. package/scripts/github/write-gate-context.mjs +213 -7
  51. package/scripts/github/write-gate-findings-log.mjs +54 -2
  52. package/scripts/loop/copilot-pr-handoff.mjs +29 -2
  53. package/scripts/loop/detect-change-scope.mjs +2 -2
  54. package/scripts/loop/detect-copilot-loop-state.mjs +23 -3
  55. package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -3
  56. package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
  57. package/scripts/loop/sanctioned-commands.mjs +2 -0
  58. package/scripts/loop/validate-pr-body-spec.mjs +21 -5
  59. package/scripts/pages/build-state-atlas.mjs +50 -48
  60. package/scripts/projects/_resolve-project.mjs +1 -148
  61. package/scripts/projects/list-queue-items.mjs +3 -377
  62. package/scripts/projects/move-queue-item.mjs +3 -410
  63. package/scripts/projects/reorder-queue-item.mjs +3 -22
  64. package/skills/copilot-pr-followup/SKILL.md +37 -23
  65. package/skills/dev-loop/SKILL.md +1 -1
  66. package/skills/docs/acceptance-criteria-verification.md +3 -2
  67. package/skills/docs/anti-patterns.md +3 -2
  68. package/skills/docs/artifact-authority-contract.md +12 -11
  69. package/skills/docs/confirmation-rules.md +1 -0
  70. package/skills/docs/contract-style-guide.md +37 -0
  71. package/skills/docs/copilot-loop-operations.md +18 -14
  72. package/skills/docs/cross-harness-regression-contract.md +2 -2
  73. package/skills/docs/debt-remediation-contract.md +1 -1
  74. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  75. package/skills/docs/issue-intake-procedure.md +25 -19
  76. package/skills/docs/merge-preconditions.md +13 -11
  77. package/skills/docs/pr-lifecycle-contract.md +44 -35
  78. package/skills/docs/public-dev-loop-contract.md +59 -43
  79. package/skills/docs/required-rules.json +149 -0
  80. package/skills/docs/retrospective-checkpoint-contract.md +18 -5
  81. package/skills/docs/spike-mode-contract.md +14 -7
  82. package/skills/docs/stop-conditions.md +30 -15
  83. package/skills/docs/tracker-first-loop-state.md +13 -8
  84. package/skills/docs/validation-policy.md +4 -5
  85. package/skills/local-implementation/SKILL.md +48 -142
  86. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
@@ -0,0 +1,442 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * validate-rule-ownership.mjs — L0/L1 contract rule ownership validator.
4
+ *
5
+ * This scan is deliberately lexical. It catches stable rule-ID ownership,
6
+ * required-rule deletion, machine-checkable references, defined-term drift,
7
+ * RFC-2119 modality conflicts, near-duplicate rule bodies, and duplicated
8
+ * free-text imperative sentences across the corpus. All findings are gating
9
+ * (exit 1). Behavioral contradictions belong to the L2/L3 harness; semantic
10
+ * contradictions belong to the gate contradiction lens.
11
+ *
12
+ * Also gates corpus→manifest completeness: every defined rule must be listed
13
+ * in required-rules.json's requiredRules or its optOutRules (unregistered_rule),
14
+ * the optOutRules manifest itself must be internally consistent (no ID in both
15
+ * lists — conflicting_manifest_entry; no opt-out for an undefined rule —
16
+ * dead_opt_out_entry), and the duplicate-sentence allowlist must not contain
17
+ * stale entries (dead_allowlist_entry).
18
+ *
19
+ * Subsumes former scripts/docs/validate-no-duplicate-rules.mjs (retired):
20
+ * the duplicate-imperative-sentence scan below ports its unique check,
21
+ * widened from skills/ only to every SOURCE_ROOTS directory.
22
+ */
23
+
24
+ import { readFile, readdir } from "node:fs/promises";
25
+ import path from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ import { isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
29
+
30
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
+ const REPO_ROOT = path.resolve(__dirname, "..", "..");
32
+
33
+ const SOURCE_ROOTS = ["skills", "agents", "commands", "docs"];
34
+ const MARKER_RE = /<!--\s*rule:\s*([A-Z][A-Z0-9-]*)\s*-->/g;
35
+ const REF_RE = /<!--\s*rule-ref:\s*([A-Z][A-Z0-9-]*)\s*-->|\[([A-Z0-9]+(?:-[A-Z0-9]+){2,})\]\([^)]*\)/g;
36
+ const TERM_RE = /<!--\s*term:\s*(state|reason|gate):([a-zA-Z0-9_.:-]+)\s*-->/g;
37
+ const CODE_TOKEN_RE = /`([a-z][a-z0-9_:-]+)`/g;
38
+ const MODAL_RE = /\b(MUST NOT|SHALL NOT|SHOULD NOT|MAY NOT|MUST|SHALL|SHOULD|MAY)\b/g;
39
+
40
+ // Duplicate-imperative-sentence scan (ported from validate-no-duplicate-rules.mjs).
41
+ const IMPERATIVE_PATTERNS = [/\bmust\b/i, /\bnever\b/i, /\bdo not\b/i, /\brequire[sd]?\b/i];
42
+ const MIN_SENTENCE_LENGTH = 20;
43
+
44
+ // Contract docs that mirror each other's content by design.
45
+ const CANONICAL_MIRROR_DOCS = new Set(["skills/docs/copilot-loop-operations.md", "skills/docs/public-dev-loop-contract.md"]);
46
+
47
+ // Sentences deliberately duplicated across files (mirrored procedure text, or
48
+ // command trigger-phrasing that STYLE/(c) guardrails require to stay verbatim
49
+ // per file). Cross-file duplication of these is not a corpus authoring bug.
50
+ const KNOWN_INTENTIONAL_DUPLICATE_SENTENCES = new Set([
51
+ // agents/dev-loop.agent.md mirrors this CLI-fallback safety line from skills/dev-loop/SKILL.md;
52
+ // each doc's surrounding candidate-resolution list differs per install layout.
53
+ "NEVER fall back to or any unbounded filesystem walk to locate the CLI — it stalls and trips the needs-attention timeout.",
54
+ // commands/loop-auto.command.md and commands/loop-start.command.md — command trigger
55
+ // phrasing is load-bearing verbatim per-command guardrail (c); not a restatement bug.
56
+ "Do not pick an internal strategy name yourself.",
57
+ // docs/slides-story-review-loop.md and docs/ui-designer-review-loop.md are sibling
58
+ // non-normative review-loop docs (epic non-goal) that share boilerplate by design.
59
+ "The loop requires all of the following inputs before it may run:",
60
+ "If any required part of this bundle is missing, incomplete, or ambiguous, the loop fails closed instead of guessing.",
61
+ "- required acceptance criteria are missing",
62
+ ]);
63
+
64
+ const USAGE = `Usage: validate-rule-ownership.mjs [--help]
65
+
66
+ Validate rule markers, required IDs, rule references, term definitions,
67
+ near-duplicate/modality-conflict findings, and duplicated imperative
68
+ sentences. Also validates corpus→manifest completeness (every defined rule
69
+ must be in requiredRules or explicitly opted out via optOutRules in
70
+ required-rules.json), optOutRules manifest hygiene (no rule listed as both
71
+ required and opted-out; no opt-out for an undefined rule), and that the
72
+ duplicate-sentence allowlist has no stale (dead) entries. All findings are
73
+ gating (exit 1).
74
+
75
+ Options:
76
+ --help, -h Show this help`.trim();
77
+
78
+ export function isImperativeSentence(sentence) {
79
+ return IMPERATIVE_PATTERNS.some((pattern) => pattern.test(sentence));
80
+ }
81
+
82
+ export function normalizeSentence(text) {
83
+ return text.replace(/\s+/g, " ").trim();
84
+ }
85
+
86
+ export function extractImperativeSentences(content) {
87
+ const lines = content.split(/\r?\n/);
88
+ const sentences = [];
89
+ let inFencedBlock = false;
90
+ let fencedDelimiter = "";
91
+
92
+ for (let i = 0; i < lines.length; i++) {
93
+ let line = lines[i];
94
+ const rawTrimmed = line.trim();
95
+
96
+ if (/^\s*#/.test(line) || /^\s*>/.test(line)) continue;
97
+
98
+ const fenceMatch = rawTrimmed.match(/^(```|~~~)/);
99
+ if (fenceMatch) {
100
+ if (!inFencedBlock) {
101
+ inFencedBlock = true;
102
+ fencedDelimiter = fenceMatch[1];
103
+ continue;
104
+ } else if (rawTrimmed.startsWith(fencedDelimiter)) {
105
+ inFencedBlock = false;
106
+ fencedDelimiter = "";
107
+ continue;
108
+ }
109
+ }
110
+ if (inFencedBlock) continue;
111
+
112
+ line = line.replace(/`[^`]*`/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
113
+
114
+ const parts = line.split(/(?<=[.!?])\s+(?=[A-Z])/);
115
+ for (const part of parts) {
116
+ const normalized = normalizeSentence(part);
117
+ if (normalized.length >= MIN_SENTENCE_LENGTH && isImperativeSentence(normalized)) {
118
+ sentences.push({ text: normalized, line: i + 1 });
119
+ }
120
+ }
121
+ }
122
+ return sentences;
123
+ }
124
+
125
+ export function detectDuplicateImperativeSentences(fileContents) {
126
+ const bySentence = new Map();
127
+ for (const { file, content } of fileContents) {
128
+ for (const { text, line } of extractImperativeSentences(content)) {
129
+ if (!bySentence.has(text)) bySentence.set(text, []);
130
+ bySentence.get(text).push({ file, line });
131
+ }
132
+ }
133
+ const findings = [];
134
+ for (const [text, occurrences] of bySentence) {
135
+ if (KNOWN_INTENTIONAL_DUPLICATE_SENTENCES.has(text)) continue;
136
+ const files = new Set(occurrences.map((o) => o.file));
137
+ if (files.size <= 1) continue;
138
+ if ([...files].every((f) => CANONICAL_MIRROR_DOCS.has(f))) continue;
139
+ findings.push({ kind: "duplicate_imperative_sentence", text, occurrences });
140
+ }
141
+ return findings;
142
+ }
143
+
144
+ function toPosix(p) {
145
+ return p.replace(/\\/g, "/");
146
+ }
147
+
148
+ async function* walk(dir) {
149
+ let entries;
150
+ try {
151
+ entries = await readdir(dir, { withFileTypes: true });
152
+ } catch (err) {
153
+ if (err?.code === "ENOENT") return;
154
+ throw err;
155
+ }
156
+ for (const entry of entries) {
157
+ if (entry.name === "node_modules" || entry.name === "tmp" || entry.name === "site" || entry.name === ".claude") {
158
+ continue;
159
+ }
160
+ const full = path.join(dir, entry.name);
161
+ if (entry.isSymbolicLink()) continue;
162
+ if (entry.isDirectory()) yield* walk(full);
163
+ else if (entry.isFile() && entry.name.endsWith(".md")) yield full;
164
+ }
165
+ }
166
+
167
+ export async function collectContractMarkdownFiles(repoRoot = REPO_ROOT) {
168
+ const files = [];
169
+ for (const root of SOURCE_ROOTS) {
170
+ for await (const file of walk(path.join(repoRoot, root))) {
171
+ files.push(file);
172
+ }
173
+ }
174
+ return files.sort();
175
+ }
176
+
177
+ function lineFor(content, index) {
178
+ return content.slice(0, index).split(/\r?\n/).length;
179
+ }
180
+
181
+ function stripFences(content) {
182
+ const lines = content.split(/\r?\n/);
183
+ let inFence = false;
184
+ let fence = "";
185
+ return lines.map((line) => {
186
+ const m = line.trim().match(/^(```|~~~)/);
187
+ if (m) {
188
+ if (!inFence) {
189
+ inFence = true;
190
+ fence = m[1];
191
+ } else if (line.trim().startsWith(fence)) {
192
+ inFence = false;
193
+ fence = "";
194
+ }
195
+ return "";
196
+ }
197
+ return inFence ? "" : line;
198
+ }).join("\n");
199
+ }
200
+
201
+ function extractRuleBody(content, markerIndex) {
202
+ const after = content.slice(markerIndex).replace(MARKER_RE, "").trimStart();
203
+ const firstLine = after.split(/\r?\n/, 1)[0].trim();
204
+ return firstLine.replace(/^`[A-Z][A-Z0-9-]*`\s*\|\s*/, "").replace(/\s+/g, " ").trim();
205
+ }
206
+
207
+ export function extractRuleDefinitions(content, file) {
208
+ const defs = [];
209
+ const scan = stripFences(content);
210
+ for (const match of scan.matchAll(MARKER_RE)) {
211
+ defs.push({ id: match[1], file, line: lineFor(scan, match.index), body: extractRuleBody(scan, match.index) });
212
+ }
213
+ return defs;
214
+ }
215
+
216
+ export function extractRuleReferences(content, file) {
217
+ const refs = [];
218
+ const scan = stripFences(content);
219
+ for (const match of scan.matchAll(REF_RE)) {
220
+ const id = match[1] || match[2];
221
+ refs.push({ id, file, line: lineFor(scan, match.index) });
222
+ }
223
+ return refs;
224
+ }
225
+
226
+ export function extractTermDefinitions(content, file) {
227
+ const terms = [];
228
+ const scan = stripFences(content);
229
+ for (const match of scan.matchAll(TERM_RE)) {
230
+ terms.push({ kind: match[1], value: match[2], key: `${match[1]}:${match[2]}`, file, line: lineFor(scan, match.index) });
231
+ }
232
+ return terms;
233
+ }
234
+
235
+ export function extractTermUses(content, file) {
236
+ const terms = extractTermDefinitions(content, file);
237
+ if (terms.length === 0 && !/<!--\s*rule:\s*[A-Z][A-Z0-9-]*\s*-->/u.test(stripFences(content))) return [];
238
+ const defs = new Set(terms.map((t) => t.value));
239
+ const uses = [];
240
+ const scan = stripFences(content);
241
+ for (const match of scan.matchAll(CODE_TOKEN_RE)) {
242
+ const token = match[1];
243
+ if (defs.has(token) || /^(npm|node|gh|dev-loop|dev-loops|final_approval|wait_watch|issue_intake|copilot_pr_followup|autonomy|humanMergeOnly)$/.test(token)) {
244
+ continue;
245
+ }
246
+ if (/^(blocked|done|approval_ready|merge_ready|waiting|waiting_for_[a-z0-9_]+|needs_reconcile|[a-z]+_[a-z0-9_]*_gate)$/.test(token)) {
247
+ uses.push({ token, file, line: lineFor(scan, match.index) });
248
+ }
249
+ }
250
+ return uses;
251
+ }
252
+
253
+ export function detectNearDuplicates(definitions) {
254
+ const seen = new Map();
255
+ const findings = [];
256
+ for (const def of definitions) {
257
+ const normalized = def.body.toLowerCase().replace(/`[^`]+`/g, "").replace(/\b(must|shall|should|may|not)\b/g, "").replace(/[^a-z0-9]+/g, " ").trim();
258
+ if (!normalized || normalized.length < 24) continue;
259
+ const prior = seen.get(normalized);
260
+ if (prior && prior.id !== def.id) findings.push({ kind: "near_duplicate", a: prior, b: def });
261
+ else seen.set(normalized, def);
262
+ }
263
+ return findings;
264
+ }
265
+
266
+ export function detectModalityConflicts(definitions) {
267
+ // Order-insensitive: group every definition per normalized subject first,
268
+ // then compare all pairs, so file-walk order can never mask a conflict
269
+ // (this scan gates the build).
270
+ const bySubject = new Map();
271
+ for (const def of definitions) {
272
+ const modalities = [...def.body.matchAll(MODAL_RE)].map((m) => m[1]);
273
+ if (modalities.length === 0) continue;
274
+ const subject = def.body
275
+ .replace(MODAL_RE, "")
276
+ .replace(/`[^`]+`/g, "")
277
+ .replace(/[^a-zA-Z0-9]+/g, " ")
278
+ .toLowerCase()
279
+ .trim();
280
+ if (!subject) continue;
281
+ if (!bySubject.has(subject)) bySubject.set(subject, []);
282
+ bySubject.get(subject).push({ def, modalities });
283
+ }
284
+ const findings = [];
285
+ for (const entries of bySubject.values()) {
286
+ for (let i = 0; i < entries.length; i += 1) {
287
+ for (let j = i + 1; j < entries.length; j += 1) {
288
+ const a = entries[i];
289
+ const b = entries[j];
290
+ const aNegative = a.modalities.some((m) => m.endsWith("NOT"));
291
+ const bNegative = b.modalities.some((m) => m.endsWith("NOT"));
292
+ // MUST and SHALL are equivalent strong forms (RFC 2119); either
293
+ // downgrading to SHOULD/MAY (or the negative equivalents) flags.
294
+ const strongPositive = (mods) => mods.some((m) => m === "MUST" || m === "SHALL");
295
+ const strongNegative = (mods) => mods.some((m) => m === "MUST NOT" || m === "SHALL NOT");
296
+ const weakPositive = (mods) => mods.some((m) => m === "SHOULD" || m === "MAY");
297
+ const weakNegative = (mods) => mods.some((m) => m === "SHOULD NOT" || m === "MAY NOT");
298
+ const weaker = (strongPositive(a.modalities) && weakPositive(b.modalities))
299
+ || (strongPositive(b.modalities) && weakPositive(a.modalities))
300
+ || (strongNegative(a.modalities) && weakNegative(b.modalities))
301
+ || (strongNegative(b.modalities) && weakNegative(a.modalities));
302
+ if (aNegative !== bNegative || weaker) findings.push({ kind: "modality_conflict", a: a.def, b: b.def });
303
+ }
304
+ }
305
+ }
306
+ return findings;
307
+ }
308
+
309
+ async function readRequiredRules(repoRoot) {
310
+ const rulesPath = path.join(repoRoot, "skills", "docs", "required-rules.json");
311
+ const text = await readFile(rulesPath, "utf8");
312
+ let parsed;
313
+ try {
314
+ parsed = parseJsonText(text);
315
+ } catch (error) {
316
+ throw new Error(`Malformed JSON in ${rulesPath}`, { cause: error });
317
+ }
318
+ return {
319
+ requiredRules: Array.isArray(parsed.requiredRules) ? parsed.requiredRules : [],
320
+ // Explicit opt-out for a defined rule that is intentionally NOT deletion-protected. Empty by default.
321
+ optOutRules: Array.isArray(parsed.optOutRules) ? parsed.optOutRules : [],
322
+ };
323
+ }
324
+
325
+ // A KNOWN_INTENTIONAL_DUPLICATE_SENTENCES entry is "dead" when it no longer suppresses a real
326
+ // cross-file duplicate: the sentence would not be flagged even without the allowlist (absent,
327
+ // single-file, or only across canonical-mirror docs). A dead entry silently pre-authorizes
328
+ // reintroducing that exact duplicate, so it gates.
329
+ export function detectDeadAllowlistEntries(fileContents, allowlist = KNOWN_INTENTIONAL_DUPLICATE_SENTENCES) {
330
+ const filesBySentence = new Map();
331
+ for (const { file, content } of fileContents) {
332
+ for (const { text } of extractImperativeSentences(content)) {
333
+ if (!filesBySentence.has(text)) filesBySentence.set(text, new Set());
334
+ filesBySentence.get(text).add(file);
335
+ }
336
+ }
337
+ const dead = [];
338
+ for (const text of allowlist) {
339
+ const files = filesBySentence.get(text);
340
+ const wouldFlag = files && files.size > 1 && ![...files].every((f) => CANONICAL_MIRROR_DOCS.has(f));
341
+ if (!wouldFlag) dead.push(text);
342
+ }
343
+ return dead;
344
+ }
345
+
346
+ export async function validateRuleOwnership(repoRoot = REPO_ROOT) {
347
+ const files = await collectContractMarkdownFiles(repoRoot);
348
+ const definitions = [];
349
+ const references = [];
350
+ const termDefs = [];
351
+ const termUses = [];
352
+ const fileContents = [];
353
+
354
+ for (const filePath of files) {
355
+ const rel = toPosix(path.relative(repoRoot, filePath));
356
+ const content = await readFile(filePath, "utf8");
357
+ fileContents.push({ file: rel, content });
358
+ definitions.push(...extractRuleDefinitions(content, rel));
359
+ references.push(...extractRuleReferences(content, rel));
360
+ termDefs.push(...extractTermDefinitions(content, rel));
361
+ termUses.push(...extractTermUses(content, rel));
362
+ }
363
+
364
+ const errors = [];
365
+ const byId = new Map();
366
+ for (const def of definitions) {
367
+ if (!byId.has(def.id)) byId.set(def.id, []);
368
+ byId.get(def.id).push(def);
369
+ }
370
+ for (const [id, defs] of byId) {
371
+ if (defs.length !== 1) errors.push({ kind: "duplicate_rule_definition", id, locations: defs.map(({ file, line }) => `${file}:${line}`) });
372
+ }
373
+
374
+ const { requiredRules, optOutRules } = await readRequiredRules(repoRoot);
375
+ const requiredSet = new Set(requiredRules);
376
+ const optOutSet = new Set(optOutRules);
377
+ for (const id of requiredRules) {
378
+ if (!byId.has(id)) errors.push({ kind: "required_rule_missing", id });
379
+ }
380
+ // corpus→manifest completeness: every defined rule must be registered in the manifest or explicitly opted out.
381
+ for (const [id, defs] of byId) {
382
+ if (!requiredSet.has(id) && !optOutSet.has(id)) {
383
+ errors.push({ kind: "unregistered_rule", id, location: defs.map(({ file, line }) => `${file}:${line}`).join(", ") });
384
+ }
385
+ }
386
+ // optOutRules manifest hygiene, in file order for deterministic error ordering.
387
+ for (const id of optOutRules) {
388
+ if (requiredSet.has(id)) errors.push({ kind: "conflicting_manifest_entry", id });
389
+ if (!byId.has(id)) errors.push({ kind: "dead_opt_out_entry", id });
390
+ }
391
+
392
+ for (const ref of references) {
393
+ if (!byId.has(ref.id)) errors.push({ kind: "unresolved_rule_reference", id: ref.id, location: `${ref.file}:${ref.line}` });
394
+ }
395
+
396
+ const termsByKey = new Map();
397
+ for (const term of termDefs) {
398
+ if (!termsByKey.has(term.key)) termsByKey.set(term.key, []);
399
+ termsByKey.get(term.key).push(term);
400
+ }
401
+ for (const [key, defs] of termsByKey) {
402
+ if (defs.length !== 1) errors.push({ kind: "duplicate_term_definition", key, locations: defs.map(({ file, line }) => `${file}:${line}`) });
403
+ }
404
+ const termValues = new Set(termDefs.map((t) => t.value));
405
+ for (const use of termUses) {
406
+ if (!termValues.has(use.token)) errors.push({ kind: "undefined_term_use", token: use.token, location: `${use.file}:${use.line}` });
407
+ }
408
+
409
+ for (const finding of detectNearDuplicates(definitions)) {
410
+ errors.push({ kind: finding.kind, id: `${finding.a.id}~${finding.b.id}`, location: `${finding.a.file}:${finding.a.line} / ${finding.b.file}:${finding.b.line}` });
411
+ }
412
+ for (const finding of detectModalityConflicts(definitions)) {
413
+ errors.push({ kind: finding.kind, id: `${finding.a.id}~${finding.b.id}`, location: `${finding.a.file}:${finding.a.line} / ${finding.b.file}:${finding.b.line}` });
414
+ }
415
+ for (const finding of detectDuplicateImperativeSentences(fileContents)) {
416
+ errors.push({ kind: finding.kind, id: finding.text, location: finding.occurrences.map((o) => `${o.file}:${o.line}`).join(", ") });
417
+ }
418
+ for (const text of detectDeadAllowlistEntries(fileContents)) {
419
+ errors.push({ kind: "dead_allowlist_entry", id: text });
420
+ }
421
+
422
+ return { ok: errors.length === 0, filesScanned: files.length, rules: definitions.length, references: references.length, terms: termDefs.length, errors };
423
+ }
424
+
425
+ async function main(argv = process.argv.slice(2)) {
426
+ if (argv.includes("--help") || argv.includes("-h")) {
427
+ process.stdout.write(`${USAGE}\n`);
428
+ return 0;
429
+ }
430
+ const result = await validateRuleOwnership(REPO_ROOT);
431
+ if (result.ok) {
432
+ process.stdout.write(`Rule ownership validation passed: ${result.rules} rules, ${result.references} references, ${result.terms} terms, ${result.filesScanned} files scanned.\n`);
433
+ return 0;
434
+ }
435
+ process.stdout.write(`Rule ownership validation failed:\n`);
436
+ for (const error of result.errors) process.stdout.write(`- ${error.kind}: ${error.id || error.key || error.token} ${error.location || (error.locations || []).join(", ")}\n`);
437
+ return 1;
438
+ }
439
+
440
+ if (isDirectCliRun(import.meta.url)) {
441
+ process.exitCode = await main();
442
+ }