@event4u/agent-config 16.4.0 → 16.5.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 (77) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +40 -191
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/design.md +1 -1
  7. package/dist/agent-src/guidelines/design-antipatterns.md +1 -1
  8. package/dist/agent-src/rules/token-budget-discipline.md +2 -2
  9. package/dist/agent-src/skills/accessibility-auditor/data/aria-patterns.csv +2 -0
  10. package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
  11. package/dist/agent-src/skills/canvas-design/SKILL.md +4 -2
  12. package/dist/agent-src/skills/design-intelligence/references/integration-mapping.md +13 -10
  13. package/dist/agent-src/skills/fe-design/references/design-patterns.md +49 -2
  14. package/dist/agent-src/skills/frontend-render-security/SKILL.md +9 -0
  15. package/dist/agent-src/skills/iconography/SKILL.md +4 -2
  16. package/dist/agent-src/skills/image-provider-routing/SKILL.md +16 -5
  17. package/dist/agent-src/skills/logo-generation/SKILL.md +12 -8
  18. package/dist/agent-src/skills/svg-authoring/SKILL.md +318 -0
  19. package/dist/agent-src/skills/wireframe/SKILL.md +5 -2
  20. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/dist/cli/commands/uiRender.js +201 -4
  22. package/dist/cli/commands/uiRenderAsset.js +1308 -0
  23. package/dist/cli/commands/uiRenderAssetCompare.js +752 -0
  24. package/dist/cli/commands/uiRenderAssetGrid.js +265 -0
  25. package/dist/cli/commands/uiRenderAssetGrounds.js +323 -0
  26. package/dist/cli/commands/uiRenderAssetIsolation.js +85 -0
  27. package/dist/cli/commands/uiRenderAssetTypes.js +145 -0
  28. package/dist/cli/main.js +47 -1
  29. package/dist/cli/registry.js +1 -1
  30. package/dist/cli-delegate/{chunk-MSUL5NYP.js → chunk-4DBG5K7M.js} +1 -1
  31. package/dist/cli-delegate/{chunk-AED2VQGM.js → chunk-TMWYUOK4.js} +10 -1
  32. package/dist/cli-delegate/cmd_conformance.js +395 -214
  33. package/dist/cli-delegate/cmd_handoff.js +2 -2
  34. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  35. package/dist/discovery/deprecation-report.md +1 -1
  36. package/dist/discovery/discovery-manifest.json +47 -22
  37. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  38. package/dist/discovery/discovery-manifest.summary.md +6 -6
  39. package/dist/discovery/dormancy-report.md +1 -1
  40. package/dist/discovery/orphan-report.md +1 -1
  41. package/dist/discovery/packs.json +19 -15
  42. package/dist/discovery/trust-report.md +2 -2
  43. package/dist/discovery/workspaces.json +13 -9
  44. package/dist/hooks/dispatch.js +1073 -808
  45. package/dist/hooks/dispatch.sha256 +1 -1
  46. package/dist/hooks/svg_rules.chunk.cjs +1626 -0
  47. package/dist/hooks/svg_rules.chunk.cjs.sha256 +1 -0
  48. package/dist/mcp/registry-manifest.json +3 -3
  49. package/dist/mcp/server.json +2 -2
  50. package/dist/mcp/server.mjs +931 -735
  51. package/dist/scripts/_lib/scan_scope.js +105 -0
  52. package/dist/scripts/_lib/svg_dom.js +284 -0
  53. package/dist/scripts/_lib/svg_geometry.js +477 -0
  54. package/dist/scripts/_lib/svg_motion.js +179 -0
  55. package/dist/scripts/_lib/svg_rules.js +1337 -0
  56. package/dist/scripts/_lib/svg_urls.js +67 -0
  57. package/dist/scripts/lint_svg_asset.js +324 -0
  58. package/docs/guidelines/design-antipatterns.md +1 -1
  59. package/package.json +4 -3
  60. package/src/config/gate-violation-baselines.json +3 -3
  61. package/src/config/routing-coverage-seed.json +9 -2
  62. package/src/scripts/_lib/svg_dom.ts +318 -0
  63. package/src/scripts/_lib/svg_geometry.ts +480 -0
  64. package/src/scripts/_lib/svg_motion.ts +208 -0
  65. package/src/scripts/_lib/svg_rules.ts +1453 -0
  66. package/src/scripts/_lib/svg_urls.ts +72 -0
  67. package/src/scripts/_lib/verification_command.ts +59 -0
  68. package/src/scripts/_lib/verify_clause.ts +27 -17
  69. package/src/scripts/hooks/block_speaking_inbox_dir.ts +16 -1
  70. package/src/scripts/hooks/design_pass_hook.ts +157 -3
  71. package/src/scripts/hooks/svg_obligation.ts +497 -0
  72. package/src/scripts/hooks/svg_rules_loader.ts +133 -0
  73. package/src/scripts/hooks/turn_end_gate_hook.ts +5 -3
  74. package/src/scripts/lint_consolidation_lineage.ts +6 -3
  75. package/src/scripts/lint_design_quality.ts +60 -12
  76. package/src/scripts/lint_motion_authority_drift.ts +27 -0
  77. package/src/scripts/lint_svg_asset.ts +365 -0
