@event4u/agent-config 10.2.0 → 10.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +110 -276
  3. package/dist/agent-src/commands/analyze/reference-repo.md +62 -0
  4. package/dist/agent-src/commands/roadmap/create.md +4 -1
  5. package/dist/agent-src/personas/advisors/first-principles.md +6 -0
  6. package/dist/agent-src/rules/active-remediation.md +53 -2
  7. package/dist/agent-src/rules/code-provenance.md +66 -4
  8. package/dist/agent-src/rules/content-quoting-floor.md +6 -0
  9. package/dist/agent-src/rules/design-fidelity.md +49 -3
  10. package/dist/agent-src/skills/design-review/references/verification-automation.md +23 -1
  11. package/dist/agent-src/skills/fe-design/SKILL.md +27 -3
  12. package/dist/agent-src/skills/judge-synthesis/SKILL.md +30 -0
  13. package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +11 -0
  14. package/dist/agent-src/skills/roadmap-writing/SKILL.md +8 -8
  15. package/dist/agent-src/skills/skill-writing/SKILL.md +4 -0
  16. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  17. package/dist/agent-src/templates/persona.md +26 -0
  18. package/dist/agent-src/templates/roadmaps.md +27 -7
  19. package/dist/agent-src/templates/skill.md +35 -1
  20. package/dist/cli/commands/commands.candidates.test.js +257 -0
  21. package/dist/cli/commands/commands.candidates.test.js.map +1 -0
  22. package/dist/cli/commands/commands.js +199 -0
  23. package/dist/cli/commands/commands.js.map +1 -1
  24. package/dist/cli/main.js +1 -0
  25. package/dist/cli/main.js.map +1 -1
  26. package/dist/cli/registry.js +1 -1
  27. package/dist/cli/registry.js.map +1 -1
  28. package/dist/cli-delegate/{chunk-E4RUZKXB.js → chunk-FV34EGEX.js} +1 -1
  29. package/dist/cli-delegate/cmd_conformance.js +1 -1
  30. package/dist/cli-delegate/cmd_doctor.js +1 -1
  31. package/dist/discovery/deprecation-report.md +1 -1
  32. package/dist/discovery/discovery-manifest.json +13 -13
  33. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  34. package/dist/discovery/discovery-manifest.summary.md +1 -1
  35. package/dist/discovery/dormancy-report.md +1 -1
  36. package/dist/discovery/orphan-report.md +1 -1
  37. package/dist/discovery/packs.json +2 -2
  38. package/dist/discovery/trust-report.md +1 -1
  39. package/dist/discovery/workspaces.json +2 -2
  40. package/dist/hooks/dispatch.js +116 -24
  41. package/dist/mcp/registry-manifest.json +2 -2
  42. package/dist/mcp/server.json +2 -2
  43. package/dist/mcp/server.mjs +1 -1
  44. package/dist/router.json +1 -1
  45. package/docs/guidelines/agent-infra/active-remediation-mechanics.md +54 -0
  46. package/docs/guidelines/design-antipatterns.md +79 -10
  47. package/docs/guidelines/design-fidelity-mechanics.md +120 -2
  48. package/package.json +1 -1
  49. package/src/config/gate-coverage.yml +42 -8
  50. package/src/config/gate-violation-baselines.json +6 -0
  51. package/src/config/review-prompt-binding-baseline.json +19 -0
  52. package/src/config/rule-enforcement-baseline.json +0 -1
  53. package/src/scripts/_dispatch.bash +1 -0
  54. package/src/scripts/ai_council/clients.ts +15 -1
  55. package/src/scripts/check_ci_local_parity.ts +60 -6
  56. package/src/scripts/check_review_prompt_binding.ts +553 -0
  57. package/src/scripts/check_suppression_hygiene.ts +19 -3
  58. package/src/scripts/design_slop_rules.test.ts +37 -6
  59. package/src/scripts/design_slop_rules.ts +150 -22
  60. package/src/scripts/dispatch_r2_reviewer.ts +15 -1
  61. package/src/scripts/hooks/ui_route_nudge_hook.ts +55 -2
  62. package/src/scripts/lint_design_antipattern_parity.ts +201 -0
  63. package/src/scripts/lint_harvest_provenance.ts +483 -0
  64. package/src/scripts/lint_skill_router_head.ts +254 -0
  65. package/src/scripts/report_consultation_rate.ts +126 -1
  66. package/src/scripts/run_skill_evals.ts +10 -5
  67. package/src/scripts/schemas/persona.schema.json +8 -0
  68. package/src/scripts/schemas/provenance-harvest.schema.json +39 -0
  69. package/src/scripts/sweep_dead_scan_roots.ts +51 -0
