@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +110 -276
- package/dist/agent-src/commands/analyze/reference-repo.md +62 -0
- package/dist/agent-src/commands/roadmap/create.md +4 -1
- package/dist/agent-src/personas/advisors/first-principles.md +6 -0
- package/dist/agent-src/rules/active-remediation.md +53 -2
- package/dist/agent-src/rules/code-provenance.md +66 -4
- package/dist/agent-src/rules/content-quoting-floor.md +6 -0
- package/dist/agent-src/rules/design-fidelity.md +49 -3
- package/dist/agent-src/skills/design-review/references/verification-automation.md +23 -1
- package/dist/agent-src/skills/fe-design/SKILL.md +27 -3
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +30 -0
- package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +11 -0
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +8 -8
- package/dist/agent-src/skills/skill-writing/SKILL.md +4 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/persona.md +26 -0
- package/dist/agent-src/templates/roadmaps.md +27 -7
- package/dist/agent-src/templates/skill.md +35 -1
- package/dist/cli/commands/commands.candidates.test.js +257 -0
- package/dist/cli/commands/commands.candidates.test.js.map +1 -0
- package/dist/cli/commands/commands.js +199 -0
- package/dist/cli/commands/commands.js.map +1 -1
- package/dist/cli/main.js +1 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/registry.js +1 -1
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-E4RUZKXB.js → chunk-FV34EGEX.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +1 -1
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +13 -13
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +116 -24
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1 -1
- package/dist/router.json +1 -1
- package/docs/guidelines/agent-infra/active-remediation-mechanics.md +54 -0
- package/docs/guidelines/design-antipatterns.md +79 -10
- package/docs/guidelines/design-fidelity-mechanics.md +120 -2
- package/package.json +1 -1
- package/src/config/gate-coverage.yml +42 -8
- package/src/config/gate-violation-baselines.json +6 -0
- package/src/config/review-prompt-binding-baseline.json +19 -0
- package/src/config/rule-enforcement-baseline.json +0 -1
- package/src/scripts/_dispatch.bash +1 -0
- package/src/scripts/ai_council/clients.ts +15 -1
- package/src/scripts/check_ci_local_parity.ts +60 -6
- package/src/scripts/check_review_prompt_binding.ts +553 -0
- package/src/scripts/check_suppression_hygiene.ts +19 -3
- package/src/scripts/design_slop_rules.test.ts +37 -6
- package/src/scripts/design_slop_rules.ts +150 -22
- package/src/scripts/dispatch_r2_reviewer.ts +15 -1
- package/src/scripts/hooks/ui_route_nudge_hook.ts +55 -2
- package/src/scripts/lint_design_antipattern_parity.ts +201 -0
- package/src/scripts/lint_harvest_provenance.ts +483 -0
- package/src/scripts/lint_skill_router_head.ts +254 -0
- package/src/scripts/report_consultation_rate.ts +126 -1
- package/src/scripts/run_skill_evals.ts +10 -5
- package/src/scripts/schemas/persona.schema.json +8 -0
- package/src/scripts/schemas/provenance-harvest.schema.json +39 -0
- 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.
|
|
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 (
|
|
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
|
+
}
|