@@ -0,0 +1,72 @@
1
+ /**
2
+ * How a browser reads the URLs and names in an SVG, for the safety rules in
3
+ * `svg_rules.ts`: references decoded, controls stripped, CSS escapes resolved,
4
+ * and an element prefix stripped only where it is bound to the SVG namespace.
5
+ * Its own module so `svg_rules.ts` stays under the source-size ceiling.
6
+ */
7
+
8
+ /** Prefixes the document binds to the SVG namespace (`xmlns:svg="…/2000/svg"`). */
9
+ export function svgPrefixes(content: string): Set<string> {
10
+ const out = new Set<string>();
11
+ const re = /xmlns:([\w.-]+)\s*=\s*["']http:\/\/www\.w3\.org\/2000\/svg["']/gi;
12
+ let m: RegExpExecArray | null;
13
+ while ((m = re.exec(content)) !== null) out.add((m[1] ?? '').toLowerCase());
14
+ return out;
15
+ }
16
+
17
+ /** The SVG local name: `svg:script` is a `script` where `svg` is bound to SVG; `foo:script` is not. */
18
+ export function localName(name: string, prefixes: ReadonlySet<string>): string {
19
+ const colon = name.indexOf(':');
20
+ return colon !== -1 && prefixes.has(name.slice(0, colon)) ? name.slice(colon + 1) : name;
21
+ }
22
+
23
+ /** Attributes read from the raw tag, unquoted values included — HTML parses those. */
24
+ export function rawAttrs(raw: string): Record<string, string> {
25
+ const out: Record<string, string> = {};
26
+ const re = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>"'`=]+))/g;
27
+ let m: RegExpExecArray | null;
28
+ while ((m = re.exec(raw)) !== null) out[(m[1] ?? '').toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? '';
29
+ return out;
30
+ }
31
+
32
+ /** Attributes whose value is followed as a URL. */
33
+ export const URL_ATTRS = new Set(['href', 'xlink:href', 'src']);
34
+
35
+
36
+ /** A URL that executes or opens a document when followed. */
37
+ export const isActiveUrl = (raw: string): boolean => /^(javascript:|data:text\/html)/i.test(resolvedUrl(raw));
38
+
39
+ const NAMED_URL_ENTITIES: Record<string, string> = {
40
+ colon: ':', sol: '/', tab: '\t', newline: '\n', period: '.', lpar: '(', rpar: ')', amp: '&',
41
+ };
42
+
43
+ /**
44
+ * The URL a browser would actually resolve from an attribute value: character
45
+ * references decoded, leading/trailing C0 controls and spaces stripped, tab and
46
+ * newline removed anywhere. The trailing `;` of a reference is optional on
47
+ * purpose: an `.svg` parsed as XML needs it, but the same graphic inlined in an
48
+ * HTML host is parsed by the HTML tokenizer, which decodes `&#106avascript:`
49
+ * too — over-reading XML here costs precision, under-reading HTML costs a miss.
50
+ * The raw value is attacker-chosen, so a scheme test
51
+ * against it is a test of one spelling — ` https:`, `ht\ttps:`, `&#106;avascript:`
52
+ * all load, and all passed.
53
+ */
54
+ export function resolvedUrl(raw: string): string {
55
+ const decoded = raw.replace(/&(?:#x([0-9a-f]+)|#(\d+)|([a-z]+));?/gi, (m, hex, dec, name) => {
56
+ const cp = hex ? parseInt(hex, 16) : dec ? parseInt(dec, 10) : NaN;
57
+ if (!Number.isNaN(cp)) return cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
58
+ return NAMED_URL_ENTITIES[(name as string).toLowerCase()] ?? m;
59
+ });
60
+ return decoded.replace(/^[\u0000- ]+|[\u0000- ]+$/g, '').replace(/[\t\n\r]/g, '');
61
+ }
62
+
63
+ /** CSS escapes decoded: `\\68 ` and `\\h` both read `h`. */
64
+ export function cssUnescape(raw: string): string {
65
+ return raw.replace(/\\([0-9a-f]{1,6})\s?|\\(.)/gi, (m, hex, ch) => {
66
+ if (hex) {
67
+ const cp = parseInt(hex, 16);
68
+ return cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
69
+ }
70
+ return ch as string;
71
+ });
72
+ }
@@ -271,6 +271,28 @@ function segmentVerifies(rawWords: readonly string[]): boolean {
271
271
  // the old token-anywhere regex accepted it by accident and neither
272
272
  // explicit list named it, so the union seeding missed it too.
273
273
  return rest.some((a) => a === 'test' || a.startsWith('--test'));
274
+ case 'agent-config':
275
+ // NEITHER `ui:render` NOR `xmllint` verifies here, and the first
276
+ // half of that was true, then false, and is true again.
277
+ //
278
+ // Step 3.5 of `road-to-svg-with-a-witness` taught this predicate
279
+ // `ui:render --profile asset`, reasoning that it is the one
280
+ // subcommand which opens the delivered file in a real engine. Step
281
+ // 2.1(b) of `road-to-svg-review-followups` removed it again on
282
+ // review claim C2, which is narrower than the first argument and
283
+ // refutes it: the turn-end gate's TRANSCRIPT path matches a command
284
+ // and never sees an exit code, so a call that rendered nothing —
285
+ // the ordinary case on a machine with no Playwright, where the
286
+ // command also exited 0 until the same day — discharged the
287
+ // obligation from its text alone.
288
+ //
289
+ // What replaces it reads the RENDER instead of the call:
290
+ // `hooks/svg_obligation.ts` matches a manifest to the file's own
291
+ // digest, and accepts a renderer-absent manifest only together with
292
+ // a clean static witness on those same bytes. `xmllint --noout`
293
+ // stays out for its original reason (S6): a file can parse and
294
+ // still be the wrong picture.
295
+ return false;
274
296
  case 'scripts-run': {
275
297
  // `./scripts-run src/scripts/lint_x` verifies; `…/rule_activation_census`
276
298
  // writes a report and asserts nothing, which is why this keys on the
@@ -322,3 +344,40 @@ const _WIDE_CANDIDATE_RE =
322
344
  export function mightBeVerification(command: string): boolean {
323
345
  return _WIDE_CANDIDATE_RE.test(command);
324
346
  }
347
+
348
+ /**
349
+ * `agent-config ui:render … --profile asset`, by shape.
350
+ *
351
+ * Moved here from `hooks/svg_obligation.ts` by finding 3 of this change's
352
+ * SECOND completion review: two detectors need it and one of them is in
353
+ * `_lib`, so a hook module was the wrong home. Re-exported there, so no
354
+ * caller moved.
355
+ */
356
+ export function isAssetRenderCommand(command: string): boolean {
357
+ return /\bui:render\b/.test(command) && /--profile[= ]\s*asset\b/.test(command);
358
+ }
359
+
360
+ /**
361
+ * Did this command ATTEMPT to verify something — as opposed to: did it produce
362
+ * a verdict this gate may read?
363
+ *
364
+ * The distinction is the whole point, and the first review round and the
365
+ * second each saw one half of it. Round one (claim C2) was right that
366
+ * `isVerificationCommand` must NOT learn the asset render: the transcript path
367
+ * never sees an exit code, so the command's TEXT would have discharged the SVG
368
+ * obligation on a machine where the render produced nothing. Round two
369
+ * (finding 3) was right that removing it left detector C — "did any
370
+ * verification command follow the last edit" — unable to see the one command
371
+ * this package PRESCRIBES for an `.svg`, so the prescribed turn was refused.
372
+ *
373
+ * Both hold because they are about different detectors. Detector C asks
374
+ * whether the author tried; it is satisfied by `eslint` and must be satisfied
375
+ * by a render too. The SVG obligation asks what the render FOUND, and still
376
+ * reads the manifest and the file's digest — an attempt that produced nothing
377
+ * discharges nothing there, with a far better reason than detector C's.
378
+ *
379
+ * So: one predicate per question, and `isVerificationCommand` is unchanged.
380
+ */
381
+ export function isVerificationAttempt(command: string): boolean {
382
+ return isVerificationCommand(command) || isAssetRenderCommand(command);
383
+ }
@@ -126,25 +126,35 @@ function lastMatch(re: RegExp, text: string): RegExpExecArray | null {
126
126
  * carries `(?!`)`: a label followed by a backtick is the delimited form, which
127
127
  * branch one already owns.
128
128
  *
129
- * The tail is capped at a single non-space token on purpose. A caller may hand
130
- * this parser a whole step BLOCK already joined into one line, so "the rest of
131
- * the line" can mean "the rest of the step" — and a step that continues after
132
- * its verify clause would then push the expectation away from the end anchor
133
- * and lose it silently. The cost is that a regex expectation here may not
134
- * contain a space; `/[1-9]/` and `/unfalsifiable-verify/` are the shapes in
135
- * use, and a space-bearing expectation loses its arrow loudly (it parses as no
136
- * expectation) rather than half-matching.
129
+ * The tail is bounded by the EXPECTATION'S OWN DELIMITERS, not by the end of
130
+ * the line. A caller may hand this parser a whole step BLOCK already joined
131
+ * into one line, so "the rest of the line" can mean "the rest of the step": an
132
+ * end-anchored tail would lose the expectation of any step that carries prose
133
+ * after its clause. A slash-delimited regex therefore runs to the first
134
+ * UNESCAPED closing `/` — one not preceded by an odd number of backslashes —
135
+ * that is followed by whitespace or the end of the text, and an exit code is
136
+ * one non-space token; both self-terminating, so the step may continue and
137
+ * `-> /[1-9]/ and then note the result` still yields `[1-9]`.
137
138
  *
138
- * THE CAP IS THIS FORM'S, NOT THE GRAMMAR'S — said plainly because an earlier
139
- * revision of this comment implied otherwise. The annotation form is
140
- * `$`-anchored through {@link VERIFY_ARROW_SOURCE} and DOES keep `-> /a b/`;
141
- * this form does not. The two agree on every space-free expectation, which is
142
- * every expectation in the tree. Both directions are pinned by test, so closing
143
- * the gap is a deliberate act against a failing assertion rather than a silent
144
- * drift — which is the failure the WHY ONE PARSER note above exists to prevent,
145
- * and it applies to this module's own two readers first.
139
+ * The word UNESCAPED is load-bearing and was added on 2026-10-11 (step 1.1 of
140
+ * `road-to-svg-review-followups`, review claim C4). The tail used to be a lazy
141
+ * `\/.*?\/(?=\s|$)`, which stops at ANY `/` followed by a space — so a clause
142
+ * ending in ``-> /a\/ b/`` was cut at the escaped slash and parsed to the
143
+ * regex source `a\`. A pattern that deliberately matches a path separator is
144
+ * exactly the case that spells it `\/`, and the truncation was silent: a
145
+ * shorter regex is still a regex, so nothing reported the loss.
146
+ *
147
+ * It was capped at a single non-space token until 2026-10-11, which cost this
148
+ * form every expectation containing a space: `-> /Icon-only button/` parsed as
149
+ * a clause with NO expectation, and nothing reported the loss — the step simply
150
+ * read as unfalsifiable. The two forms now agree on every expectation rather
151
+ * than only the space-free ones; {@link VERIFY_ARROW_SOURCE} stays the single
152
+ * `$`-anchored decision about what an expectation IS, and this regex decides
153
+ * only where the tail ENDS. A bare word stays refused by the constant, so
154
+ * widening the boundary widened no grammar.
146
155
  */
147
- const BACKTICKED_RE = /(?:`verify:`|verify:(?!`))\s*`([^`]+)`[ \t]*((?:->|→)[ \t]*\S+)?/g;
156
+ const BACKTICKED_RE =
157
+ /(?:`verify:`|verify:(?!`))\s*`([^`]+)`[ \t]*((?:->|→)[ \t]*(?:\/(?:[^\\/]|\\.)*\/(?=\s|$)|\S+))?/g;
148
158
 
149
159
  /** Prose after the label. The MANUAL form; the body is bounded separately. */
150
160
  const PROSE_RE = /(?:`verify:`|verify:(?!`))[ \t]*/g;