@@ -17,6 +17,14 @@
17
17
  * dependency-free approach. Council 2026-06-28 assumed those libs were present;
18
18
  * for THIS package, pattern-based is the correct, frugal adaptation.
19
19
  *
20
+ * WHAT THE NEGATIVE FIXTURES ARE NOT: each rule ships one positive and one
21
+ * negative fixture, and the suite asserts both exist. That is a PRESENCE guard —
22
+ * "this regex stays quiet on one clean snippet crafted for it" — and it is NOT a
23
+ * false-positive RATE. Nineteen green negatives say nothing about how often a
24
+ * rule fires on clean UI it has never seen. Reading them as an FP measurement is
25
+ * the mistake that makes the registry look better-evidenced than it is; the rate
26
+ * is measured against the clean corpus under `internal/bench/corpora/`, not here.
27
+ *
20
28
  * Severity is a REBUTTABLE PRESUMPTION, never a hard block:
21
29
  * P0 — objective failure (none here; those live in lint_design_quality)
22
30
  * P1 — high-confidence tell (e.g. the side-stripe signature)
@@ -437,7 +445,7 @@ export const SLOP_RULES: SlopRule[] = [
437
445
  },
438
446
  },
439
447
  {
440
- id: "slop-lock-shape",
448
+ id: "slop-v8-lock-shape",
441
449
  catalogId: "V8",
442
450
  severity: "P2",
443
451
  engines: ["css"],
@@ -464,27 +472,6 @@ export const SLOP_RULES: SlopRule[] = [
464
472
  return [];
465
473
  },
466
474
  },
