@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,1453 @@
1
+ /**
2
+ * svg_rules — the SVG rule set, with no command-line surface attached.
3
+ *
4
+ * Split out of `lint_svg_asset.ts` for a measured reason, not a tidy one, and
5
+ * the routing it was split FOR IS NOW LIVE.
6
+ *
7
+ * THIS PARAGRAPH USED TO SAY THE OPPOSITE, and it was false the moment the
8
+ * routing landed — finding 10 of that change's own completion review, which is
9
+ * the cheapest kind of defect to leave behind and the most expensive to read: a
10
+ * reader opening this file to decide whether it sits on a hot path was told it
11
+ * does not.
12
+ *
13
+ * It does. `hooks/design_pass_hook.ts` routes every `.svg` write here through
14
+ * `hooks/svg_rules_loader.ts`, and `package.json`'s `build:hook-chunks` emits
15
+ * this module as `dist/hooks/svg_rules.chunk.cjs` for exactly that. The chunk
16
+ * is why it is live without being expensive: step 1.4 of
17
+ * `road-to-svg-with-a-witness` imported these rules STATICALLY into the one
18
+ * bundle every hook event loads — 30,924 bytes, 12,931 past the ceiling — and
19
+ * an independent council refused it 2/2. Loaded as a chunk the same rules cost
20
+ * 2,128 bytes in that bundle and are `require`d only for a path ending in
21
+ * `.svg`.
22
+ *
23
+ * So: this module is on the `.svg` write path and on no other. Anything added
24
+ * here is paid by an SVG write and by nothing else — a budget, not a licence.
25
+ * Its other consumers are `lint_svg_asset` and the fixture report.
26
+ *
27
+ * `lint_svg_asset.ts` is now the CLI over this module and re-exports it, so
28
+ * every existing import keeps resolving.
29
+ */
30
+
31
+ import { attrsOf, parseDocument, tagEnd, type XmlDocument, type XmlNode } from './svg_dom.js';
32
+ import { cssUnescape, isActiveUrl, localName, rawAttrs, resolvedUrl, svgPrefixes, URL_ATTRS } from './svg_urls.js';
33
+ import {
34
+ type Box,
35
+ elementExtent,
36
+ pathDataError,
37
+ pathExtent,
38
+ pointsExtent,
39
+ staticBox,
40
+ tokenisePath,
41
+ } from './svg_geometry.js';
42
+
43
+ export { elementExtent, pathDataError, pathExtent, pointsExtent, staticBox, tokenisePath };
44
+ export type { Box };
45
+ import {
46
+ SMIL_TAGS,
47
+ declarationsOf,
48
+ firstSmilLine,
49
+ hasCssAnimation,
50
+ hasReducedMotionQuery,
51
+ hasSmilMarkup,
52
+ readCssRules,
53
+ smilVerdict,
54
+ } from './svg_motion.js';
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Types
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Where the file is embedded. It decides what can EXECUTE, which is why the
62
+ * safety family reads it (step 1.5). An undeclared mode is graded as `inline`,
63
+ * the strictest — never as the mode that happens to be convenient.
64
+ */
65
+ export type DeliveryMode = 'inline' | 'document' | 'img' | 'css-bg';
66
+
67
+ export const DELIVERY_MODES: readonly DeliveryMode[] = ['inline', 'document', 'img', 'css-bg'];
68
+
69
+ export type Severity = 'error' | 'warning' | 'inert';
70
+
71
+ export interface Finding {
72
+ rule: string;
73
+ file: string;
74
+ line: number;
75
+ snippet: string;
76
+ severity: Severity;
77
+ }
78
+
79
+ export interface ProjectConfig {
80
+ ignoreRules?: string[];
81
+ ignoreFiles?: string[];
82
+ }
83
+
84
+ /** One parsed element. Attribute values are raw (entity-decoded for `&` only). */
85
+ export interface SvgElement {
86
+ name: string;
87
+ attrs: Record<string, string>;
88
+ line: number;
89
+ /** Character offset of the opening `<`. */
90
+ start: number;
91
+ selfClosing: boolean;
92
+ closing: boolean;
93
+ raw: string;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // A deliberately small scanner
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Elements in document order.
102
+ *
103
+ * Not a conformant XML parser and not trying to be: it skips comments, CDATA
104
+ * and the prolog, and reads tag names plus attributes. Everything the rules
105
+ * below need is attribute-level or text-level, and pulling in a real parser
106
+ * would make a dependency-free linter depend on one.
107
+ */
108
+ export function parseElements(content: string): SvgElement[] {
109
+ const out: SvgElement[] = [];
110
+ const lineOf = lineIndexer(content);
111
+ let i = 0;
112
+ while (i < content.length) {
113
+ const lt = content.indexOf('<', i);
114
+ if (lt === -1) break;
115
+ // Comments, CDATA and the prolog carry no elements.
116
+ if (content.startsWith('<!--', lt)) {
117
+ const end = content.indexOf('-->', lt + 4);
118
+ i = end === -1 ? content.length : end + 3;
119
+ continue;
120
+ }
121
+ if (content.startsWith('<![CDATA[', lt)) {
122
+ const end = content.indexOf(']]>', lt + 9);
123
+ i = end === -1 ? content.length : end + 3;
124
+ continue;
125
+ }
126
+ if (content.startsWith('<?', lt)) {
127
+ // A PROCESSING INSTRUCTION ENDS AT `?>`, NEVER AT THE FIRST `>`.
128
+ //
129
+ // Finding 3 of the third completion review, reproduced by running
130
+ // `scanSvg`: this branch shared the DOCTYPE's first-`>` search, so
131
+ // `<?xml-stylesheet href="a.css?x=1><script/>"?>` — a legal PI
132
+ // whose quoted pseudo-attribute contains a `>` — ended early and
133
+ // the remainder was parsed as markup. `<script/>` then became a
134
+ // real element and the file was reported
135
+ // `svg-safety-active-content` at severity `error`, on a document
136
+ // that contains no script. Under finding 4's static-only path that
137
+ // false error is a BLOCKING refusal, so the two compound.
138
+ //
139
+ // `svg_dom.parseDocument` always did this correctly; the two
140
+ // scanners disagreeing about where the prolog ends is what the
141
+ // module header below now says plainly instead of claiming they
142
+ // are one scan.
143
+ const end = content.indexOf('?>', lt + 2);
144
+ i = end === -1 ? content.length : end + 2;
145
+ continue;
146
+ }
147
+ if (content.startsWith('<!', lt)) {
148
+ // A DOCTYPE may carry a bracketed internal subset; skip past it.
149
+ const bracket = content.indexOf('[', lt);
150
+ const close = content.indexOf('>', lt);
151
+ if (bracket !== -1 && close !== -1 && bracket < close) {
152
+ const endBracket = content.indexOf(']', bracket);
153
+ const after = endBracket === -1 ? close : content.indexOf('>', endBracket);
154
+ i = after === -1 ? content.length : after + 1;
155
+ } else {
156
+ i = close === -1 ? content.length : close + 1;
157
+ }
158
+ continue;
159
+ }
160
+ const gt = tagEnd(content, lt);
161
+ if (gt === -1) break;
162
+ const raw = content.slice(lt, gt + 1);
163
+ const closing = raw.startsWith('</');
164
+ const selfClosing = raw.endsWith('/>');
165
+ const nameMatch = /^<\/?\s*([A-Za-z_][\w.:-]*)/.exec(raw);
166
+ if (!nameMatch) {
167
+ i = gt + 1;
168
+ continue;
169
+ }
170
+ out.push({
171
+ name: (nameMatch[1] ?? '').toLowerCase(),
172
+ attrs: closing ? {} : attrsOf(raw),
173
+ line: lineOf(lt),
174
+ start: lt,
175
+ selfClosing,
176
+ closing,
177
+ raw,
178
+ });
179
+ i = gt + 1;
180
+ }
181
+ return out;
182
+ }
183
+
184
+ /** End of the tag, honouring quoted attribute values that may contain `>`. */
185
+
186
+
187
+ /**
188
+ * Blank out every XML comment, preserving offsets and line breaks.
189
+ *
190
+ * Finding 2.3 of `road-to-svg-rules-in-the-dispatch-path`, from the R2
191
+ * completion review of the parent change: `parseElements` skips comments and
192
+ * the content-level regex rules did not, so the two disagreed about what the
193
+ * file contains. A commented `url(#x)` marked a `<defs>` entry used, and a
194
+ * commented `@media (prefers-reduced-motion)` suppressed the
195
+ * no-reduced-motion rule — in both directions a finding was lost to markup
196
+ * that does not render.
197
+ *
198
+ * Every character inside `<!--` … `-->` becomes a space, the delimiters
199
+ * included, and newlines are kept. That keeps every offset and every line
200
+ * number identical to the raw text, so a rule can report a position in the
201
+ * masked string and have it mean the same thing in the file. An unterminated
202
+ * comment masks to end of input, which is what a browser does with it too.
203
+ *
204
+ * NOT applied to `parseIgnores`, deliberately: the inline
205
+ * `svg-asset-disable` markers LIVE in comments, so masking them would make every one of them silently stop
206
+ * working. The raw text is the right input there and the masked text is the
207
+ * right input everywhere else.
208
+ */
209
+ export function maskComments(content: string): string {
210
+ if (!content.includes('<!--')) return content;
211
+ const out = content.split('');
212
+ let i = 0;
213
+ while (i < out.length) {
214
+ const start = content.indexOf('<!--', i);
215
+ if (start === -1) break;
216
+ const close = content.indexOf('-->', start + 4);
217
+ const end = close === -1 ? content.length : close + 3;
218
+ for (let j = start; j < end; j += 1) {
219
+ if (out[j] !== '\n' && out[j] !== '\r') out[j] = ' ';
220
+ }
221
+ i = end;
222
+ }
223
+ return out.join('');
224
+ }
225
+
226
+ function lineIndexer(content: string): (offset: number) => number {
227
+ const starts: number[] = [0];
228
+ for (let i = 0; i < content.length; i++) if (content[i] === '\n') starts.push(i + 1);
229
+ return (offset: number): number => {
230
+ let lo = 0;
231
+ let hi = starts.length - 1;
232
+ while (lo < hi) {
233
+ const mid = (lo + hi + 1) >> 1;
234
+ if ((starts[mid] as number) <= offset) lo = mid;
235
+ else hi = mid - 1;
236
+ }
237
+ return lo + 1;
238
+ };
239
+ }
240
+
241
+ /** The text between an opening and its matching closing tag, for simple leaves. */
242
+ function textOf(content: string, elements: SvgElement[], index: number): string {
243
+ const el = elements[index];
244
+ if (!el || el.selfClosing) return '';
245
+ const openEnd = content.indexOf('>', el.start);
246
+ const closeTag = `</${el.name}`;
247
+ const closeAt = content.toLowerCase().indexOf(closeTag, openEnd);
248
+ if (openEnd === -1 || closeAt === -1) return '';
249
+ return content.slice(openEnd + 1, closeAt);
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Shared readings
254
+ // ---------------------------------------------------------------------------
255
+
256
+ export interface ViewBox {
257
+ x: number;
258
+ y: number;
259
+ w: number;
260
+ h: number;
261
+ }
262
+
263
+ export function readViewBox(el: SvgElement | undefined): ViewBox | null {
264
+ const raw = el?.attrs['viewbox'];
265
+ if (!raw) return null;
266
+ const parts = raw.trim().split(/[\s,]+/).map(Number);
267
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return null;
268
+ const [x, y, w, h] = parts as [number, number, number, number];
269
+ if (w <= 0 || h <= 0) return null;
270
+ return { x, y, w, h };
271
+ }
272
+
273
+ /** Every `#id` this file points at, from `href`/`xlink:href` and from `url(#…)`. */
274
+ export function referencedIds(content: string, elements: SvgElement[]): Set<string> {
275
+ const ids = new Set<string>();
276
+ for (const el of elements) {
277
+ for (const key of ['href', 'xlink:href']) {
278
+ const v = el.attrs[key];
279
+ if (v && v.startsWith('#')) ids.add(v.slice(1));
280
+ }
281
+ }
282
+ const urlRe = /url\(\s*['"]?#([^)'"\s]+)/g;
283
+ let m: RegExpExecArray | null;
284
+ while ((m = urlRe.exec(content)) !== null) ids.add(m[1] as string);
285
+ // `begin="other.end"` and `<set attributeName=…>` name elements too.
286
+ const beginRe = /\b(?:begin|end)\s*=\s*"([A-Za-z_][\w.:-]*)\./g;
287
+ while ((m = beginRe.exec(content)) !== null) ids.add(m[1] as string);
288
+ return ids;
289
+ }
290
+
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Rule family — structure
294
+ // ---------------------------------------------------------------------------
295
+
296
+ function ruleMissingXmlns(elements: SvgElement[], file: string): Finding[] {
297
+ const root = elements.find((el) => el.name === 'svg' && !el.closing);
298
+ if (!root) return [];
299
+ if (root.attrs['xmlns']) return [];
300
+ return [
301
+ {
302
+ rule: 'svg-structure-missing-xmlns',
303
+ file,
304
+ line: root.line,
305
+ snippet: 'root <svg> has no xmlns — the file does not render as a standalone document or through <img>',
306
+ severity: 'error',
307
+ },
308
+ ];
309
+ }
310
+
311
+ /** Is this element inside a `<defs>`, `<symbol>`, `<clipPath>`, `<mask>` or `<marker>`? */
312
+ function buildContainerDepth(elements: SvgElement[]): Map<number, string[]> {
313
+ const stack: string[] = [];
314
+ const out = new Map<number, string[]>();
315
+ elements.forEach((el, idx) => {
316
+ if (el.closing) {
317
+ if (stack[stack.length - 1] === el.name) stack.pop();
318
+ out.set(idx, [...stack]);
319
+ return;
320
+ }
321
+ out.set(idx, [...stack]);
322
+ if (!el.selfClosing) stack.push(el.name);
323
+ });
324
+ return out;
325
+ }
326
+
327
+ const NON_RENDERED_CONTAINERS = new Set(['defs', 'symbol', 'clippath', 'mask', 'marker', 'pattern']);
328
+
329
+ function ruleOutsideViewBox(elements: SvgElement[], file: string): Finding[] {
330
+ const root = elements.find((el) => el.name === 'svg' && !el.closing);
331
+ const vb = readViewBox(root);
332
+ if (!vb) return [];
333
+ const ancestry = buildContainerDepth(elements);
334
+ const findings: Finding[] = [];
335
+ elements.forEach((el, idx) => {
336
+ if (el.closing) return;
337
+ const chain = ancestry.get(idx) ?? [];
338
+ if (chain.some((c) => NON_RENDERED_CONTAINERS.has(c))) return;
339
+ // A transform anywhere up the chain moves the box; staticBox already
340
+ // refuses an element with its own, and this refuses an inherited one.
341
+ if (chain.length && hasTransformedAncestor(elements, idx)) return;
342
+ const box = staticBox(el);
343
+ if (!box) return;
344
+ const outside =
345
+ box.maxX <= vb.x || box.minX >= vb.x + vb.w || box.maxY <= vb.y || box.minY >= vb.y + vb.h;
346
+ if (!outside) return;
347
+ findings.push({
348
+ rule: 'svg-structure-outside-viewbox',
349
+ file,
350
+ line: el.line,
351
+ snippet:
352
+ `<${el.name}> box (${box.minX},${box.minY})-(${box.maxX},${box.maxY}) lies entirely outside ` +
353
+ `viewBox ${vb.x} ${vb.y} ${vb.w} ${vb.h} — it will never paint`,
354
+ severity: 'error',
355
+ });
356
+ });
357
+ return findings;
358
+ }
359
+
360
+ function hasTransformedAncestor(elements: SvgElement[], index: number): boolean {
361
+ const stack: SvgElement[] = [];
362
+ for (let i = 0; i < index; i++) {
363
+ const el = elements[i] as SvgElement;
364
+ if (el.closing) {
365
+ if (stack[stack.length - 1]?.name === el.name) stack.pop();
366
+ continue;
367
+ }
368
+ if (!el.selfClosing) stack.push(el);
369
+ }
370
+ return stack.some((el) => Boolean(el.attrs['transform']));
371
+ }
372
+
373
+ /**
374
+ * Path-data grammar check.
375
+ *
376
+ * Reports only data that no conformant parser could accept — a command letter
377
+ * followed by the wrong number of coordinates, or a stray token. A path whose
378
+ * numbers are odd but well-formed is a design question, not a parse error.
379
+ */
380
+ function ruleUnparseablePath(elements: SvgElement[], file: string): Finding[] {
381
+ const findings: Finding[] = [];
382
+ for (const el of elements) {
383
+ if (el.closing) continue;
384
+ const d = el.attrs['d'];
385
+ if (d === undefined) continue;
386
+ const err = pathDataError(d);
387
+ if (err) {
388
+ findings.push({
389
+ rule: 'svg-structure-unparseable-path',
390
+ file,
391
+ line: el.line,
392
+ snippet: `<${el.name} d="…"> ${err}`,
393
+ severity: 'error',
394
+ });
395
+ }
396
+ }
397
+ return findings;
398
+ }
399
+
400
+ function ruleUnusedDefs(content: string, elements: SvgElement[], file: string): Finding[] {
401
+ const used = referencedIds(content, elements);
402
+ const ancestry = buildContainerDepth(elements);
403
+ const findings: Finding[] = [];
404
+ elements.forEach((el, idx) => {
405
+ if (el.closing) return;
406
+ const id = el.attrs['id'];
407
+ if (!id) return;
408
+ const chain = ancestry.get(idx) ?? [];
409
+ if (!chain.includes('defs')) return;
410
+ // A `<symbol>` is a sprite-sheet entry addressed from a HOST page, so
411
+ // the file cannot see its own reference. Exempt by construction —
412
+ // flagging it would red every sprite sheet in existence.
413
+ if (el.name === 'symbol') return;
414
+ if (used.has(id)) return;
415
+ findings.push({
416
+ rule: 'svg-structure-unused-defs',
417
+ file,
418
+ line: el.line,
419
+ snippet: `<${el.name} id="${id}"> sits in <defs> and nothing in this file references it`,
420
+ severity: 'warning',
421
+ });
422
+ });
423
+ return findings;
424
+ }
425
+
426
+ function ruleDanglingReference(content: string, elements: SvgElement[], file: string): Finding[] {
427
+ const defined = new Set<string>();
428
+ for (const el of elements) if (!el.closing && el.attrs['id']) defined.add(el.attrs['id'] as string);
429
+ const findings: Finding[] = [];
430
+ const lineOf = lineIndexer(content);
431
+
432
+ for (const el of elements) {
433
+ if (el.closing) continue;
434
+ for (const key of ['href', 'xlink:href']) {
435
+ const v = el.attrs[key];
436
+ if (!v || !v.startsWith('#')) continue;
437
+ const id = v.slice(1);
438
+ if (defined.has(id)) continue;
439
+ findings.push({
440
+ rule: 'svg-structure-dangling-reference',
441
+ file,
442
+ line: el.line,
443
+ snippet: `<${el.name} ${key}="${v}"> points at an id this file does not define`,
444
+ severity: 'error',
445
+ });
446
+ }
447
+ }
448
+ const urlRe = /url\(\s*['"]?#([^)'"\s]+)/g;
449
+ let m: RegExpExecArray | null;
450
+ while ((m = urlRe.exec(content)) !== null) {
451
+ const id = m[1] as string;
452
+ if (defined.has(id)) continue;
453
+ findings.push({
454
+ rule: 'svg-structure-dangling-reference',
455
+ file,
456
+ line: lineOf(m.index),
457
+ snippet: `url(#${id}) points at an id this file does not define`,
458
+ severity: 'error',
459
+ });
460
+ }
461
+ return dedupe(findings);
462
+ }
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // Rule family — safety, graded per delivery mode (step 1.5)
466
+ // ---------------------------------------------------------------------------
467
+
468
+ /**
469
+ * What a delivery mode can execute.
470
+ *
471
+ * `inline` and `document` run script in an origin that matters, so active
472
+ * content is an ERROR there. `img` and `css-bg` run nothing and load nothing,
473
+ * so the same file is `inert` — reported, never called clean, because the same
474
+ * bytes can be inlined tomorrow with no edit to the file (decision E12).
475
+ */
476
+ export function safetySeverity(mode: DeliveryMode): Severity {
477
+ return mode === 'img' || mode === 'css-bg' ? 'inert' : 'error';
478
+ }
479
+
480
+ // An UNQUOTED value counts. Finding 1 of the fifth completion review: this
481
+ // fallback exists because the attribute parser only sees quoted values, and it
482
+ // required a quote itself — so `onload=alert(1)`, which every browser runs,
483
+ // produced zero findings while `onload="alert(1)"` produced an error. The
484
+ // safety family missing the unquoted spelling is the one miss that matters,
485
+ // because an attacker writes whichever spelling is not checked.
486
+ const ACTIVE_ATTR_RE = /\son[a-z]+\s*=\s*(["']|[^\s>"'])/i;
487
+
488
+
489
+ /** Elements that run or embed foreign content when the graphic is inlined. */
490
+ const ACTIVE_ELEMENTS = new Set(['script', 'foreignobject', 'iframe', 'embed', 'object']);
491
+
492
+ function ruleActiveContent(
493
+ content: string,
494
+ elements: SvgElement[],
495
+ file: string,
496
+ mode: DeliveryMode,
497
+ ): Finding[] {
498
+ const severity = safetySeverity(mode);
499
+ const findings: Finding[] = [];
500
+ const lineOf = lineIndexer(content);
501
+ const prefixes = svgPrefixes(content);
502
+
503
+ for (const el of elements) {
504
+ if (el.closing) continue;
505
+ const local = localName(el.name, prefixes);
506
+ // SMIL that writes an active URL into an href is the same href, later.
507
+ const smil = local === 'set' || local === 'animate' ? rawAttrs(el.raw) : null;
508
+ const animatesHref = smil !== null && /^(xlink:)?href$/i.test(resolvedUrl(smil['attributename'] ?? ''));
509
+ if (
510
+ animatesHref &&
511
+ ['to', 'from', 'by', 'values'].some((k) => (smil[k] ?? '').split(';').some(isActiveUrl))
512
+ ) {
513
+ findings.push({
514
+ rule: 'svg-safety-active-content',
515
+ file,
516
+ line: el.line,
517
+ snippet: `<${el.name} attributeName="href"> animates in an active URL; delivery mode "${mode}" grades it ${severity}`,
518
+ severity,
519
+ });
520
+ }
521
+ if (ACTIVE_ELEMENTS.has(local)) {
522
+ findings.push({
523
+ rule: 'svg-safety-active-content',
524
+ file,
525
+ line: el.line,
526
+ snippet: `<${el.name}> is active content; delivery mode "${mode}" grades it ${severity}`,
527
+ severity,
528
+ });
529
+ }
530
+ for (const [key, value] of Object.entries(el.attrs)) {
531
+ if (/^on[a-z]+$/.test(key)) {
532
+ findings.push({
533
+ rule: 'svg-safety-active-content',
534
+ file,
535
+ line: el.line,
536
+ snippet: `<${el.name} ${key}="…"> is an event handler; delivery mode "${mode}" grades it ${severity}`,
537
+ severity,
538
+ });
539
+ }
540
+ if (/^javascript:/i.test(resolvedUrl(value)) || (URL_ATTRS.has(key) && isActiveUrl(value))) {
541
+ findings.push({
542
+ rule: 'svg-safety-active-content',
543
+ file,
544
+ line: el.line,
545
+ snippet: `<${el.name} ${key}="${resolvedUrl(value).slice(0, 16)}…"> is an active URL; delivery mode "${mode}" grades it ${severity}`,
546
+ severity,
547
+ });
548
+ }
549
+ }
550
+ }
551
+ // A handler written on an element the scanner did not turn into attributes
552
+ // (an unquoted value, say) still has to be seen.
553
+ if (!findings.length && ACTIVE_ATTR_RE.test(content)) {
554
+ const idx = content.search(ACTIVE_ATTR_RE);
555
+ findings.push({
556
+ rule: 'svg-safety-active-content',
557
+ file,
558
+ line: lineOf(idx),
559
+ snippet: `an on* event handler attribute is present; delivery mode "${mode}" grades it ${severity}`,
560
+ severity,
561
+ });
562
+ }
563
+ return dedupe(findings);
564
+ }
565
+
566
+ /**
567
+ * An internal DTD subset or an entity declaration.
568
+ *
569
+ * A BARE public DOCTYPE is clean and deliberately so — exported assets carry
570
+ * one routinely and it is harmless. What is not harmless is the bracketed
571
+ * subset, which is where billion-laughs and external-entity payloads live.
572
+ */
573
+ function ruleInternalSubset(content: string, file: string): Finding[] {
574
+ const lineOf = lineIndexer(content);
575
+ const findings: Finding[] = [];
576
+ const entity = /<!ENTITY\b/i.exec(content);
577
+ if (entity) {
578
+ findings.push({
579
+ rule: 'svg-safety-internal-subset',
580
+ file,
581
+ line: lineOf(entity.index),
582
+ snippet: '<!ENTITY declaration — an entity-expansion surface; a bare public DOCTYPE is clean, this is not',
583
+ severity: 'error',
584
+ });
585
+ }
586
+ const doctype = /<!DOCTYPE[^>[]*\[/i.exec(content);
587
+ if (doctype) {
588
+ findings.push({
589
+ rule: 'svg-safety-internal-subset',
590
+ file,
591
+ line: lineOf(doctype.index),
592
+ snippet: 'DOCTYPE carries an internal subset "[…]" — strip it; a bare public DOCTYPE is clean',
593
+ severity: 'error',
594
+ });
595
+ }
596
+ return dedupe(findings);
597
+ }
598
+
599
+ const EXTERNAL_URL_RE = /^(?:https?:|ftp:|\/\/)/i;
600
+
601
+
602
+ function ruleExternalReference(
603
+ content: string,
604
+ elements: SvgElement[],
605
+ file: string,
606
+ mode: DeliveryMode,
607
+ ): Finding[] {
608
+ const severity = safetySeverity(mode);
609
+ const findings: Finding[] = [];
610
+ const lineOf = lineIndexer(content);
611
+
612
+ for (const el of elements) {
613
+ if (el.closing) continue;
614
+ for (const key of ['href', 'xlink:href', 'src']) {
615
+ const v = el.attrs[key];
616
+ if (v && EXTERNAL_URL_RE.test(resolvedUrl(v))) {
617
+ findings.push({
618
+ rule: 'svg-safety-external-reference',
619
+ file,
620
+ line: el.line,
621
+ snippet: `<${el.name} ${key}="${v.slice(0, 60)}"> loads from outside the file; mode "${mode}" grades it ${severity}`,
622
+ severity,
623
+ });
624
+ }
625
+ }
626
+ }
627
+ const urlRe = /url\(\s*['"]?((?:https?:|ftp:|\/\/)[^)'"\s]+)/gi;
628
+ let m: RegExpExecArray | null;
629
+ while ((m = urlRe.exec(content)) !== null) {
630
+ findings.push({
631
+ rule: 'svg-safety-external-reference',
632
+ file,
633
+ line: lineOf(m.index),
634
+ snippet: `url(${(m[1] ?? '').slice(0, 60)}) loads from outside the file; mode "${mode}" grades it ${severity}`,
635
+ severity,
636
+ });
637
+ }
638
+ // `@import "https://…"` names its target without `url()`. The target is
639
+ // resolved before the scheme test — XML references and CSS escapes both
640
+ // reach the stylesheet loader decoded.
641
+ const importRe = /@import\s+(?:url\(\s*)?(['"])([^'"]*)\1/gi;
642
+ while ((m = importRe.exec(content)) !== null) {
643
+ const target = resolvedUrl(cssUnescape(resolvedUrl(m[2] ?? '')));
644
+ if (!EXTERNAL_URL_RE.test(target)) continue;
645
+ findings.push({
646
+ rule: 'svg-safety-external-reference',
647
+ file,
648
+ line: lineOf(m.index),
649
+ snippet: `@import "${target.slice(0, 60)}" loads from outside the file; mode "${mode}" grades it ${severity}`,
650
+ severity,
651
+ });
652
+ }
653
+ return dedupe(findings);
654
+ }
655
+
656
+ // ---------------------------------------------------------------------------
657
+ // Rule family — vector honesty (defect D3)
658
+ // ---------------------------------------------------------------------------
659
+
660
+
661
+ /** Geometry elements that ARE vector drawing. `rect`/`circle` count (D3's false red). */
662
+ export const VECTOR_GEOMETRY = new Set([
663
+ 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'text', 'textpath', 'use',
664
+ ]);
665
+
666
+ export interface VectorVerdict {
667
+ /** `true` only when the file paints exclusively with vector geometry. */
668
+ trueVector: boolean;
669
+ rasterElements: number;
670
+ vectorElements: number;
671
+ reason: string;
672
+ }
673
+
674
+ /**
675
+ * Replaces the presence-of-`<path>` check (`logo-generation/SKILL.md:53-55`),
676
+ * which was false-green and false-red at once: a raster beside one dummy path
677
+ * passed, and a mark built from `rect`/`circle` — the very thing that file
678
+ * recommends — failed.
679
+ */
680
+ export function vectorVerdict(elements: SvgElement[]): VectorVerdict {
681
+ let raster = 0;
682
+ let vector = 0;
683
+ for (const el of elements) {
684
+ if (el.closing) continue;
685
+ if (el.name === 'image') {
686
+ // EVERY `<image>` counts, whatever it points at. Finding 7 of the
687
+ // fifth completion review: both arms of the `if` added 1, so the
688
+ // test and the regex behind it decided nothing — a dead constant
689
+ // and a dead branch dressed as a discrimination, which is the
690
+ // class this change has now found five times under other names.
691
+ // The comment on the else arm was the correct rule all along: an
692
+ // `<image>` of unknown type is still not vector geometry, and an
693
+ // href this scanner cannot classify is exactly the case where
694
+ // guessing "vector" would be the unsafe answer.
695
+ raster += 1;
696
+ continue;
697
+ }
698
+ if (el.name === 'foreignobject') {
699
+ raster += 1;
700
+ continue;
701
+ }
702
+ if (VECTOR_GEOMETRY.has(el.name)) vector += 1;
703
+ }
704
+ if (raster > 0) {
705
+ return {
706
+ trueVector: false,
707
+ rasterElements: raster,
708
+ vectorElements: vector,
709
+ reason: `carries ${raster} raster/foreignObject element(s) — not a true vector whatever else it contains`,
710
+ };
711
+ }
712
+ return {
713
+ trueVector: vector > 0,
714
+ rasterElements: 0,
715
+ vectorElements: vector,
716
+ reason:
717
+ vector > 0
718
+ ? `${vector} vector geometry element(s), no embedded raster`
719
+ : 'no painted geometry at all',
720
+ };
721
+ }
722
+
723
+ function ruleEmbeddedRaster(elements: SvgElement[], file: string): Finding[] {
724
+ const verdict = vectorVerdict(elements);
725
+ if (verdict.rasterElements === 0) return [];
726
+ const first = elements.find((el) => !el.closing && (el.name === 'image' || el.name === 'foreignobject'));
727
+ return [
728
+ {
729
+ rule: 'svg-vector-embedded-raster',
730
+ file,
731
+ line: first?.line ?? 1,
732
+ snippet: `${verdict.reason}; ${verdict.vectorElements} vector element(s) beside it does not make it one`,
733
+ severity: 'error',
734
+ },
735
+ ];
736
+ }
737
+
738
+ // ---------------------------------------------------------------------------
739
+ // Rule family — accessibility
740
+ // ---------------------------------------------------------------------------
741
+
742
+ /**
743
+ * Ids that collide the moment two graphics are inlined on one page (R7: two
744
+ * inline graphics both using `id="title"` made the accessibility tree name
745
+ * both after the first).
746
+ */
747
+ export const GENERIC_IDS = new Set([
748
+ 'title', 'desc', 'a', 'b', 'c', 'g', 'gradient', 'grad', 'mask', 'clip', 'clippath',
749
+ 'shadow', 'icon', 'path', 'path1', 'layer1', 'layer_1', 'svg', 'defs', 'filter', 'glow',
750
+ ]);
751
+
752
+ function ruleGenericId(elements: SvgElement[], file: string): Finding[] {
753
+ const findings: Finding[] = [];
754
+ for (const el of elements) {
755
+ if (el.closing) continue;
756
+ const id = el.attrs['id'];
757
+ if (!id) continue;
758
+ if (!GENERIC_IDS.has(id.toLowerCase())) continue;
759
+ findings.push({
760
+ rule: 'svg-a11y-generic-id',
761
+ file,
762
+ line: el.line,
763
+ snippet: `id="${id}" collides with any second graphic inlined on the same page — namespace it`,
764
+ severity: 'warning',
765
+ });
766
+ }
767
+ return findings;
768
+ }
769
+
770
+ interface NameState {
771
+ root: SvgElement | undefined;
772
+ hidden: boolean;
773
+ hasTitle: boolean;
774
+ hasLabel: boolean;
775
+ hasLabelledBy: boolean;
776
+ /** `aria-labelledby` resolving to this graphic's own `<title>` — one name, not two. */
777
+ labelledByOwnTitle: boolean;
778
+ hasRoleImg: boolean;
779
+ }
780
+
781
+ /** Is `elements[index]` a direct child of the root `<svg>`? */
782
+ /**
783
+ * Is the element at `index` a direct child of the root graphic?
784
+ *
785
+ * Counts open/close depth over the flat list. Step 3.1 of
786
+ * `road-to-svg-review-followups` moved the AUTHORITY for this question onto
787
+ * the parse tree ({@link directChildrenOfRoot}); this stays as the flat
788
+ * reading it is cross-checked against, because a depth counter and a tree
789
+ * agreeing on every fixture is evidence the tree is right, while replacing one
790
+ * with the other is evidence of nothing.
791
+ */
792
+ function isDirectChildOfRoot(elements: SvgElement[], index: number): boolean {
793
+ let depth = 0;
794
+ for (let i = 0; i < index; i++) {
795
+ const el = elements[i] as SvgElement;
796
+ if (el.closing) depth -= 1;
797
+ else if (!el.selfClosing) depth += 1;
798
+ }
799
+ return depth === 1;
800
+ }
801
+
802
+ /**
803
+ * The root graphic's direct element children, from the parse tree.
804
+ *
805
+ * Parentage decided by ancestry rather than by a depth count: the two agree on
806
+ * a well-formed file and the tree is the one that stays right on a file that
807
+ * is not, because a stray closing tag moves a depth counter and does not move
808
+ * an ancestor chain.
809
+ */
810
+ export function directChildrenOfRoot(doc: XmlDocument): XmlNode[] {
811
+ const root = doc.elements.find((el) => el.name === 'svg');
812
+ if (root === undefined) return [];
813
+ return root.children.filter((n) => n.kind === 'element');
814
+ }
815
+
816
+ /**
817
+ * Well-formedness, reported rather than assumed.
818
+ *
819
+ * Step 3.1. `<g><path></g></path>` carries both tags, both spelled correctly,
820
+ * and is not a tree — which is precisely what a flat element list cannot see
821
+ * and what a pattern over the raw text cannot decide. A file that does not
822
+ * parse is a FINDING, never a skipped file: a skip reads as clean in every
823
+ * count that matters.
824
+ *
825
+ * Capped at the first five errors. One mistake inside a deep document can
826
+ * produce an error per remaining tag, and a wall of them buries the first,
827
+ * which is the only one a reader can act on.
828
+ */
829
+ /**
830
+ * A gradient whose stops are all the same colour is a flat fill in costume.
831
+ *
832
+ * Step 3.1 of `road-to-svg-from-a-live-session`. A rejected 195 KB trace
833
+ * carried 355 gradient definitions, nine of them with identical stops, and the
834
+ * static witness reported one finding: a missing accessible name. Two external
835
+ * reviews counted the gradients by hand. This is cheap to decide from the
836
+ * bytes and nothing was deciding it.
837
+ *
838
+ * WARNING, not an error. A degenerate gradient renders correctly; it costs
839
+ * bytes and makes a file harder to edit, which is a reading rather than a
840
+ * defect — and the cost family above it is deliberately ungated for the same
841
+ * reason.
842
+ *
843
+ * Colour comparison is textual after whitespace and case normalisation, so
844
+ * `#FFF` and `#ffffff` are NOT reported as identical. That is the conservative
845
+ * direction: a false negative costs a reading, a false positive costs trust in
846
+ * every other finding beside it.
847
+ */
848
+ function ruleDegenerateGradient(elements: SvgElement[], file: string): Finding[] {
849
+ const findings: Finding[] = [];
850
+ const open: Array<{ el: SvgElement; stops: Array<{ color: string; opacity: string }> }> = [];
851
+ for (const el of elements) {
852
+ if (el.name === 'lineargradient' || el.name === 'radialgradient') {
853
+ if (el.closing) {
854
+ const g = open.pop();
855
+ if (!g || g.stops.length < 2) continue;
856
+ const first = g.stops[0] as { color: string; opacity: string };
857
+ const same = g.stops.every((s) => s.color === first.color && s.opacity === first.opacity);
858
+ if (!same) continue;
859
+ findings.push({
860
+ rule: 'svg-structure-degenerate-gradient',
861
+ file,
862
+ line: g.el.line,
863
+ snippet:
864
+ `<${g.el.name} id="${g.el.attrs['id'] ?? ''}"> has ${String(g.stops.length)} stops, all ` +
865
+ `${first.color || '(inherited)'} at opacity ${first.opacity} — a flat fill in a gradient's ` +
866
+ 'clothes, which costs bytes and makes the file harder to edit',
867
+ severity: 'warning',
868
+ });
869
+ } else if (!el.selfClosing) {
870
+ open.push({ el, stops: [] });
871
+ }
872
+ continue;
873
+ }
874
+ if (el.name === 'stop' && !el.closing) {
875
+ const host = open[open.length - 1];
876
+ if (!host) continue;
877
+ const style = el.attrs['style'] ?? '';
878
+ const fromStyle = /stop-color\s*:\s*([^;]+)/i.exec(style)?.[1];
879
+ const opacityFromStyle = /stop-opacity\s*:\s*([^;]+)/i.exec(style)?.[1];
880
+ host.stops.push({
881
+ color: (fromStyle ?? el.attrs['stop-color'] ?? '').trim().toLowerCase(),
882
+ opacity: (opacityFromStyle ?? el.attrs['stop-opacity'] ?? '1').trim(),
883
+ });
884
+ }
885
+ }
886
+ return findings;
887
+ }
888
+
889
+ function ruleWellFormed(doc: XmlDocument, file: string): Finding[] {
890
+ if (doc.errors.length === 0) return [];
891
+ const shown = doc.errors.slice(0, 5);
892
+ const more = doc.errors.length - shown.length;
893
+ return shown.map((e, i) => ({
894
+ rule: 'svg-structure-not-well-formed',
895
+ file,
896
+ line: e.line,
897
+ snippet:
898
+ `${e.message} (column ${String(e.column)})` +
899
+ (i === shown.length - 1 && more > 0 ? ` — and ${String(more)} further error(s)` : ''),
900
+ severity: 'error' as Severity,
901
+ }));
902
+ }
903
+
904
+ function rootNameState(content: string, doc: XmlDocument, elements: SvgElement[]): NameState {
905
+ const rootIdx = elements.findIndex((el) => el.name === 'svg' && !el.closing);
906
+ const root = rootIdx === -1 ? undefined : elements[rootIdx];
907
+ // A DIRECT child of the root only. The first `<title>` ANYWHERE was wrong
908
+ // in both directions: a chart's nested series title read as the graphic's
909
+ // name, producing a false `svg-a11y-double-name` beside an `aria-label`,
910
+ // and hiding a genuinely unnamed graphic behind a descendant's title.
911
+ const titleIdx = elements.findIndex(
912
+ (el, i) => el.name === 'title' && !el.closing && rootIdx !== -1 && isDirectChildOfRoot(elements, i),
913
+ );
914
+ // Step 3.1: the PARSE TREE decides parentage, and the depth count above is
915
+ // kept as the reading it is checked against. The two agree on every
916
+ // well-formed file; where they disagree the file is not well-formed, which
917
+ // `svg-structure-not-well-formed` already reports, and the tree is the one
918
+ // that is still right — a stray closing tag moves a depth counter and does
919
+ // not move an ancestor chain.
920
+ const titleIsDirectChild = directChildrenOfRoot(doc).some((n) => n.name === 'title');
921
+ const hasTitle =
922
+ titleIsDirectChild && titleIdx !== -1 && textOf(content, elements, titleIdx).trim().length > 0;
923
+
924
+ // `aria-labelledby="x"` + `<title id="x">` is the CANONICAL pattern, not a
925
+ // collision: the attribute POINTS AT the title rather than competing with
926
+ // it. Counting the pair as two names reds the one shape the accessibility
927
+ // guidance actually asks for, which is the false red that gets a linter
928
+ // switched off (Risk 1).
929
+ const titleIds = new Set(
930
+ elements
931
+ .filter((el) => el.name === 'title' && !el.closing && el.attrs['id'])
932
+ .map((el) => el.attrs['id'] as string),
933
+ );
934
+ const labelledBy = (root?.attrs['aria-labelledby'] ?? '').trim();
935
+ const labelledByTargets = labelledBy.split(/\s+/).filter(Boolean);
936
+ const labelledByOwnTitle =
937
+ labelledByTargets.length > 0 && labelledByTargets.every((id) => titleIds.has(id));
938
+
939
+ return {
940
+ root,
941
+ hidden: (root?.attrs['aria-hidden'] ?? '').toLowerCase() === 'true',
942
+ hasTitle,
943
+ hasLabel: Boolean(root?.attrs['aria-label']?.trim()),
944
+ hasLabelledBy: labelledBy.length > 0,
945
+ labelledByOwnTitle,
946
+ hasRoleImg: (root?.attrs['role'] ?? '').toLowerCase() === 'img',
947
+ };
948
+ }
949
+
950
+ function ruleAccessibleName(s: NameState, file: string): Finding[] {
951
+ if (!s.root) return [];
952
+ // A graphic declared decorative is correct with no name — that is the
953
+ // other half of the pattern, not a gap in it.
954
+ if (s.hidden) return [];
955
+ if (s.hasTitle || s.hasLabel || s.hasLabelledBy) return [];
956
+ return [
957
+ {
958
+ rule: 'svg-a11y-no-accessible-name',
959
+ file,
960
+ line: s.root.line,
961
+ snippet:
962
+ 'graphic has no accessible name and is not aria-hidden="true" — add role="img" with a <title>, ' +
963
+ 'or declare it decorative',
964
+ severity: 'error',
965
+ },
966
+ ];
967
+ }
968
+
969
+ function ruleDoubleName(s: NameState, file: string): Finding[] {
970
+ if (!s.root) return [];
971
+ // A labelledby that resolves to this graphic's own <title> is the SAME
972
+ // name reached through a reference, so it is not counted twice.
973
+ const labelledByIsSeparate = s.hasLabelledBy && !s.labelledByOwnTitle;
974
+ const names = [s.hasLabel, labelledByIsSeparate, s.hasTitle].filter(Boolean).length;
975
+ if (names < 2) return [];
976
+ return [
977
+ {
978
+ rule: 'svg-a11y-double-name',
979
+ file,
980
+ line: s.root.line,
981
+ snippet:
982
+ 'graphic declares more than one accessible name (aria-label / aria-labelledby / <title>) — ' +
983
+ 'only one wins and which one is renderer-dependent',
984
+ severity: 'error',
985
+ },
986
+ ];
987
+ }
988
+
989
+ // ---------------------------------------------------------------------------
990
+ // Rule family — motion. Every rule binds per element or per selector (R9).
991
+ // ---------------------------------------------------------------------------
992
+
993
+ function ruleKeySplinesRange(elements: SvgElement[], file: string): Finding[] {
994
+ const findings: Finding[] = [];
995
+ for (const el of elements) {
996
+ if (el.closing) continue;
997
+ const raw = el.attrs['keysplines'];
998
+ if (!raw) continue;
999
+ const nums = raw.split(/[;\s,]+/).filter(Boolean).map(Number);
1000
+ const bad = nums.filter((n) => Number.isFinite(n) && (n < 0 || n > 1));
1001
+ if (!bad.length) continue;
1002
+ findings.push({
1003
+ rule: 'svg-motion-keysplines-range',
1004
+ file,
1005
+ line: el.line,
1006
+ snippet:
1007
+ `<${el.name} keySplines="${raw}"> holds ${bad.join(', ')} outside [0,1] — SMIL rejects the ` +
1008
+ 'whole animation rather than clamping it, so the element never moves',
1009
+ severity: 'error',
1010
+ });
1011
+ }
1012
+ return findings;
1013
+ }
1014
+
1015
+ /**
1016
+ * `transform-origin` without `transform-box`.
1017
+ *
1018
+ * PER ELEMENT and PER SELECTOR, which is the R9 correction: a file-scoped
1019
+ * "does `transform-box` appear anywhere" test passes a file whose wrong
1020
+ * element sits beside a correct twin, and the fixture plants exactly that.
1021
+ */
1022
+ function ruleTransformBox(content: string, elements: SvgElement[], file: string): Finding[] {
1023
+ const findings: Finding[] = [];
1024
+
1025
+ for (const el of elements) {
1026
+ if (el.closing) continue;
1027
+ const style = el.attrs['style'] ?? '';
1028
+ const decls = declarationsOf(style);
1029
+ const origin = decls['transform-origin'] ?? el.attrs['transform-origin'];
1030
+ if (!origin) continue;
1031
+ if (isOriginDefault(origin)) continue;
1032
+ const box = decls['transform-box'] ?? el.attrs['transform-box'];
1033
+ if (box) continue;
1034
+ findings.push({
1035
+ rule: 'svg-motion-transform-box',
1036
+ file,
1037
+ line: el.line,
1038
+ snippet:
1039
+ `<${el.name}> sets transform-origin: ${origin} with no transform-box — in SVG the origin ` +
1040
+ 'resolves against the view box, not the element, so the pivot lands somewhere else',
1041
+ severity: 'error',
1042
+ });
1043
+ }
1044
+
1045
+ for (const rule of readCssRules(content)) {
1046
+ const decls = declarationsOf(rule.body);
1047
+ const origin = decls['transform-origin'];
1048
+ if (!origin || isOriginDefault(origin)) continue;
1049
+ if (decls['transform-box']) continue;
1050
+ findings.push({
1051
+ rule: 'svg-motion-transform-box',
1052
+ file,
1053
+ line: rule.line,
1054
+ snippet:
1055
+ `selector "${rule.selector}" sets transform-origin: ${origin} with no transform-box — ` +
1056
+ 'the pivot resolves against the view box',
1057
+ severity: 'error',
1058
+ });
1059
+ }
1060
+ return dedupe(findings);
1061
+ }
1062
+
1063
+ function isOriginDefault(value: string): boolean {
1064
+ const v = value.trim().toLowerCase();
1065
+ return v === '0 0' || v === '0px 0px' || v === '0' || v === 'left top' || v === '0% 0%';
1066
+ }
1067
+
1068
+ function ruleNoReducedMotion(content: string, elements: SvgElement[], file: string): Finding[] {
1069
+ if (!hasSmilMarkup(content) && !hasCssAnimation(content)) return [];
1070
+ if (hasReducedMotionQuery(content)) return [];
1071
+ const line = hasSmilMarkup(content)
1072
+ ? firstSmilLine(content)
1073
+ : (elements.find((el) => !el.closing)?.line ?? 1);
1074
+ return [
1075
+ {
1076
+ rule: 'svg-motion-no-reduced-motion',
1077
+ file,
1078
+ line,
1079
+ snippet:
1080
+ 'the file animates and carries no prefers-reduced-motion route at all — a viewer who asked ' +
1081
+ 'the OS to stop motion still gets it',
1082
+ severity: 'error',
1083
+ },
1084
+ ];
1085
+ }
1086
+
1087
+ /**
1088
+ * SMIL whose only reduced-motion route is CSS (R4).
1089
+ *
1090
+ * Measured: under emulated `reduce`, a page-level `svg * { animation: none
1091
+ * !important }` left an `<animate>` moving `cx` from 20.2 to 76.8 in 700 ms.
1092
+ * CSS `animation` does not reach SMIL, so a file that believes it is covered
1093
+ * is covered by nothing. The reading itself lives in `_lib/svg_motion.ts`, so
1094
+ * DQ4's inline half (step 1.3) cannot drift away from this one.
1095
+ */
1096
+ function ruleSmilCssOnlyBranch(content: string, file: string): Finding[] {
1097
+ if (smilVerdict(content) !== 'css-only') return [];
1098
+ return [
1099
+ {
1100
+ rule: 'svg-motion-smil-css-only',
1101
+ file,
1102
+ line: firstSmilLine(content),
1103
+ snippet:
1104
+ 'this file animates with SMIL and its only reduced-motion route is a CSS declaration — ' +
1105
+ 'CSS animation/transition properties do not reach SMIL, so the animation keeps running',
1106
+ severity: 'error',
1107
+ },
1108
+ ];
1109
+ }
1110
+
1111
+ const PATH_COMMAND_RE = /[a-zA-Z]/g;
1112
+
1113
+ /**
1114
+ * An `<animate attributeName="d">` whose `values` entries are not the same
1115
+ * shape. SVG morphs a path by interpolating coordinate lists, so two values
1116
+ * with a different command count or a different command sequence do not
1117
+ * interpolate — the browser snaps instead of morphing, or refuses outright.
1118
+ */
1119
+ function ruleDMorphMismatch(elements: SvgElement[], file: string): Finding[] {
1120
+ const findings: Finding[] = [];
1121
+ for (const el of elements) {
1122
+ if (el.closing) continue;
1123
+ if (!SMIL_TAGS.has(el.name)) continue;
1124
+ if ((el.attrs['attributename'] ?? '').toLowerCase() !== 'd') continue;
1125
+ const raw = el.attrs['values'];
1126
+ if (!raw) continue;
1127
+ const entries = raw.split(';').map((s) => s.trim()).filter(Boolean);
1128
+ if (entries.length < 2) continue;
1129
+ const shapes = entries.map((e) => (e.match(PATH_COMMAND_RE) ?? []).join('').toUpperCase());
1130
+ const first = shapes[0] as string;
1131
+ const differing = shapes.findIndex((s) => s !== first);
1132
+ if (differing === -1) continue;
1133
+ findings.push({
1134
+ rule: 'svg-motion-d-morph-mismatch',
1135
+ file,
1136
+ line: el.line,
1137
+ snippet:
1138
+ `<${el.name} attributeName="d"> value ${differing + 1} has command sequence "${shapes[differing]}" ` +
1139
+ `against "${first}" in value 1 — a path morph interpolates coordinates, not commands`,
1140
+ severity: 'error',
1141
+ });
1142
+ }
1143
+ return findings;
1144
+ }
1145
+
1146
+ // ---------------------------------------------------------------------------
1147
+ // Composition
1148
+ // ---------------------------------------------------------------------------
1149
+
1150
+ function dedupe(findings: Finding[]): Finding[] {
1151
+ const seen = new Set<string>();
1152
+ const out: Finding[] = [];
1153
+ for (const f of findings) {
1154
+ const key = `${f.rule}::${f.file}::${f.line}::${f.snippet}`;
1155
+ if (seen.has(key)) continue;
1156
+ seen.add(key);
1157
+ out.push(f);
1158
+ }
1159
+ return out;
1160
+ }
1161
+
1162
+ export interface ScanOptions {
1163
+ /** Undeclared is graded as `inline`, the strictest (step 1.5). */
1164
+ delivery?: DeliveryMode | undefined;
1165
+ }
1166
+
1167
+ /** Every static finding for one file's bytes. */
1168
+ export function scanSvg(content: string, relPath: string, opts: ScanOptions = {}): Finding[] {
1169
+ const mode: DeliveryMode = opts.delivery ?? 'inline';
1170
+ const elements = parseElements(content);
1171
+ // ONE parse, and ONE name state. Finding 10 of the third completion
1172
+ // review, re-measured because the previous round's disposition was taken
1173
+ // on the wrong regime: over the seven small real fixtures the duplicate
1174
+ // parse is 0.03% of the dispatch, which is why it was accepted then — but
1175
+ // on a 760 KB file `parseDocument` is 8.2 ms against a 163 ms `scanSvg`,
1176
+ // so two redundant parses plus a recomputed name state are about 10% of a
1177
+ // pass that now runs on every `.svg` write. The document and the state are
1178
+ // passed rather than cached: a module-level cache in a hook is shared
1179
+ // mutable state, and the only thing that needed fixing was recomputation
1180
+ // inside one call.
1181
+ const doc = parseDocument(content);
1182
+ const nameState = rootNameState(content, doc, elements);
1183
+ // Finding 2.3: the content-level rules read the SAME file the element
1184
+ // parser reads. `maskComments` keeps every offset, so a line number taken
1185
+ // here still names the right line in the raw text.
1186
+ const text = maskComments(content);
1187
+ const raw: Finding[] = [
1188
+ ...ruleWellFormed(doc, relPath),
1189
+ // No `text` argument: the rule reads elements only, and comment
1190
+ // skipping already happened inside `parseElements`. Passing the masked
1191
+ // text told a reader the parameter was load-bearing (finding 12);
1192
+ // `no-unused-vars` could not catch it because two later parameters were
1193
+ // used.
1194
+ ...ruleDegenerateGradient(elements, relPath),
1195
+ ...ruleMissingXmlns(elements, relPath),
1196
+ ...ruleOutsideViewBox(elements, relPath),
1197
+ ...ruleUnparseablePath(elements, relPath),
1198
+ ...ruleUnusedDefs(text, elements, relPath),
1199
+ ...ruleDanglingReference(text, elements, relPath),
1200
+ ...ruleActiveContent(text, elements, relPath, mode),
1201
+ ...ruleInternalSubset(text, relPath),
1202
+ ...ruleExternalReference(text, elements, relPath, mode),
1203
+ ...ruleEmbeddedRaster(elements, relPath),
1204
+ ...ruleGenericId(elements, relPath),
1205
+ ...ruleAccessibleName(nameState, relPath),
1206
+ ...ruleDoubleName(nameState, relPath),
1207
+ ...ruleKeySplinesRange(elements, relPath),
1208
+ ...ruleTransformBox(text, elements, relPath),
1209
+ ...ruleNoReducedMotion(text, elements, relPath),
1210
+ ...ruleSmilCssOnlyBranch(text, relPath),
1211
+ ...ruleDMorphMismatch(elements, relPath),
1212
+ ];
1213
+ // RAW, not masked — the inline `svg-asset-disable` markers live in comments.
1214
+ const ignores = parseIgnores(content.split('\n'));
1215
+ return dedupe(raw).filter((f) => !isIgnored(ignores, f));
1216
+ }
1217
+
1218
+ /**
1219
+ * Inline SVG inside a host HTML page.
1220
+ *
1221
+ * Each `<svg>…</svg>` is scanned on its own so a per-element rule keeps its
1222
+ * own line numbers, and the id rules then run once ACROSS the graphics —
1223
+ * which is the whole of R7: two graphics are each individually fine and
1224
+ * collide only together.
1225
+ */
1226
+ /**
1227
+ * The TOP-LEVEL `<svg>` elements of a host document, by offset.
1228
+ *
1229
+ * Depth-counted, not matched. Finding 3 of the fifth completion review: the
1230
+ * non-greedy `/<svg\b[\s\S]*?<\/svg\s*>/` ended at the FIRST `</svg>`, which
1231
+ * on a graphic containing a nested `<svg>` — legal, and how a sprite or a
1232
+ * symbol is embedded — is the inner one. The slice was then a truncated
1233
+ * fragment, so the host reported a FALSE `svg-structure-not-well-formed` for
1234
+ * an unclosed root AND missed everything after the inner close, including a
1235
+ * real `<script>` that the same bytes report correctly when scanned flat. A
1236
+ * false structure error and a missed active-content error, from one regex.
1237
+ *
1238
+ * A nested graphic is part of its parent and is not returned separately: the
1239
+ * rules read the whole thing, exactly as a browser does.
1240
+ */
1241
+ /**
1242
+ * Blank the BODY of every `<script>` and `<style>`, offsets and newlines kept.
1243
+ *
1244
+ * Finding 1 of the review of this branch, and it is a regression this branch
1245
+ * introduced: emitting an unterminated graphic made `topLevelGraphics` count
1246
+ * `<svg …>` wherever it appears — including inside a JavaScript string, where
1247
+ * it is text rather than markup. A host containing NO SVG element at all, but
1248
+ * mentioning one in a script, went from zero findings to five, four of them in
1249
+ * the blocking families the static witness counts. Fail-CLOSED rather than
1250
+ * fail-open, so it refuses a valid document instead of passing a bad one, but
1251
+ * a gate that refuses a correct turn is the failure this change has spent six
1252
+ * rounds removing.
1253
+ *
1254
+ * The tags survive; only what is between them is blanked. `ruleActiveContent`
1255
+ * keys on the element NAME, so a real `<script>` inside a graphic is still
1256
+ * found — masking its body hides nothing the rules read.
1257
+ */
1258
+ function maskScriptBodies(content: string): string {
1259
+ const out = content.split('');
1260
+ const re = /<(script|style)\b[^>]*>/gi;
1261
+ let m: RegExpExecArray | null;
1262
+ while ((m = re.exec(content)) !== null) {
1263
+ const bodyStart = m.index + m[0].length;
1264
+ const close = content.toLowerCase().indexOf(`</${(m[1] as string).toLowerCase()}`, bodyStart);
1265
+ const bodyEnd = close === -1 ? content.length : close;
1266
+ for (let i = bodyStart; i < bodyEnd; i += 1) {
1267
+ if (out[i] !== '\n' && out[i] !== '\r') out[i] = ' ';
1268
+ }
1269
+ re.lastIndex = bodyEnd;
1270
+ }
1271
+ return out.join('');
1272
+ }
1273
+
1274
+ function topLevelGraphics(text: string): Array<{ start: number; end: number }> {
1275
+ const out: Array<{ start: number; end: number }> = [];
1276
+ const tagRe = /<(\/?)svg\b[^>]*?(\/?)>/gi;
1277
+ let depth = 0;
1278
+ let start = -1;
1279
+ let t: RegExpExecArray | null;
1280
+ while ((t = tagRe.exec(text)) !== null) {
1281
+ const closing = t[1] === '/';
1282
+ const selfClosing = t[2] === '/';
1283
+ if (closing) {
1284
+ if (depth === 0) continue;
1285
+ depth -= 1;
1286
+ if (depth === 0 && start !== -1) {
1287
+ out.push({ start, end: t.index + t[0].length });
1288
+ start = -1;
1289
+ }
1290
+ continue;
1291
+ }
1292
+ if (selfClosing) {
1293
+ // An empty graphic at top level is still a graphic.
1294
+ if (depth === 0) out.push({ start: t.index, end: t.index + t[0].length });
1295
+ continue;
1296
+ }
1297
+ if (depth === 0) start = t.index;
1298
+ depth += 1;
1299
+ }
1300
+ // AN UNTERMINATED GRAPHIC IS STILL A GRAPHIC, to end of input.
1301
+ //
1302
+ // Finding 1 of the sixth completion review, and it FAILS OPEN, which is
1303
+ // why it is the one that matters: a host whose inline `<svg>` is never
1304
+ // closed was dropped whole, so `scanHostHtml` returned zero findings — a
1305
+ // planted `<script>` included — `staticWitness` then reported `errors: 0`,
1306
+ // and that is exactly the condition `dischargeFor` uses to discharge the
1307
+ // BLOCKING turn-end obligation on the static-only path. A malformed file
1308
+ // discharged a gate that a well-formed one with the same script does not.
1309
+ //
1310
+ // NOT a regression of the depth counter that replaced the regex: the old
1311
+ // `/<svg\b[\s\S]*?<\/svg\s*>/` required a closing tag and matched nothing
1312
+ // here either. The rewrite preserved the hole rather than opening it, and
1313
+ // the honest version of that sentence is worth more than the flattering
1314
+ // one. Emitting the fragment hands `scanSvg` a document whose root is
1315
+ // never closed — it reports that AND everything inside it, which is both
1316
+ // answers the reader needs.
1317
+ if (depth > 0 && start !== -1) out.push({ start, end: text.length });
1318
+ return out;
1319
+ }
1320
+
1321
+ export function scanHostHtml(content: string, relPath: string, opts: ScanOptions = {}): Finding[] {
1322
+ const mode: DeliveryMode = opts.delivery ?? 'inline';
1323
+ const findings: Finding[] = [];
1324
+ const lineOf = lineIndexer(content);
1325
+ const graphics: Array<{ body: string; offset: number }> = [];
1326
+ // Finding 2.3 on the host side: a graphic inside an HTML comment is not on
1327
+ // the page, so it is not scanned and its ids do not collide with anything.
1328
+ // Masking preserves offsets, so `lineOf` — built from the raw text — still
1329
+ // answers correctly for the graphics that survive.
1330
+ const hostText = maskScriptBodies(maskComments(content));
1331
+ for (const m of topLevelGraphics(hostText)) {
1332
+ // The MATCH is found in the masked text — a graphic inside an HTML
1333
+ // comment is not on the page — but the BODY is sliced from the raw
1334
+ // text at the same offsets.
1335
+ //
1336
+ // Finding 16 of this change's completion review: handing the masked
1337
+ // slice to `scanSvg` meant its own `parseIgnores` saw every
1338
+ // `svg-asset-disable` marker blanked, which is precisely the failure
1339
+ // `maskComments`' doc warns about. Suppression survived only because
1340
+ // this function re-filters on absolute lines at the end — the inner
1341
+ // pass was inert, and silently so, which made the outer re-filter
1342
+ // load-bearing without anything saying it was.
1343
+ graphics.push({ body: content.slice(m.start, m.end), offset: m.start });
1344
+ }
1345
+
1346
+ for (const g of graphics) {
1347
+ const baseLine = lineOf(g.offset) - 1;
1348
+ // Two families are dropped for an inline graphic, each for a reason
1349
+ // the slice itself cannot see:
1350
+ // · the host supplies the namespace, so a missing xmlns is correct;
1351
+ // · the reduced-motion ROUTE may live in the host's own <style> or
1352
+ // in a <picture> wrapper, and this slice ends at </svg>. Judging
1353
+ // the route from inside the slice would red every correctly
1354
+ // covered inline animation. DQ4 owns the inline case (step 1.3)
1355
+ // and reads the whole document, which is where that answer is.
1356
+ for (const f of scanSvg(g.body, relPath, { delivery: mode })) {
1357
+ if (f.rule === 'svg-structure-missing-xmlns') continue;
1358
+ if (f.rule === 'svg-motion-no-reduced-motion') continue;
1359
+ if (f.rule === 'svg-motion-smil-css-only') continue;
1360
+ findings.push({ ...f, line: f.line + baseLine });
1361
+ }
1362
+ }
1363
+
1364
+ // Cross-graphic id collisions — the finding R7 named.
1365
+ const seen = new Map<string, number>();
1366
+ for (const g of graphics) {
1367
+ const baseLine = lineOf(g.offset) - 1;
1368
+ for (const el of parseElements(g.body)) {
1369
+ if (el.closing) continue;
1370
+ const id = el.attrs['id'];
1371
+ if (!id) continue;
1372
+ const key = id.toLowerCase();
1373
+ if (seen.has(key)) {
1374
+ findings.push({
1375
+ // ITS OWN RULE ID. Finding 4 of the fifth completion
1376
+ // review: this error shared `svg-a11y-generic-id` with the
1377
+ // single-graphic WARNING about a generic name, so one
1378
+ // `svg-asset-disable svg-a11y-generic-id` — a reasonable
1379
+ // thing to write when you have deliberately named
1380
+ // something `icon` — silenced a real collision between two
1381
+ // graphics as well. Two findings, two severities, two
1382
+ // remedies; one id was one too few.
1383
+ rule: 'svg-a11y-duplicate-id',
1384
+ file: relPath,
1385
+ line: el.line + baseLine,
1386
+ snippet:
1387
+ `id="${id}" is already used by another inline graphic on this page (line ${seen.get(key)}) — ` +
1388
+ 'the accessibility tree names both after the first',
1389
+ severity: 'error',
1390
+ });
1391
+ } else {
1392
+ seen.set(key, el.line + baseLine);
1393
+ }
1394
+ }
1395
+ }
1396
+
1397
+ const ignores = parseIgnores(content.split('\n'));
1398
+ return dedupe(findings).filter((f) => !isIgnored(ignores, f));
1399
+ }
1400
+
1401
+ // ---------------------------------------------------------------------------
1402
+
1403
+ // ---------------------------------------------------------------------------
1404
+ // Inline-ignore + config
1405
+ // ---------------------------------------------------------------------------
1406
+
1407
+ /**
1408
+ * Which lines a rule is suppressed on, and whether the whole file is.
1409
+ *
1410
+ * Finding 6 of the fifth completion review: "whole file" used to be spelled as
1411
+ * the numbers 1..10_000 in a Set — so a directive cost ten thousand entries on
1412
+ * a path that now runs on every `.svg` write, AND a file longer than ten
1413
+ * thousand lines silently stopped being suppressed past that line. A flag says
1414
+ * the same thing in one bit and has no horizon.
1415
+ */
1416
+ export interface RuleIgnore {
1417
+ lines: Set<number>;
1418
+ wholeFile: boolean;
1419
+ }
1420
+
1421
+ export function parseIgnores(lines: string[]): Map<string, RuleIgnore> {
1422
+ const ignores = new Map<string, RuleIgnore>();
1423
+ const slot = (rule: string): RuleIgnore => {
1424
+ const found = ignores.get(rule) ?? { lines: new Set<number>(), wholeFile: false };
1425
+ ignores.set(rule, found);
1426
+ return found;
1427
+ };
1428
+ const add = (rule: string, line: number): void => {
1429
+ slot(rule).lines.add(line);
1430
+ };
1431
+ for (let i = 0; i < lines.length; i++) {
1432
+ const line = lines[i] ?? '';
1433
+ const next = /svg-asset-disable-next-line\s+([\w-]+)/i.exec(line);
1434
+ if (next?.[1]) add(next[1].toLowerCase(), i + 2);
1435
+ const same = /svg-asset-disable-line\s+([\w-]+)/i.exec(line);
1436
+ if (same?.[1]) add(same[1].toLowerCase(), i + 1);
1437
+ const whole = /svg-asset-disable\s+([\w-]+)/i.exec(line);
1438
+ if (whole?.[1] && !/-next-line|-line\s/.test(line)) {
1439
+ slot(whole[1].toLowerCase()).wholeFile = true;
1440
+ }
1441
+ }
1442
+ return ignores;
1443
+ }
1444
+
1445
+ function isIgnored(ignores: Map<string, RuleIgnore>, finding: Finding): boolean {
1446
+ // The safety family is not suppressible from inside the file it judges: the
1447
+ // markers are the scanned file's own bytes, and a file that silences its
1448
+ // own script zeroes the error count a blocking gate discharges on.
1449
+ if (finding.rule.startsWith('svg-safety-')) return false;
1450
+ const hit = ignores.get(finding.rule.toLowerCase());
1451
+ if (hit === undefined) return false;
1452
+ return hit.wholeFile || hit.lines.has(finding.line);
1453
+ }