@@ -149,11 +149,26 @@ function isGlobOverAcceptableInboxDir(name: string): boolean {
149
149
  if (!/[*?[\]]/.test(name)) {
150
150
  return false;
151
151
  }
152
- const literal = name.replace(/[*?]|\[[^\]]*\]/g, '');
152
+ // Only a generic class is stripped. Any other bracket payload stays in the
153
+ // literal and is judged: `[acme]` names a source, and so does a run of
154
+ // singleton ranges like `[a-a][c-c]`.
155
+ const literal = name.replace(/[*?]|\[(?:a-z|0-9|a-f|0-9a-f|a-z0-9)\]/gi, '');
156
+ if (OPAQUE_FAMILY_WILDCARD_RE.test(name)) {
157
+ return true;
158
+ }
153
159
  const trimmed = literal.replace(/-+$/, '');
154
160
  return trimmed !== '' && isAcceptableInboxDir(trimmed);
155
161
  }
156
162
 
163
+ /**
164
+ * The fixed head of an opaque identifier family followed by wildcards only.
165
+ * `round-*` lists hex rounds; as a literal it would create `round-*`, which
166
+ * carries no source. Brackets are excluded because their payload survives as
167
+ * literal text: `round-[acme]-*` must not pass by stripping to `round`.
168
+ */
169
+ const OPAQUE_FAMILY_WILDCARD_RE =
170
+ /^(?:(?:inbox|round|src-set|source-set)(?:-\d{4}(?:-\d{2})?)?|set)-[*?]+$/i;
171
+
157
172
  /**
158
173
  * The paths a redirect inside one segment would create.
159
174
  *
@@ -48,6 +48,37 @@
48
48
  * reason instead of passing silently. `src/rules/design-review-after-ui-write.md`
49
49
  * already required that in prose with nothing to read it; this emits it.
50
50
  *
51
+ * SVG ROUTING, AND HOW TO REVERT IT. Step 1.3 of
52
+ * `road-to-svg-rules-in-the-dispatch-path`. An `.svg` write reaches
53
+ * `lint_svg_asset`'s rules from here, inside or outside a UI tree, through
54
+ * `svg_rules_loader` — a CJS chunk beside the dispatch bundle, required only
55
+ * for a path ending in `.svg`. The review that withdrew the first attempt
56
+ * named the gap plainly: a statically bundled rule set has no off switch, so
57
+ * the triggers and the mechanism are written down before they are needed.
58
+ *
59
+ * REVERT WHEN any of these is read:
60
+ * · the composition gate exceeds its ceiling attributable to this routing
61
+ * (`check_hook_bundle_composition`; measured 2,128 B on 2026-10-11, with
62
+ * 16,910 B of headroom left);
63
+ * · a non-`.svg` hook event is observed failing with a stack that passes
64
+ * through the chunk — the containment case
65
+ * `design_pass_svg.test.ts > rule-failure-does-not-reach-an-unrelated-event`
66
+ * exists to make impossible, so one observation is enough;
67
+ * · an operator reports SVG false positives on files they accept, at a rate
68
+ * that would make them switch the carrier off — Risk 1 of the parent
69
+ * roadmap, and the failure this whole concern is shaped around.
70
+ *
71
+ * THE MECHANISM, in increasing order of blast radius:
72
+ * 1. Delete `dist/hooks/svg_rules.chunk.cjs`. The loader returns `null`,
73
+ * the pass reports `verification: degraded` naming the missing file,
74
+ * and every other finding family is unaffected. This is the per-install
75
+ * off switch the static import could not have had.
76
+ * 2. Drop `isSvgAsset` from `isPassSurface` below. An `.svg` outside a UI
77
+ * tree stops reaching the pass at all; one inside it reads as it did
78
+ * before this step.
79
+ * 3. Turn the concern off entirely (`hooks.design_pass.enabled: false`),
80
+ * which was already the default.
81
+ *
51
82
  * Default-OFF (`hooks.design_pass.enabled`), fail_closed: false.
52
83
  *
53
84
  * Exit codes (dispatcher contract): 0 allow · 1 block · 2 warn + JSON reason.
@@ -62,6 +93,7 @@ import { trigger_matches, type Trigger } from '../_lib/router_match.js';
62
93
  import { isUiPath, isUiTreePath } from '../_lib/ui_surface.js';
63
94
  import { loadDesignContext, scanFile } from '../lint_design_slop.js';
64
95
  import { readHookStdin } from './hook_stdin.js';
96
+ import { CHUNK_BASENAME, loadSvgScan } from './svg_rules_loader.js';
65
97
  import { EXIT_ALLOW, EXIT_WARN } from './exit_codes.js';
66
98
 
67
99
  const SETTINGS_FILE = '.agent-settings.yml';
@@ -129,8 +161,42 @@ export function enabled(root: string, key = 'design_pass'): boolean {
129
161
  return false;
130
162
  }
131
163
 
164
+ /**
165
+ * True for a path this pass should look at.
166
+ *
167
+ * `.svg` is admitted HERE and nowhere else — step 1.2 of
168
+ * `road-to-svg-rules-in-the-dispatch-path`. The obvious way to reach an `.svg`
169
+ * from this pass was to add `svg` to `UI_EXTENSIONS`, and that is the thing the
170
+ * parent roadmap's decision E10 and `design_pass_svg.test.ts` forbid: the same
171
+ * predicate backs `ui_route_nudge_hook`'s `isUiWrite`, so widening it would
172
+ * draw a UI routing nudge and its tokens on every icon dropped into `public/`.
173
+ * A local disjunct reaches the asset without touching the shared predicate, in
174
+ * either direction.
175
+ */
176
+ export const isSvgAsset = (p: string): boolean => /\.svg$/i.test(p);
177
+
178
+ /**
179
+ * UNCHANGED, and that is the point of the predicate below it.
180
+ *
181
+ * Finding 6 of this change's own completion review: widening THIS predicate to
182
+ * admit every `.svg` would have moved the UI-turn denominator the
183
+ * consultation-rate measurement is defined against — which the
184
+ * `HANDOVER_TRIGGERS` note further down reserves, in writing, as a different
185
+ * decision than any routing change. It was widened and is widened no longer.
186
+ */
132
187
  export const isUiSurface = (p: string): boolean => isUiPath(p) || isUiTreePath(p);