467
- {
468
- id: "slop-lock-colour",
469
- catalogId: "C6",
470
- severity: "P3",
471
- engines: ["css"],
472
- description: "Colour Lock — multiple saturated accent hue families",
473
- message: "Several accent hue families read as no single accent identity; lock one (C6 / Colour Lock).",
474
- gated: (ctx) => ctx.has("colour lock", "color lock"),
475
- detect: ({ content }) => {
476
- const buckets = new Set<number>();
477
- for (const pm of content.matchAll(/#([0-9a-f]{3}|[0-9a-f]{6})\b/gi)) {
478
- const b = hueBucket("#" + (pm[1] ?? ""));
479
- if (b !== null) buckets.add(b);
480
- }
481
- if (buckets.size >= 3) {
482
- const idx = content.search(/#[0-9a-f]{3,6}\b/i);
483
- return [{ line: lineOf(content, Math.max(0, idx)), snippet: `${buckets.size} distinct accent hue families` }];
484
- }
485
- return [];
486
- },
487
- },
488
475
  {
489
476
  id: "slop-l9-section-monotony",
490
477
  catalogId: "L9",
@@ -527,4 +514,145 @@ export const SLOP_RULES: SlopRule[] = [
527
514
  return [];
528
515
  },
529
516
  },
517
+ // -------------------------------------------------------------------------
518
+ // Catalog thresholds promoted to rules (road-to-design-detector-evidence
519
+ // Phase 3). Each entry below already published its number in
520
+ // docs/guidelines/design-antipatterns.md — the prose is the specification,
521
+ // nothing here is invented, and each was graded per rule against the clean
522
+ // corpus rather than as a batch.
523
+ // -------------------------------------------------------------------------
524
+ {
525
+ id: "slop-v4-over-rounded",
526
+ catalogId: "V4",
527
+ severity: "P3",
528
+ engines: ["css"],
529
+ description: "border-radius > 16px on an element declared under 200px wide",
530
+ message: "Heavy rounding on a small surface reads as scaffold-default; scale the radius to the element (V4).",
531
+ gated: (ctx) => ctx.has("pill", "fully rounded", "soft ui", "claymorphism"),
532
+ detect: ({ content }) =>
533
+ cssBlocks(content)
534
+ .filter((b) => {
535
+ // A pill (9999px / 50% / 999px) and a circle are deliberate shapes,
536
+ // not over-rounding — the tell is a mid-range radius on a small box.
537
+ const r = /border-radius\s*:\s*(\d+)px/i.exec(b.body);
538
+ if (!r?.[1]) return false;
539
+ const radius = parseInt(r[1], 10);
540
+ if (radius <= 16 || radius >= 100) return false;
541
+ // "small" must be declared in the same block; nothing here infers
542
+ // layout, because inferring it would need a cascade.
543
+ const size = /(?:^|[;{\s])(?:max-)?(?:width|height)\s*:\s*(\d+)px/i.exec(b.body);
544
+ return size?.[1] !== undefined && parseInt(size[1], 10) < 200;
545
+ })
546
+ .map((b) => ({ line: b.line, snippet: b.selector.slice(0, 80) })),
547
+ },
548
+ {
549
+ id: "slop-c3-dark-glow",
550
+ catalogId: "C3",
551
+ severity: "P2",
552
+ engines: ["css"],
553
+ description: "Saturated zero-offset glow (box-shadow / text-shadow) in a dark context",
554
+ message: "A neon glow on dark reads as gaming-UI default rather than a considered accent (C3).",
555
+ gated: (ctx) => ctx.has("glow", "neon", "cyberpunk", "gaming"),
556
+ detect: ({ content, lines }) => {
557
+ // The tell is dark-context-specific, so a file with no dark surface at all
558
+ // is out of scope — a glow on a light page is a different question.
559
+ const darkContext =
560
+ /prefers-color-scheme\s*:\s*dark/i.test(content) ||
561
+ /\.dark\b|\[data-theme=["']?dark/i.test(content) ||
562
+ /background(?:-color)?\s*:\s*#(?:0[0-9a-f]{5}|1[0-9a-f]{5}|000|111)\b/i.test(content);
563
+ if (!darkContext) return [];
564
+ const out: RawHit[] = [];
565
+ for (let i = 0; i < lines.length; i++) {
566
+ const l = lines[i] ?? "";
567
+ // zero-offset + real blur is the glow signature; an offset shadow is depth.
568
+ const m = /(?:box|text)-shadow\s*:\s*0\s+0\s+(\d+)px[^;]*?(#[0-9a-f]{3,8})/i.exec(l);
569
+ if (!m?.[1] || !m[2]) continue;
570
+ if (parseInt(m[1], 10) < 8) continue;
571
+ if (hueBucket(m[2]) === null) continue; // a neutral halo is not a neon accent
572
+ out.push({ line: i + 1, snippet: l.trim().slice(0, 80) });
573
+ }
574
+ return out;
575
+ },
576
+ },
577
+ {
578
+ id: "slop-t9-uppercase-body",
579
+ catalogId: "T9",
580
+ severity: "P3",
581
+ engines: ["css"],
582
+ description: "text-transform: uppercase applied to body-length text",
583
+ message: "All-caps destroys word-shape for sustained reading; reserve it for short labels (T9).",
584
+ gated: (ctx) => ctx.has("all caps", "all-caps", "uppercase display"),
585
+ detect: ({ content }) =>
586
+ cssBlocks(content)
587
+ // Short UI surfaces (labels, buttons, table headers, badges, eyebrows)
588
+ // are the legitimate home of uppercase and are excluded by selector.
589
+ .filter((b) => /\b(p|body|article|\.prose|\.body|\.content|\.copy|blockquote|li)\b/i.test(b.selector))
590
+ .filter((b) => !/label|button|\bth\b|badge|chip|tag|eyebrow|kicker|caption|nav/i.test(b.selector))
591
+ .filter((b) => /text-transform\s*:\s*uppercase/i.test(b.body))
592
+ .map((b) => ({ line: b.line, snippet: b.selector.slice(0, 80) })),
593
+ },
594
+ {
595
+ id: "slop-t10-wide-tracking-body",
596
+ catalogId: "T10",
597
+ severity: "P3",
598
+ engines: ["css"],
599
+ description: "letter-spacing above 0.05em on body-length text",
600
+ message: "Wide tracking slows sustained reading; it belongs to display type, not body (T10).",
601
+ gated: (ctx) => ctx.has("wide tracking", "letterspacing", "letter-spacing"),
602
+ detect: ({ content }) =>
603
+ cssBlocks(content)
604
+ .filter((b) => /\b(p|body|article|\.prose|\.body|\.content|\.copy|blockquote|li)\b/i.test(b.selector))
605
+ .filter((b) => !/label|button|\bth\b|badge|chip|tag|eyebrow|kicker|caption|nav|h[1-6]/i.test(b.selector))
606
+ .filter((b) => {
607
+ const em = /letter-spacing\s*:\s*(\d*\.?\d+)(em|rem)/i.exec(b.body);
608
+ if (em?.[1]) return parseFloat(em[1]) > 0.05;
609
+ const px = /letter-spacing\s*:\s*(\d*\.?\d+)px/i.exec(b.body);
610
+ return px?.[1] !== undefined && parseFloat(px[1]) > 0.8;
611
+ })
612
+ .map((b) => ({ line: b.line, snippet: b.selector.slice(0, 80) })),
613
+ },
614
+ {
615
+ id: "slop-m1-bounce-easing",
616
+ catalogId: "M1",
617
+ severity: "P2",
618
+ engines: ["css"],
619
+ description: "Bounce or elastic easing on a UI transition",
620
+ message: "Overshoot draws attention to the animation instead of the state change (M1).",
621
+ gated: (ctx) => ctx.has("bounce", "playful motion", "elastic"),
622
+ detect: ({ lines }) => {
623
+ const out: RawHit[] = [];
624
+ for (let i = 0; i < lines.length; i++) {
625
+ const l = lines[i] ?? "";
626
+ // Overshoot is decidable from the curve itself: a control point outside
627
+ // [0,1] on the output axis means the value passes its target and returns.
628
+ const cb = /cubic-bezier\(\s*[-\d.]+\s*,\s*(-?[\d.]+)\s*,\s*[-\d.]+\s*,\s*(-?[\d.]+)\s*\)/i.exec(l);
629
+ if (cb?.[1] !== undefined && cb[2] !== undefined) {
630
+ const y1 = parseFloat(cb[1]);
631
+ const y2 = parseFloat(cb[2]);
632
+ if (y1 < 0 || y1 > 1 || y2 < 0 || y2 > 1) {
633
+ out.push({ line: i + 1, snippet: l.trim().slice(0, 80) });
634
+ continue;
635
+ }
636
+ }
637
+ if (/(?:transition|animation)[^;]*\b(?:bounce|elastic|back(?:In|Out|InOut))\b/i.test(l)) {
638
+ out.push({ line: i + 1, snippet: l.trim().slice(0, 80) });
639
+ }
640
+ }
641
+ return out;
642
+ },
643
+ },
644
+ {
645
+ id: "slop-m3-img-hover-transform",
646
+ catalogId: "M3",
647
+ severity: "P3",
648
+ engines: ["css"],
649
+ description: "transform or filter animation on an <img> hover",
650
+ message: "Hover-scaling an image triggers composite plus decode and often shifts layout (M3).",
651
+ gated: (ctx) => ctx.has("image hover", "hover zoom", "ken burns"),
652
+ detect: ({ content }) =>
653
+ cssBlocks(content)
654
+ .filter((b) => /\bimg\b/i.test(b.selector) && /:hover|:focus-within/i.test(b.selector))
655
+ .filter((b) => /(?:^|[;{\s])(?:transform|filter)\s*:/i.test(b.body))
656
+ .map((b) => ({ line: b.line, snippet: b.selector.slice(0, 80) })),
657
+ },
530
658
  ];
@@ -642,7 +642,21 @@ function reviewerPrompt(args: {
642
642
  * Writing the hash of the prompt THIS dispatcher built makes the channel
643
643
  * attributable: the artefact now records which prompt produced it, so a review
644
644
  * run against a different prompt is distinguishable from one run against the
645
- * dispatcher's. `--verify` re-derives it from the same inputs and compares.
645
+ * dispatcher's.
646
+ *
647
+ * `--verify` / `--verify-current` do NOT re-derive it. {@link runVerify}
648
+ * re-derives `scope_hash`, `roadmap_hash` and `ac_hash` only — it re-computes
649
+ * the review's INPUTS from the current repo state, and the prompt is not one of
650
+ * them (it is not reconstructible from the repo; it is an artefact this
651
+ * dispatcher wrote). An earlier revision of this comment claimed otherwise for
652
+ * roughly a year, which is how the field came to be written, parsed, and
653
+ * compared to nothing.
654
+ *
655
+ * What compares it is `check_review_prompt_binding`, against the prompt package
656
+ * this dispatcher commits beside the artefact (`<slug>.review-input/prompt.md`,
657
+ * written below) — a different check with a different scope: corpus-wide rather
658
+ * than current-branch, because a committed prompt and its recorded hash are both
659
+ * immutable.
646
660
  *
647
661
  * It does not CLOSE the residual and is not sold as doing so: the same host that
648
662
  * authors a steered prompt could hash the steered text. The gain is an
@@ -123,6 +123,45 @@ export function isUiWrite(event: ToolEvent): boolean {
123
123
  return isUiPath(event.file) || isUiTreePath(event.file);
124
124
  }
125
125
 
126
+ /**
127
+ * The two handover shapes `design-fidelity` routes on a FILE rather than on a
128
+ * word: its `file_pattern: *design.html` and its
129
+ * `path_prefix: .claude/design-system/`. Reading one is the agent reaching
130
+ * rung 1 of the data-basis ladder before it writes.
131
+ *
132
+ * A SHARED PREDICATE, NOT A DECISION INPUT HERE. `decide` does not branch on
133
+ * it: the nudge's behaviour is unchanged by an artifact read, and the phase
134
+ * that introduced this states `nothing behavioural`. The one consumer is
135
+ * `report_consultation_rate`, which measures over transcripts. Keeping the
136
+ * predicate beside `isUiWrite` / `isConsultation` is what stops the analyzer
137
+ * and the nudge from classifying the same event differently.
138
+ *
139
+ * THIS IS A COPY OF THE RULE'S TRIGGERS, NOT A READ OF THEM — same honesty
140
+ * boundary the header states for `ui_surface.ts`: nothing here parses
141
+ * `src/rules/*.md`. `ui_route_nudge_artifact_read.test.ts` pins the two against
142
+ * the rule's own frontmatter so the copy cannot drift silently.
143
+ *
144
+ * **Not disjoint from `isConsultation` by construction** —
145
+ * `src/skills/design-review/references/design.html` satisfies both. Callers
146
+ * must evaluate the two independently rather than branching on the first hit;
147
+ * an early return on this predicate silently removes such an event from the
148
+ * consultation numerator.
149
+ *
150
+ * **Known over-count, not fixed here.** A `Grep` / `Glob` whose `path` argument
151
+ * happens to be the artifact satisfies this predicate although nothing was
152
+ * read — the same search-is-not-a-read class `isConsultation` was narrowed to
153
+ * exclude. It is not excluded here because `ToolEvent` carries no tool name,
154
+ * and adding one would change the shared shape `isConsultation` and `isUiWrite`
155
+ * are measured through, moving a published rate this branch does not own. The
156
+ * consequence is stated where it matters: the rate is a CEILING on
157
+ * read-before-write, and `report_consultation_rate`'s output says so.
158
+ */
159
+ export function isArtifactRead(event: ToolEvent): boolean {
160
+ if (event.isWrite || !event.file) return false;
161
+ const normalized = event.file.replace(/\\/g, '/').toLowerCase();
162
+ return normalized.endsWith('design.html') || normalized.includes('.claude/design-system/');
163
+ }
164
+
126
165
  export interface SessionState {
127
166
  consulted: boolean;
128
167
  nudges: number;
@@ -203,8 +242,22 @@ export function decide(
203
242
  return { state: { ...state, nudges: state.nudges + 1 }, warn: true };
204
243
  }
205
244
 
245
+ /**
246
+ * Did anything worth persisting change?
247
+ *
248
+ * Enumerated per field rather than deep-compared. **A new field must be added
249
+ * here or it does not persist** — a review on this branch caught exactly that
250
+ * shape: two session fields were computed on every event and written on none,
251
+ * because the guard tested the two older fields by name. Those fields are gone
252
+ * (the measurement lives in the analyzer, one implementation instead of two),
253
+ * but the guard stays enumerated and this note stays with it.
254
+ */
255
+ export function stateChanged(before: SessionState, after: SessionState): boolean {
256
+ return before.consulted !== after.consulted || before.nudges !== after.nudges;
257
+ }
258
+
206
259
  export function nudgeReason(file: string): string {
207
- return `Non-trivial UI write to ${file} with no design consultation this session. Run \`existing-ui-audit\` first, then the fe-design loop (brief → build → review). Skip only if this is ui-trivial: <=1 file, <=5 lines, no new component, no new state, no new dependency.`;
260
+ return `Non-trivial UI write to ${file} with no design consultation this session. Run \`existing-ui-audit\` first, then the fe-design loop (brief → inventory, if an artifact was provided → build → review). Skip only if this is ui-trivial: <=1 file, <=5 lines, no new component, no new state, no new dependency.`;
208
261
  }
209
262
 
210
263
  export function main(): number {
@@ -230,7 +283,7 @@ export function main(): number {
230
283
 
231
284
  const before = readState(root, session);
232
285
  const { state, warn } = decide(event, before);
233
- if (state.consulted !== before.consulted || state.nudges !== before.nudges) {
286
+ if (stateChanged(before, state)) {
234
287
  writeState(root, session, state);
235
288
  }
236
289
  if (!warn) return EXIT_ALLOW;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Gate: catalog ↔ slop-rule parity (road-to-design-detector-evidence Phase 1).
3
+ *
4
+ * `src/scripts/design_slop_rules.ts` states in its header that every rule cites
5
+ * its catalog id "so prose ↔ rule stay traceable". Nothing enforced that. The
6
+ * unit test validates the *shape* `/^[A-Z]+\d+$/` and never opens
7
+ * `docs/guidelines/design-antipatterns.md`, so a rule citing `Z99` passed, and a
8
+ * catalog entry could silently lose or gain a detector with no signal anywhere.
9
+ * The tree happened to be consistent; the defect was that consistency was
10
+ * unguarded, not that it was absent.
11
+ *
12
+ * This gate makes the claim true, in both directions:
13
+ *
14
+ * A. every non-`Q*` catalog entry appears exactly once in § Detector status
15
+ * B. every § Detector status row names a catalog entry that exists
16
+ * C. the set of `backed` rows equals the registry's `catalogId` set
17
+ * D. every status is one of the five allowed values
18
+ * E. every non-`backed` row carries a reason
19
+ *
20
+ * `Q*` entries are out of scope by construction: they are objective floors owned
21
+ * by `lint_design_quality`, not aesthetic tells, and the status table says so.
22
+ *
23
+ * Pure core (`parityFindings`) takes both documents as text, so the failure
24
+ * paths are unit-testable without the filesystem. Exit 0 = clean, 1 = at least
25
+ * one finding.
26
+ */
27
+
28
+ import * as fs from 'node:fs';
29
+ import * as path from 'node:path';
30
+ import { fileURLToPath, pathToFileURL } from 'node:url';
31
+
32
+ import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
33
+
34
+ const QUIET = process.argv.includes('--quiet');
35
+ const _HERE = fileURLToPath(import.meta.url);
36
+ const REPO = path.resolve(path.dirname(_HERE), '..', '..');
37
+ const CATALOG = path.join(REPO, 'docs', 'guidelines', 'design-antipatterns.md');
38
+ const REGISTRY = path.join(REPO, 'src', 'scripts', 'design_slop_rules.ts');
39
+
40
+ /** The five statuses § Detector status may use. */
41
+ export const STATUSES = ['backed', 'floor', 'judgment-only', 'deferred', 'candidate'] as const;
42
+ export type Status = (typeof STATUSES)[number];
43
+
44
+ export interface ParityFinding {
45
+ kind: string;
46
+ msg: string;
47
+ }
48
+
49
+ const ENTRY_ROW = /^\|\s*((?:V|C|T|L|M|CP)\d+)\s*\|/gm;
50
+ const STATUS_ROW = /^\|\s*((?:V|C|T|L|M|CP)\d+)\s*\|\s*([a-z-]*)\s*\|([^|]*)\|/gm;
51
+ const STATUS_SECTION = /^## Detector status$([\s\S]*?)(?=^## )/m;
52
+ const CATALOG_ID = /^\s+catalogId:\s*"([A-Z]+\d+)"/gm;
53
+
54
+ /** Catalog entry ids outside the § Detector status section. */
55
+ export function catalogEntries(catalogMd: string): string[] {
56
+ const section = STATUS_SECTION.exec(catalogMd)?.[0] ?? '';
57
+ const outside = section === '' ? catalogMd : catalogMd.replace(section, '');
58
+ return [...outside.matchAll(ENTRY_ROW)].map((m) => m[1] as string);
59
+ }
60
+
61
+ /** `id -> {status, note}` parsed from the § Detector status table. */
62
+ export function statusRows(catalogMd: string): Map<string, { status: string; note: string }> {
63
+ const section = STATUS_SECTION.exec(catalogMd)?.[1] ?? '';
64
+ const out = new Map<string, { status: string; note: string }>();
65
+ for (const m of section.matchAll(STATUS_ROW)) {
66
+ out.set(m[1] as string, { status: (m[2] ?? '').trim(), note: (m[3] ?? '').trim() });
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /** `catalogId` values declared by the rule registry. */
72
+ export function registryCatalogIds(registryTs: string): string[] {
73
+ return [...registryTs.matchAll(CATALOG_ID)].map((m) => m[1] as string);
74
+ }
75
+
76
+ /** Pure invariant check over both documents. */
77
+ export function parityFindings(catalogMd: string, registryTs: string): ParityFinding[] {
78
+ const out: ParityFinding[] = [];
79
+
80
+ if (STATUS_SECTION.exec(catalogMd) === null) {
81
+ return [
82
+ {
83
+ kind: 'no-status-section',
84
+ msg: 'design-antipatterns.md has no `## Detector status` section — the parity claim has nothing to check against',
85
+ },
86
+ ];
87
+ }
88
+
89
+ const entries = catalogEntries(catalogMd);
90
+ const seen = new Set<string>();
91
+ for (const id of entries) {
92
+ if (seen.has(id)) out.push({ kind: 'duplicate-entry', msg: `${id}: appears more than once in the catalog tables` });
93
+ seen.add(id);
94
+ }
95
+
96
+ const rows = statusRows(catalogMd);
97
+
98
+ // A — every catalog entry is classified.
99
+ for (const id of seen) {
100
+ if (!rows.has(id)) {
101
+ out.push({ kind: 'unclassified', msg: `${id}: catalog entry missing from § Detector status` });
102
+ }
103
+ }
104
+ // B — every classified row names a real entry.
105
+ for (const id of rows.keys()) {
106
+ if (!seen.has(id)) {
107
+ out.push({ kind: 'orphan-status', msg: `${id}: § Detector status row names an entry that no catalog table defines` });
108
+ }
109
+ }
110
+ // D + E — status vocabulary and reasons.
111
+ for (const [id, row] of rows) {
112
+ if (!(STATUSES as readonly string[]).includes(row.status)) {
113
+ out.push({
114
+ kind: 'bad-status',
115
+ msg: `${id}: status '${row.status || '(empty)'}' is not one of ${STATUSES.join(', ')}`,
116
+ });
117
+ continue;
118
+ }
119
+ if (row.status !== 'backed' && row.note === '') {
120
+ out.push({ kind: 'missing-reason', msg: `${id}: status '${row.status}' carries no reason` });
121
+ }
122
+ }
123
+
124
+ // C — backed set equals the registry set, both directions.
125
+ const backed = new Set([...rows].filter(([, r]) => r.status === 'backed').map(([id]) => id));
126
+ const registry = new Set(registryCatalogIds(registryTs));
127
+ for (const id of backed) {
128
+ if (!registry.has(id)) {
129
+ out.push({ kind: 'backed-without-rule', msg: `${id}: marked backed but no registry rule declares that catalogId` });
130
+ }
131
+ }
132
+ for (const id of registry) {
133
+ if (!backed.has(id)) {
134
+ const known = rows.get(id);
135
+ out.push({
136
+ kind: 'rule-without-backed',
137
+ msg: known
138
+ ? `${id}: a registry rule declares this catalogId but § Detector status marks it '${known.status}'`
139
+ : `${id}: a registry rule declares this catalogId and no catalog entry carries it`,
140
+ });
141
+ }
142
+ }
143
+
144
+ return out;
145
+ }
146
+
147
+ function _print(s: string): void {
148
+ if (!QUIET) process.stdout.write(`${s}\n`);
149
+ }
150
+
151
+ export function main(): number {
152
+ try {
153
+ assertWatchlistResolves({
154
+ gate: 'lint_design_antipattern_parity',
155
+ candidates: [path.relative(REPO, CATALOG), path.relative(REPO, REGISTRY)],
156
+ repoRoot: REPO,
157
+ });
158
+ } catch (exc) {
159
+ if (exc instanceof DeadScopeError) {
160
+ process.stderr.write(`❌ ${exc.message}\n`);
161
+ return 1;
162
+ }
163
+ throw exc;
164
+ }
165
+
166
+ const catalogMd = fs.readFileSync(CATALOG, 'utf-8');
167
+ const registryTs = fs.readFileSync(REGISTRY, 'utf-8');
168
+ const findings = parityFindings(catalogMd, registryTs);
169
+
170
+ const entries = catalogEntries(catalogMd).length;
171
+ const backed = [...statusRows(catalogMd).values()].filter((r) => r.status === 'backed').length;
172
+
173
+ if (findings.length === 0) {
174
+ _print(`✅ catalog ↔ rule parity OK — ${entries} entries classified, ${backed} detector-backed`);
175
+ return 0;
176
+ }
177
+ _print(`❌ catalog ↔ rule parity — ${findings.length} finding(s):`);
178
+ for (const f of findings) _print(` - ${f.msg}`);
179
+ return 1;
180
+ }
181
+
182
+ function _isCliEntry(): boolean {
183
+ if (process.argv[1] === undefined) {
184
+ return false;
185
+ }
186
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
187
+ if (import.meta.url === argvUrl) {
188
+ return true;
189
+ }
190
+ try {
191
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
192
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
193
+ return here === argv;
194
+ } catch {
195
+ return false;
196
+ }
197
+ }
198
+
199
+ if (_isCliEntry()) {
200
+ process.exit(main());
201
+ }