@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.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +40 -191
- package/README.md +1 -1
- package/dist/agent-src/commands/design.md +1 -1
- package/dist/agent-src/guidelines/design-antipatterns.md +1 -1
- package/dist/agent-src/rules/token-budget-discipline.md +2 -2
- package/dist/agent-src/skills/accessibility-auditor/data/aria-patterns.csv +2 -0
- package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
- package/dist/agent-src/skills/canvas-design/SKILL.md +4 -2
- package/dist/agent-src/skills/design-intelligence/references/integration-mapping.md +13 -10
- package/dist/agent-src/skills/fe-design/references/design-patterns.md +49 -2
- package/dist/agent-src/skills/frontend-render-security/SKILL.md +9 -0
- package/dist/agent-src/skills/iconography/SKILL.md +4 -2
- package/dist/agent-src/skills/image-provider-routing/SKILL.md +16 -5
- package/dist/agent-src/skills/logo-generation/SKILL.md +12 -8
- package/dist/agent-src/skills/svg-authoring/SKILL.md +318 -0
- package/dist/agent-src/skills/wireframe/SKILL.md +5 -2
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/commands/uiRender.js +201 -4
- package/dist/cli/commands/uiRenderAsset.js +1308 -0
- package/dist/cli/commands/uiRenderAssetCompare.js +752 -0
- package/dist/cli/commands/uiRenderAssetGrid.js +265 -0
- package/dist/cli/commands/uiRenderAssetGrounds.js +323 -0
- package/dist/cli/commands/uiRenderAssetIsolation.js +85 -0
- package/dist/cli/commands/uiRenderAssetTypes.js +145 -0
- package/dist/cli/main.js +47 -1
- package/dist/cli/registry.js +1 -1
- package/dist/cli-delegate/{chunk-MSUL5NYP.js → chunk-4DBG5K7M.js} +1 -1
- package/dist/cli-delegate/{chunk-AED2VQGM.js → chunk-TMWYUOK4.js} +10 -1
- package/dist/cli-delegate/cmd_conformance.js +395 -214
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +47 -22
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +6 -6
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +19 -15
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +13 -9
- package/dist/hooks/dispatch.js +1073 -808
- package/dist/hooks/dispatch.sha256 +1 -1
- package/dist/hooks/svg_rules.chunk.cjs +1626 -0
- package/dist/hooks/svg_rules.chunk.cjs.sha256 +1 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +931 -735
- package/dist/scripts/_lib/scan_scope.js +105 -0
- package/dist/scripts/_lib/svg_dom.js +284 -0
- package/dist/scripts/_lib/svg_geometry.js +477 -0
- package/dist/scripts/_lib/svg_motion.js +179 -0
- package/dist/scripts/_lib/svg_rules.js +1337 -0
- package/dist/scripts/_lib/svg_urls.js +67 -0
- package/dist/scripts/lint_svg_asset.js +324 -0
- package/docs/guidelines/design-antipatterns.md +1 -1
- package/package.json +4 -3
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/routing-coverage-seed.json +9 -2
- package/src/scripts/_lib/svg_dom.ts +318 -0
- package/src/scripts/_lib/svg_geometry.ts +480 -0
- package/src/scripts/_lib/svg_motion.ts +208 -0
- package/src/scripts/_lib/svg_rules.ts +1453 -0
- package/src/scripts/_lib/svg_urls.ts +72 -0
- package/src/scripts/_lib/verification_command.ts +59 -0
- package/src/scripts/_lib/verify_clause.ts +27 -17
- package/src/scripts/hooks/block_speaking_inbox_dir.ts +16 -1
- package/src/scripts/hooks/design_pass_hook.ts +157 -3
- package/src/scripts/hooks/svg_obligation.ts +497 -0
- package/src/scripts/hooks/svg_rules_loader.ts +133 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +5 -3
- package/src/scripts/lint_consolidation_lineage.ts +6 -3
- package/src/scripts/lint_design_quality.ts +60 -12
- package/src/scripts/lint_motion_authority_drift.ts +27 -0
- 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
|
|
316
|
+
let entries: string[];
|
|
317
317
|
try {
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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)
|
|
104
|
-
|
|
105
|
-
|
|
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 */
|