133
188
 
189
+ /**
190
+ * What this PASS looks at — a UI surface, or any `.svg`.
191
+ *
192
+ * The routing step 1.2 of `road-to-svg-rules-in-the-dispatch-path` asked for,
193
+ * in the one place it belongs: the pass's own entry gate. Keeping it out of
194
+ * `isUiSurface` leaves every other reading that predicate feeds — the
195
+ * conformance verdict's noteworthy test, the denominator above — reading
196
+ * exactly what it read before.
197
+ */
198
+ export const isPassSurface = (p: string): boolean => isUiSurface(p) || isSvgAsset(p);
199
+
134
200
  /**
135
201
  * The shadow read of `ui-conformance.json` — Phase 5 of
136
202
  * `road-to-behaviour-evidence-over-pixels`.
@@ -441,6 +507,53 @@ function renderAvailable(root: string): boolean {
441
507
  }
442
508
  }
443
509
 
510
+ /**
511
+ * SVG severity → this pass's severity.
512
+ *
513
+ * `error` and `warning` become P1 and P2; `inert` is dropped. Deliberately NOT
514
+ * P0: the P0 set above is a closed list of objective floors and it is the only
515
+ * thing that computes a block at stop. Routing a new rule family into it would
516
+ * change the block surface, which is a different decision than making the
517
+ * findings visible — and the one Risk 1 of the parent roadmap warns about.
518
+ */
519
+ export function svgSeverity(s: 'error' | 'warning' | 'inert'): Finding['severity'] | null {
520
+ if (s === 'error') return 'P1';
521
+ if (s === 'warning') return 'P2';
522
+ return null;
523
+ }
524
+
525
+ export interface SvgRuleFinding {
526
+ rule: string;
527
+ file: string;
528
+ line: number;
529
+ snippet: string;
530
+ severity: 'error' | 'warning' | 'inert';
531
+ }
532
+
533
+ /**
534
+ * Map the SVG rule set's findings onto this pass's shape.
535
+ *
536
+ * Pure, and exported, so the routing can be proven without building a bundle:
537
+ * the half that is expensive to exercise end-to-end is the chunk load, and that
538
+ * has its own test in `svg_rules_loader`.
539
+ */
540
+ export function svgFindings(raw: readonly SvgRuleFinding[], rel: string): Finding[] {
541
+ const out: Finding[] = [];
542
+ for (const f of raw) {
543
+ const severity = svgSeverity(f.severity);
544
+ if (severity === null) continue;
545
+ out.push({
546
+ file: rel,
547
+ severity,
548
+ catalogId: 'SVG',
549
+ rule: f.rule,
550
+ line: f.line,
551
+ message: `${f.rule}${f.snippet ? ` — ${f.snippet}` : ''}`,
552
+ });
553
+ }
554
+ return out;
555
+ }
556
+
444
557
  /** Extracts the written path from a tool payload, whichever key the host uses. */
