@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
@@ -313,18 +313,21 @@ export function analyseFolder(
313
313
  surface: Surface = 'inbox',
314
314
  ): Finding[] {
315
315
  const findings: Finding[] = [];
316
- let names: string[];
316
+ let entries: string[];
317
317
  try {
318
- names = fs.readdirSync(dir).filter((n) => ROADMAP_RE.test(n)).sort();
318
+ entries = fs.readdirSync(dir);
319
319
  } catch {
320
320
  return findings;
321
321
  }
322
+ const names = entries.filter((n) => ROADMAP_RE.test(n)).sort();
322
323
  const artifacts: Artifact[] = names.map((n) => ({
323
324
  stem: n.replace(/\.md$/i, ''),
324
325
  file: n,
325
326
  decl: parseDeclaration(fs.readFileSync(path.join(dir, n), 'utf-8'), surface),
326
327
  }));
327
- const present = new Set(artifacts.map((a) => a.stem));
328
+ // A parent need not be named road-to-*: a dated deep-dive plan is a parent
329
+ // too, and only its absence makes it missing.
330
+ const present = new Set(entries.filter((n) => /\.md$/i.test(n)).map((n) => n.replace(/\.md$/i, '')));
328
331
 
329
332
  for (const a of artifacts) {
330
333
  if (a.decl.unparseable) {
@@ -13,7 +13,8 @@
13
13
  * DQ1 contrast — Text/background contrast < WCAG AA (4.5:1 normal, 3:1 large)
14
14
  * DQ2 font-size — Body font-size < 14px (floor; 16px strongly preferred)
15
15
  * DQ3 line-length — Prose line-length > 75ch without column constraint
16
- * DQ4 reduced-motion — @keyframes / animation present without prefers-reduced-motion alt
16
+ * DQ4 reduced-motion — @keyframes / animation present without prefers-reduced-motion alt,
17
+ * and (for INLINE SVG in HTML) SMIL with no route or a CSS-only one
17
18
  * DQ5 heading-skip — Heading levels skipped (h1 → h3 without h2)
18
19
  * DQ6 focus-indicator— Interactive element with no :focus-visible indicator
19
20
  *
@@ -45,6 +46,7 @@
45
46
  import fs from "node:fs";
46
47
  import path from "node:path";
47
48
  import { assertScanned, DeadScopeError } from "./_lib/scan_scope.js";
49
+ import { firstSmilLine, smilVerdict } from "./_lib/svg_motion.js";
48
50
 
49
51
  interface Finding {
50
52
  rule: string;
@@ -94,25 +96,71 @@ function checkFontSize(lines: string[], filePath: string): Finding[] {
94
96
  return findings;
95
97
  }
96
98
 
97
- /** DQ4: @keyframes / animation without @media (prefers-reduced-motion) */
99
+ /**
100
+ * DQ4: animation without a reduced-motion route.
101
+ *
102
+ * Two halves. The CSS half is the original rule. The SMIL half (step 1.3 of
103
+ * `road-to-svg-with-a-witness`) covers INLINE SVG inside an HTML document and
104
+ * closes a measured blind spot: an HTML file whose only animation is an
105
+ * infinite `<animate>` read "clean — 1 file(s) checked" here, because
106
+ * `@keyframes` and `animation:` are the only two things the CSS half looks
107
+ * for, and SMIL is neither (R10).
108
+ *
109
+ * It also reports the subtler case: SMIL whose ONLY reduced-motion route is a
110
+ * CSS declaration. Under an emulated `reduce`, a page-level
111
+ * `svg * { animation: none !important }` left an `<animate>` moving `cx` from
112
+ * 20.2 to 76.8 in 700 ms (R4) — CSS animation properties do not reach SMIL, so
113
+ * the file looks covered and is not.
114
+ *
115
+ * STANDALONE `.svg` IS DELIBERATELY NOT HERE. A file cannot see its host's
116
+ * `<picture>` route, so judging the route from inside it would red correct
117
+ * assets and break this linter's zero-false-positive promise (line 12). That
118
+ * case belongs to `lint_svg_asset`, which knows it is reading a whole asset.
119
+ * Both halves read the same `_lib/svg_motion.ts`, so the two cannot drift.
120
+ */
98
121
  function checkReducedMotion(content: string, filePath: string, lines: string[]): Finding[] {
99
- const hasAnimation = /@keyframes\s+\w+|animation\s*:/i.test(content);
100
- if (!hasAnimation) return [];
122
+ const findings: Finding[] = [];
101
123
 
124
+ const hasAnimation = /@keyframes\s+\w+|animation\s*:/i.test(content);
102
125
  const hasReducedMotion = /@media\s*\([^)]*prefers-reduced-motion/i.test(content);
103
- if (hasReducedMotion) return [];
104
-
105
- // Find first animation declaration for the line number
106
- const animLine = lines.findIndex((l) => /@keyframes\s+\w+|animation\s*:/.test(l));
107
- return [
108
- {
126
+ if (hasAnimation && !hasReducedMotion) {
127
+ const animLine = lines.findIndex((l) => /@keyframes\s+\w+|animation\s*:/.test(l));
128
+ findings.push({
109
129
  rule: "dq4-reduced-motion",
110
130
  file: filePath,
111
131
  line: animLine + 1,
112
132
  snippet: "@keyframes or animation present without @media (prefers-reduced-motion) alternative",
113
133
  severity: "error",
114
- },
115
- ];
134
+ });
135
+ }
136
+
137
+ // The SMIL half runs for documents that can HOST an inline graphic. A `.css`
138
+ // file cannot carry one, and a standalone `.svg` is the other linter's.
139
+ if (!/\.(html|htm|vue|svelte|astro)$/i.test(filePath)) return findings;
140
+
141
+ const verdict = smilVerdict(content);
142
+ if (verdict === "no-route") {
143
+ findings.push({
144
+ rule: "dq4-reduced-motion",
145
+ file: filePath,
146
+ line: firstSmilLine(content),
147
+ snippet:
148
+ "inline SVG animates with SMIL (<animate>/<animateTransform>/<set>) and the document carries " +
149
+ "no @media (prefers-reduced-motion) route at all",
150
+ severity: "error",
151
+ });
152
+ } else if (verdict === "css-only") {
153
+ findings.push({
154
+ rule: "dq4-reduced-motion",
155
+ file: filePath,
156
+ line: firstSmilLine(content),
157
+ snippet:
158
+ "inline SVG animates with SMIL and the only reduced-motion route is a CSS animation/transition " +
159
+ "declaration — CSS does not reach SMIL, so the animation keeps running under `reduce`",
160
+ severity: "error",
161
+ });
162
+ }
163
+ return findings;
116
164
  }
117
165
 
118
166
  /** DQ5: heading levels skipped (h1 → h3 without h2) in HTML */
@@ -325,6 +325,33 @@ interface Allowed {
325
325
 
326
326
  const norm = (s: string): string => s.replace(/\s+/g, ' ').trim();
327
327
 
328
+ /**
329
+ * The reviewed statements, one verdict each.
330
+ *
331
+ * THE AUTHORITY IS NOT IN THIS TABLE, AND THAT IS THE DESIGN. The table is what
332
+ * the five DEPENDENTS are checked against; `design-patterns.md` § Motion is the
333
+ * thing they are compared TO, so it is PARSED — bands, ceiling, both curves —
334
+ * and never passed to {@link checkStatement}. A row keyed to it would be inert,
335
+ * and an inert row reads as enforcement.
336
+ *
337
+ * Measured 2026-10-11, when § Motion gained step 6b (the SVG mechanisms): of
338
+ * the 49 lines the authority gained, {@link carriesMotionValue} flags two when
339
+ * applied to them directly, and this gate reads neither, because neither
340
+ * reaches the scan path. Both verdicts are `permitted`, recorded here rather
341
+ * than left to be rediscovered by whoever next widens the scanned set:
342
+ *
343
+ * · "Step 2's bounce prohibition already rules the intent out" — a deferral
344
+ * to the prohibition, not a curve. The `keySplines` bullet states which
345
+ * values the MECHANISM accepts; step 2 still owns whether to want one.
346
+ * · "All of them still obey step 3's bands, step 2's single `ease-out` curve"
347
+ * — the authority naming its own curve, in a sentence widened from the six
348
+ * HTML mechanisms to those plus the SVG ones. No band, curve or duration
349
+ * moved with it.
350
+ *
351
+ * If the authority is ever added to the scanned set, those two are the rows it
352
+ * needs on day one — and every band line in § Motion needs one too, which is
353
+ * the cost that keeps it parsed instead.
354
+ */
328
355
  export const PROSE_ALLOWLIST: readonly Allowed[] = [
329
356
  {
330
357
  file: CHECKLIST_REL, key: 'table:7',
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lint_svg_asset — the command-line surface over the SVG rule set.
4
+ *
5
+ * The rules live in `_lib/svg_rules.ts`, because the design pass imports them
6
+ * into the hook bundle and must not drag a CLI in with them (see that file's
7
+ * header for the measurement). Everything this module adds is the part only a
8
+ * command needs: argument parsing, the directory walk, the project config, the
9
+ * scanned-nothing refusal, and the step 1.2 preservation readout.
10
+ *
11
+ * Inline-ignore (same shape as `lint_design_quality`, works in XML comments):
12
+ * svg-asset-disable <rule-id> [-- reason] (file scope)
13
+ * svg-asset-disable-next-line <rule-id> [-- reason]
14
+ * svg-asset-disable-line <rule-id> [-- reason]
15
+ *
16
+ * Per-project config (`.svg-asset.json` in the project root):
17
+ * { "ignoreRules": ["svg-a11y-generic-id"], "ignoreFiles": ["vendor/**"] }
18
+ *
19
+ * Usage:
20
+ * lint_svg_asset --dir <path> [--delivery inline|img|css-bg|document]
21
+ * lint_svg_asset --file <a.svg> [--file <b.svg>] [--delivery …]
22
+ * lint_svg_asset --file <after.svg> --baseline <before.svg> --allow id,id
23
+ *
24
+ * Exit codes: 0 = clean, 2 = findings, 1 = internal error (including the
25
+ * scanned-nothing refusal — a gate that scanned no file is not a clean gate).
26
+ */
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+
30
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
31
+ import {
32
+ DELIVERY_MODES,
33
+ parseElements,
34
+ scanHostHtml,
35
+ scanSvg,
36
+ type DeliveryMode,
37
+ type Finding,
38
+ type ProjectConfig,
39
+ type SvgElement,
40
+ } from './_lib/svg_rules.js';
41
+
42
+ export * from './_lib/svg_rules.js';
43
+
44
+ // Step 1.2 — preservation readout for a surgical edit
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export interface PreservationChange {
48
+ id: string;
49
+ element: string;
50
+ attribute: string;
51
+ before: string;
52
+ after: string;
53
+ }
54
+
55
+ /**
56
+ * Canonicalised attribute map — number formatting, attribute order and
57
+ * whitespace normalised away, so a re-serialisation that changed nothing reads
58
+ * as nothing changed.
59
+ */
60
+ export function canonicalAttrs(attrs: Record<string, string>): Record<string, string> {
61
+ const out: Record<string, string> = {};
62
+ for (const key of Object.keys(attrs).sort()) {
63
+ const v = (attrs[key] ?? '')
64
+ .replace(/\s+/g, ' ')
65
+ .trim()
66
+ // Numbers only where a number is what it is. A hex color is a
67
+ // TOKEN, not a quantity: normalising inside `#00ff00` collapses it
68
+ // onto `#0ff0`, and the readout then reports no change for a color
69
+ // edit outside the allowed ids — a surgical-edit witness that
70
+ // cannot see a recolour.
71
+ .replace(/#[0-9a-fA-F]+|-?\d+\.?\d*(?:[eE][-+]?\d+)?/g, (tok) => {
72
+ if (tok.startsWith('#')) return tok.toLowerCase();
73
+ const num = Number(tok);
74
+ return Number.isFinite(num) ? String(Number(num.toFixed(4))) : tok;
75
+ });
76
+ out[key] = v;
77
+ }
78
+ return out;
79
+ }
80
+
81
+ /**
82
+ * Every element outside the allowed set that changed between two revisions.
83
+ *
84
+ * NO PIXEL DIFF decides this. Anti-aliasing and font metrics would make a
85
+ * pixel comparison a per-machine verdict, and the discipline this witnesses —
86
+ * `design-fidelity-mechanics.md` § surgical edits — is about which elements
87
+ * the edit was allowed to touch, which is an attribute fact.
88
+ */
89
+ export function preservationReadout(
90
+ beforeContent: string,
91
+ afterContent: string,
92
+ allowedIds: readonly string[],
93
+ ): PreservationChange[] {
94
+ const allow = new Set(allowedIds.map((s) => s.trim()).filter(Boolean));
95
+
96
+ /**
97
+ * A stable key per element, and the id chain above it.
98
+ *
99
+ * An element WITHOUT an id is keyed by its nearest id-bearing ancestor plus
100
+ * its tag and its ordinal INSIDE that ancestor — never by a document-wide
101
+ * index. A document-wide index shifts for every element after an allowed
102
+ * deletion, so removing one child of the allowed group reported every
103
+ * un-idded element after it as changed. Measured on the `grouped-transforms`
104
+ * triple: the correct edit reported `rect#7 (element)` and the readout could
105
+ * not tell a clean surgical edit from a dirty one.
106
+ */
107
+ const index = (content: string): Map<string, { el: SvgElement; chain: string[] }> => {
108
+ const map = new Map<string, { el: SvgElement; chain: string[] }>();
109
+ const stack: Array<{ name: string; id: string | null }> = [];
110
+ const ordinals = new Map<string, number>();
111
+ for (const el of parseElements(content)) {
112
+ if (el.closing) {
113
+ if (stack[stack.length - 1]?.name === el.name) stack.pop();
114
+ continue;
115
+ }
116
+ const chain = stack.map((f) => f.id).filter((x): x is string => x !== null);
117
+ const ownerId = [...stack].reverse().find((f) => f.id !== null)?.id ?? '';
118
+ const own = el.attrs['id'] ?? null;
119
+ let key: string;
120
+ if (own) {
121
+ key = own;
122
+ } else {
123
+ const bucket = `${ownerId}/${el.name}`;
124
+ const n = (ordinals.get(bucket) ?? 0) + 1;
125
+ ordinals.set(bucket, n);
126
+ key = `${bucket}#${n}`;
127
+ }
128
+ map.set(key, { el, chain: own ? [...chain, own] : chain });
129
+ if (!el.selfClosing) stack.push({ name: el.name, id: own });
130
+ }
131
+ return map;
132
+ };
133
+
134
+ const before = index(beforeContent);
135
+ const after = index(afterContent);
136
+ const changes: PreservationChange[] = [];
137
+ /** An element inside an allowed subtree is itself allowed. */
138
+ const permitted = (entry: { chain: string[] } | undefined): boolean =>
139
+ Boolean(entry && entry.chain.some((id) => allow.has(id)));
140
+
141
+ for (const [key, beforeEntry] of before) {
142
+ if (permitted(beforeEntry)) continue;
143
+ const afterEntry = after.get(key);
144
+ if (!afterEntry) {
145
+ changes.push({
146
+ id: key,
147
+ element: beforeEntry.el.name,
148
+ attribute: '(element)',
149
+ before: 'present',
150
+ after: 'removed',
151
+ });
152
+ continue;
153
+ }
154
+ const a = canonicalAttrs(beforeEntry.el.attrs);
155
+ const b = canonicalAttrs(afterEntry.el.attrs);
156
+ for (const attr of new Set([...Object.keys(a), ...Object.keys(b)])) {
157
+ if ((a[attr] ?? '') === (b[attr] ?? '')) continue;
158
+ changes.push({
159
+ id: key,
160
+ element: beforeEntry.el.name,
161
+ attribute: attr,
162
+ before: a[attr] ?? '(absent)',
163
+ after: b[attr] ?? '(absent)',
164
+ });
165
+ }
166
+ }
167
+ for (const [key, afterEntry] of after) {
168
+ if (permitted(afterEntry) || before.has(key)) continue;
169
+ changes.push({
170
+ id: key,
171
+ element: afterEntry.el.name,
172
+ attribute: '(element)',
173
+ before: 'absent',
174
+ after: 'added',
175
+ });
176
+ }
177
+ return changes;
178
+ }
179
+
180
+ export function loadConfig(dir: string): ProjectConfig {
181
+ const configPath = path.join(dir, '.svg-asset.json');
182
+ if (!fs.existsSync(configPath)) return {};
183
+ try {
184
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8')) as ProjectConfig;
185
+ } catch {
186
+ return {};
187
+ }
188
+ }
189
+
190
+ function* walkDir(dir: string, ext: RegExp): Generator<string> {
191
+ if (!fs.existsSync(dir)) return;
192
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
193
+ const full = path.join(dir, entry.name);
194
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
195
+ if (entry.isDirectory()) yield* walkDir(full, ext);
196
+ else if (ext.test(entry.name)) yield full;
197
+ }
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // CLI
202
+ // ---------------------------------------------------------------------------
203
+
204
+ function readFlag(args: string[], name: string): string | undefined {
205
+ const i = args.indexOf(name);
206
+ return i >= 0 ? args[i + 1] : undefined;
207
+ }
208
+
209
+ function readAll(args: string[], name: string): string[] {
210
+ const out: string[] = [];
211
+ for (let i = 0; i < args.length; i++) if (args[i] === name && args[i + 1]) out.push(args[i + 1] as string);
212
+ return out;
213
+ }
214
+
215
+ export function main(argv: readonly string[] = process.argv.slice(2)): number {
216
+ const args = [...argv];
217
+ const jsonMode = args.includes('--json');
218
+ const quiet = args.includes('--quiet');
219
+ const deliveryRaw = readFlag(args, '--delivery');
220
+ if (deliveryRaw && !DELIVERY_MODES.includes(deliveryRaw as DeliveryMode)) {
221
+ process.stderr.write(
222
+ `[lint_svg_asset] unknown --delivery "${deliveryRaw}"; expected one of ${DELIVERY_MODES.join(', ')}\n`,
223
+ );
224
+ return 1;
225
+ }
226
+ const delivery = deliveryRaw as DeliveryMode | undefined;
227
+
228
+ // --- step 1.2: preservation readout -------------------------------------
229
+ const baseline = readFlag(args, '--baseline');
230
+ if (baseline) {
231
+ const files = readAll(args, '--file');
232
+ const after = files[0];
233
+ if (!after) {
234
+ process.stderr.write('[lint_svg_asset] --baseline needs a --file <after.svg> to compare against\n');
235
+ return 1;
236
+ }
237
+ if (!fs.existsSync(baseline) || !fs.existsSync(after)) {
238
+ process.stderr.write('[lint_svg_asset] --baseline or --file path not found\n');
239
+ return 1;
240
+ }
241
+ const allow = (readFlag(args, '--allow') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
242
+ const changes = preservationReadout(
243
+ fs.readFileSync(baseline, 'utf-8'),
244
+ fs.readFileSync(after, 'utf-8'),
245
+ allow,
246
+ );
247
+ if (jsonMode) {
248
+ process.stdout.write(`${JSON.stringify(changes, null, 2)}\n`);
249
+ return changes.length ? 2 : 0;
250
+ }
251
+ if (!changes.length) {
252
+ if (!quiet) {
253
+ process.stdout.write(
254
+ `✅ lint_svg_asset --baseline: nothing changed outside ${allow.length} allowed id(s).\n`,
255
+ );
256
+ }
257
+ return 0;
258
+ }
259
+ for (const c of changes) {
260
+ process.stdout.write(
261
+ `❌ ${after} [svg-preserve-changed-outside-allowed] <${c.element} id="${c.id}"> ` +
262
+ `${c.attribute}: "${c.before}" → "${c.after}"\n`,
263
+ );
264
+ }
265
+ process.stdout.write(
266
+ `\nlint_svg_asset: ${changes.length} element change(s) outside the allowed set. ` +
267
+ 'A surgical edit touches only what it declared.\n',
268
+ );
269
+ return 2;
270
+ }
271
+
272
+ // --- scanning -----------------------------------------------------------
273
+ const explicit = readAll(args, '--file');
274
+ const dirArg = readFlag(args, '--dir');
275
+ const targets: string[] = [];
276
+ let roots: string[] = [];
277
+
278
+ if (explicit.length) {
279
+ targets.push(...explicit.map((f) => path.resolve(f)));
280
+ roots = targets;
281
+ } else {
282
+ const scanDir = dirArg ? path.resolve(dirArg) : path.resolve(process.cwd(), 'src');
283
+ roots = [scanDir];
284
+ if (!fs.existsSync(scanDir)) {
285
+ // The two cases are NOT the same, and collapsing them into exit 0
286
+ // made a renamed or mistyped `--dir` report success in CI. An
287
+ // ABSENT DEFAULT root is the opt-in escape — a consumer with no
288
+ // `src/` has nothing to scan and must not red. An EXPLICIT `--dir`
289
+ // that does not resolve is a broken invocation, and this module's
290
+ // own header already says a gate that scanned no file is not a
291
+ // clean gate.
292
+ if (!quiet) process.stderr.write(`[lint_svg_asset] scan directory not found: ${scanDir}\n`);
293
+ if (dirArg) return 1;
294
+ process.stderr.write(
295
+ 'This linter is opt-in. Run with --dir <path> or --file <a.svg> pointing at your assets.\n',
296
+ );
297
+ return 0;
298
+ }
299
+ for (const f of walkDir(scanDir, /\.(svg|html|htm)$/i)) targets.push(f);
300
+ }
301
+
302
+ const config = loadConfig(process.cwd());
303
+ const ignoreRules = new Set((config.ignoreRules ?? []).map((r) => r.toLowerCase()));
304
+ const ignoreGlobs = config.ignoreFiles ?? [];
305
+ const scanned = targets.filter((f) => !ignoreGlobs.some((g) => f.includes(g.replace('**', ''))));
306
+
307
+ try {
308
+ assertScanned({ gate: 'lint_svg_asset', scanned: scanned.length, units: 'SVG/HTML file(s)', roots });
309
+ } catch (exc) {
310
+ if (exc instanceof DeadScopeError) {
311
+ process.stderr.write(`❌ ${exc.message}\n`);
312
+ return 1;
313
+ }
314
+ throw exc;
315
+ }
316
+
317
+ const all: Finding[] = [];
318
+ for (const abs of scanned) {
319
+ const rel = path.relative(process.cwd(), abs);
320
+ try {
321
+ const content = fs.readFileSync(abs, 'utf-8');
322
+ const findings = /\.svg$/i.test(abs)
323
+ ? scanSvg(content, rel, { delivery })
324
+ : scanHostHtml(content, rel, { delivery });
325
+ all.push(...findings.filter((f) => !ignoreRules.has(f.rule.toLowerCase())));
326
+ } catch (err) {
327
+ if (!quiet) process.stderr.write(`[lint_svg_asset] error: ${abs}: ${err}\n`);
328
+ }
329
+ }
330
+
331
+ if (jsonMode) {
332
+ process.stdout.write(`${JSON.stringify(all, null, 2)}\n`);
333
+ return all.length > 0 ? 2 : 0;
334
+ }
335
+ if (!all.length) {
336
+ if (!quiet) {
337
+ process.stdout.write(
338
+ `✅ lint_svg_asset: clean — ${scanned.length} file(s) checked, no violations.\n`,
339
+ );
340
+ }
341
+ return 0;
342
+ }
343
+ const errors = all.filter((f) => f.severity === 'error');
344
+ const warnings = all.filter((f) => f.severity === 'warning');
345
+ const inert = all.filter((f) => f.severity === 'inert');
346
+ for (const f of all) {
347
+ const icon = f.severity === 'error' ? '❌' : f.severity === 'warning' ? '⚠️ ' : 'ℹ️ ';
348
+ process.stdout.write(`${icon} ${f.file}:${f.line} [${f.rule}] ${f.snippet}\n`);
349
+ }
350
+ process.stdout.write(
351
+ `\nlint_svg_asset: ${errors.length} error(s), ${warnings.length} warning(s), ${inert.length} inert. ` +
352
+ 'Use svg-asset-disable-next-line <rule> -- reason or .svg-asset.json to suppress; ' +
353
+ 'svg-safety-* findings yield only to .svg-asset.json, never to a marker in the file. ' +
354
+ 'Advisory: an "inert" row is not a clean row — the same file inlined tomorrow is an error.\n',
355
+ );
356
+ return 2;
357
+ }
358
+
359
+ /* c8 ignore start — entrypoint guard, exercised by the CLI not by the tests */
360
+ const invokedDirectly =
361
+ typeof process !== 'undefined' &&
362
+ Array.isArray(process.argv) &&
363
+ /lint_svg_asset\.(ts|js)$/.test(process.argv[1] ?? '');
364
+ if (invokedDirectly) process.exit(main());
365
+ /* c8 ignore stop */