445
558
  export function targetPath(payload: unknown): string | null {
446
559
  if (typeof payload !== 'object' || payload === null) return null;
@@ -499,7 +612,7 @@ function main(): number {
499
612
  targets = state.touched;
500
613
  } else {
501
614
  const t = targetPath(payload);
502
- if (!t || !isUiSurface(t)) return EXIT_ALLOW;
615
+ if (!t || !isPassSurface(t)) return EXIT_ALLOW;
503
616
  targets = [t];
504
617
  if (!state.touched.includes(t)) state.touched.push(t);
505
618
  }
@@ -508,6 +621,7 @@ function main(): number {
508
621
  // Findings come from the shipped registry, via the same entrypoint CI uses.
509
622
  const findings: Finding[] = [];
510
623
  let scanFailed: string | null = null;
624
+ let svgRulesUnavailable = false;
511
625
  try {
512
626
  const ctx = loadDesignContext(root);
513
627
  for (const rel of targets) {
@@ -517,6 +631,17 @@ function main(): number {
517
631
  // itself rather than handing over a path, which is also what lets a
518
632
  // post-write pass see the bytes that were just written.
519
633
  const content = fs.readFileSync(abs, 'utf-8');
634
+ if (isSvgAsset(rel)) {
635
+ // The conditional chunk. Loaded only here, so a hook event that
636
+ // never touched an `.svg` pays none of its bytes and runs none
637
+ // of its code — the containment `design_pass_svg.test.ts` pins.
638
+ // A null load is reported through `svgRulesUnavailable`, never
639
+ // thrown: a consumer whose install is missing the chunk must
640
+ // lose the SVG findings, not the whole dispatch.
641
+ const scan = loadSvgScan();
642
+ if (scan === null) svgRulesUnavailable = true;
643
+ else findings.push(...svgFindings(scan(content, rel) as SvgRuleFinding[], rel));
644
+ }
520
645
  for (const f of scanFile(content, rel, ctx)) {
521
646
  findings.push({
522
647
  file: rel,
@@ -532,11 +657,40 @@ function main(): number {
532
657
  scanFailed = String(err);
533
658
  }
534
659
 
535
- const auditMissing = targets.filter((t) => !auditIsFresh(root, t));
536
- recordAuditDischarge(root, targets, auditMissing);
660
+ // The audit question is asked of UI SURFACES, never of the pass surface.
661
+ // Findings 1 and 2 of the third completion review, both reproduced by
662
+ // running the hook: round one split the entry gate from the UI predicate,
663
+ // and `targets` carried the `.svg` past the split anyway. So a clean
664
+ // conformant icon write exited WARN with "no ui-audit.json newer than
665
+ // public/icon.svg" — an asset has no component inventory to be audited
666
+ // against — and, worse, `recordAuditDischarge` returns early on a non-empty
667
+ // `auditMissing`, so writing ONE `.svg` in a turn silently suppressed the
668
+ // ui-audit discharge row for every real UI file in that same turn.
669
+ const auditTargets = targets.filter((t) => isUiSurface(t));
670
+ const auditMissing = auditTargets.filter((t) => !auditIsFresh(root, t));
671
+ recordAuditDischarge(root, auditTargets, auditMissing);
537
672
  let result = decide(slot, findings, auditMissing, new Set(state.surfaced), renderAvailable(root));
538
673
  if (scanFailed) {
539
674
  result = { ...result, verification: 'unverified', degradation_reason: `detector unavailable: ${scanFailed}` };
675
+ } else if (svgRulesUnavailable) {
676
+ // Degraded, not unverified: the rest of the pass ran. An absent chunk
677
+ // is reported rather than silently producing a clean SVG verdict —
678
+ // "absent is not clean" is the same rule the conformance verdict keeps.
679
+ // APPENDED, never overwritten. Finding 6 of the fourth completion
680
+ // review: `decide` may already have set a reason — the renderer being
681
+ // absent is the one it sets — and assigning here threw it away, so a
682
+ // turn where both degradations held reported only the newer one. Two
683
+ // degradations are two facts; the operator needs both or they fix one
684
+ // and are surprised by the other.
685
+ const svgReason = `the SVG rule set could not be loaded (expected dist/hooks/${CHUNK_BASENAME} beside the dispatch bundle) — the .svg files in this turn were not checked`;
686
+ result = {
687
+ ...result,
688
+ verification: 'degraded',
689
+ degradation_reason:
690
+ result.degradation_reason === undefined || result.degradation_reason === ''
691
+ ? svgReason
692
+ : `${result.degradation_reason}; ${svgReason}`,
693
+ };
540
694
  }
541
695
 
542
696
  for (const f of result.findings) state.surfaced.push(`${f.file}::${f.rule}::${f.line}`);