@adia-ai/adia-ui-forge 0.8.55 → 0.8.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +40 -0
- package/README.md +1 -1
- package/__init__.py +5 -0
- package/commands/demo-audit.md +1 -1
- package/commands/gen-ui-review.md +1 -1
- package/commands/package-release.md +1 -1
- package/commands/site-deployment.md +1 -1
- package/package.json +1 -1
- package/plugin.yaml +1 -1
- package/prompts/demo-audit.md +1 -1
- package/prompts/gen-ui-review.md +1 -1
- package/prompts/package-release.md +1 -1
- package/prompts/site-deployment.md +1 -1
- package/scripts/forge-lint.mjs +168 -0
- package/scripts/lint-rules.generated.mjs +1700 -0
- package/scripts/site-postwrite-derivation-gate +23 -127
- package/skills/a2ui-maintenance/SKILL.md +1 -1
- package/skills/a2ui-maintenance/references/data-model-reactivity.md +49 -29
- package/skills/a2ui-maintenance/references/pipeline-overview.md +58 -22
- package/skills/a2ui-maintenance/references/surface-lifecycle.md +14 -7
- package/skills/component-md-authoring/SKILL.md +116 -0
- package/skills/component-md-authoring/agents/openai.yaml +3 -0
- package/skills/demo-audit/references/visual-probe-triage.md +4 -1
- package/skills/gen-ui-review/SKILL.md +4 -1
- package/skills/gen-ui-review/references/loop-protocol.md +6 -5
- package/skills/package-release/references/changelog-discipline.md +6 -3
- package/skills/package-release/references/cut-procedure.md +61 -14
- package/skills/package-release/references/gates-catalog.md +6 -2
- package/skills/package-release/references/recovery-paths.md +6 -3
- package/skills/package-release/scripts/gate-roster.mjs +10 -7
- package/skills/package-release/scripts/release-pack.mjs +289 -17
- package/skills/primitive-authoring/references/anti-patterns.md +2 -2
- package/skills/primitive-authoring/references/api-contract.md +17 -4
- package/skills/primitive-authoring/references/authoring-cycle.md +1 -1
- package/skills/primitive-authoring/references/code-style.md +1 -1
- package/skills/primitive-authoring/references/common-gotchas.md +6 -6
- package/skills/primitive-authoring/references/form-control-sizing.md +22 -7
- package/skills/primitive-authoring/references/token-contract.md +5 -0
- package/skills/primitive-authoring/references/yaml-contract.md +132 -14
- package/skills/site-docs-authoring/SKILL.md +7 -9
- package/hooks/hooks.json +0 -44
- package/scripts/forge-lint +0 -315
|
@@ -0,0 +1,1700 @@
|
|
|
1
|
+
// GENERATED — do not hand-edit. Sources:
|
|
2
|
+
// scripts/lint/engine/primitives.mjs, scripts/lint/engine/run.mjs
|
|
3
|
+
// scripts/lint/rules/consumer/genui-doc.mjs
|
|
4
|
+
// scripts/lint/rules/consumer/hardcoded-open.mjs
|
|
5
|
+
// scripts/lint/rules/consumer/llm-key-in-client.mjs
|
|
6
|
+
// scripts/lint/rules/consumer/shell-nesting.mjs
|
|
7
|
+
// scripts/lint/rules/consumer/shell-resize.mjs
|
|
8
|
+
// scripts/lint/rules/consumer/ssr.mjs
|
|
9
|
+
// scripts/lint/rules/forge/missing-scope.mjs
|
|
10
|
+
// scripts/lint/rules/generated/composition/chat-shell.mjs
|
|
11
|
+
// scripts/lint/rules/genui-output/anti-patterns.mjs
|
|
12
|
+
// scripts/lint/rules/shared/attr-typo.mjs
|
|
13
|
+
// scripts/lint/rules/shared/bool-default-true.mjs
|
|
14
|
+
// scripts/lint/rules/shared/dead-font-token.mjs
|
|
15
|
+
// scripts/lint/rules/shared/example-id-grammar.mjs
|
|
16
|
+
// scripts/lint/rules/shared/example-prop-contract.mjs
|
|
17
|
+
// scripts/lint/rules/shared/legacy-shell.mjs
|
|
18
|
+
// scripts/lint/rules/shared/native-primitive.mjs
|
|
19
|
+
// scripts/lint/rules/shared/raw-color.mjs
|
|
20
|
+
// scripts/lint/rules/shared/raw-px.mjs
|
|
21
|
+
// scripts/lint/rules/shared/scope-extent.mjs
|
|
22
|
+
// scripts/lint/rules/shared/shadow-dom.mjs
|
|
23
|
+
// scripts/lint/rules/shared/slotted.mjs
|
|
24
|
+
// scripts/lint/rules/shared/stale-dialect.mjs
|
|
25
|
+
// Rebuild: node scripts/build/lint-rules.mjs
|
|
26
|
+
// Freshness gate: node scripts/verify/check-lint-rules-fresh.mjs --verify
|
|
27
|
+
// Self-contained (no relative imports) — copied byte-for-byte to 4 vendored
|
|
28
|
+
// destinations at different directory depths (LLD-0016 §C5), one of which
|
|
29
|
+
// (packages/gen-ui/engine/retrieval/anti-patterns.generated.js) is
|
|
30
|
+
// browser-reachable — check:browser-safe forbids a top-level 'node:*'
|
|
31
|
+
// import there. Rules that touch disk (only the .yaml-corpus ones) reach
|
|
32
|
+
// node:fs/node:path via process.getBuiltinModule() inside their own
|
|
33
|
+
// functions instead of a static import — zero import statements, so
|
|
34
|
+
// nothing to strip or guard per-destination.
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* scripts/lint/engine/primitives.mjs — the fixed matcher-primitive library
|
|
38
|
+
* every rule module (scripts/lint/rules/**\/*.mjs) is written against.
|
|
39
|
+
*
|
|
40
|
+
* LLD-0016 §C2: rules are real code, not serializable config, but every
|
|
41
|
+
* rule is built from this small, fixed set of primitives — the engine's
|
|
42
|
+
* real extensibility seam. Ported 1:1 from forge-lint's and adia-lint's
|
|
43
|
+
* shared regex bank + `_strip_comments`/`_is_foundation_css` helpers
|
|
44
|
+
* (both scripts' docstrings called that bank a deliberate vendored
|
|
45
|
+
* duplicate; this module is its single source now).
|
|
46
|
+
*
|
|
47
|
+
* Stdlib-equivalent only (node:fs / node:path, no npm deps) — LLD-0016 §C2:
|
|
48
|
+
* this is what keeps the generated rule bank safely vendorable into three
|
|
49
|
+
* plugin trees with no `npm install` at hook-execution time.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
const CODE_EXT = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'];
|
|
53
|
+
const STYLE_EXT = ['.css'];
|
|
54
|
+
const MARKUP_EXT = ['.html', '.htm', '.vue', '.svelte', '.astro', '.tsx', '.jsx'];
|
|
55
|
+
// gh#2649 — the yaml-corpus rules (STALE-DIALECT, EXAMPLE-PROP-CONTRACT,
|
|
56
|
+
// EXAMPLE-ID-GRAMMAR) are the first .yaml-fileType rules in the bank;
|
|
57
|
+
// without this, `npm run lint`'s git-ls-files filter and both hooks'
|
|
58
|
+
// ext gate drop every .yaml write before lintText ever sees it, making
|
|
59
|
+
// those three rules permanently unreachable outside their own selftest.
|
|
60
|
+
const YAML_EXT = ['.yaml'];
|
|
61
|
+
const LINT_EXT = [...new Set([...CODE_EXT, ...STYLE_EXT, ...MARKUP_EXT, ...YAML_EXT])].sort();
|
|
62
|
+
|
|
63
|
+
const FOUNDATION_STEMS = new Set([
|
|
64
|
+
'tokens', 'token', 'theme', 'themes', 'foundation', 'foundations',
|
|
65
|
+
'palette', 'palettes', 'host', 'reset', 'resets', 'scheme', 'schemes',
|
|
66
|
+
'color', 'colors',
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/** File extension, lowercased, dot included (mirrors Python's os.path.splitext). */
|
|
70
|
+
function ext(path) {
|
|
71
|
+
const base = (path || '').split('/').pop() || '';
|
|
72
|
+
const i = base.lastIndexOf('.');
|
|
73
|
+
return i <= 0 ? '' : base.slice(i).toLowerCase();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A genuine token/foundation sheet — exempt from the CSS-literal + scope
|
|
78
|
+
* checks. Matched by exact stem or a styles/tokens dir, never a path
|
|
79
|
+
* substring (so a `color-picker` component is still linted).
|
|
80
|
+
*/
|
|
81
|
+
function isFoundationCss(path, text, optInTag) {
|
|
82
|
+
const segs = (path || '').replace(/\\/g, '/').split('/');
|
|
83
|
+
if (segs.includes('styles') || segs.includes('tokens')) return true;
|
|
84
|
+
const base = segs[segs.length - 1] || '';
|
|
85
|
+
const dot = base.lastIndexOf('.');
|
|
86
|
+
const stem = (dot <= 0 ? base : base.slice(0, dot)).toLowerCase();
|
|
87
|
+
if (FOUNDATION_STEMS.has(stem)) return true;
|
|
88
|
+
if (optInTag) {
|
|
89
|
+
const re = new RegExp(`${optInTag}:\\s*foundation`, 'i');
|
|
90
|
+
return re.test(text.slice(0, 1000));
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Per-line comment-stripping iterator (gh#976): tracks /* *\/ block-comment
|
|
97
|
+
* state across lines; also strips `//` line comments for CODE_EXT files.
|
|
98
|
+
* Returns an array of { lineNo, raw, stripped } — `stripped` is what rules
|
|
99
|
+
* should match against so a literal quoted in prose never fires.
|
|
100
|
+
*/
|
|
101
|
+
function stripCommentLines(text, fileExt) {
|
|
102
|
+
const lines = text.split('\n');
|
|
103
|
+
const out = [];
|
|
104
|
+
let inBlock = false;
|
|
105
|
+
for (let idx = 0; idx < lines.length; idx += 1) {
|
|
106
|
+
const raw = lines[idx];
|
|
107
|
+
let stripped = '';
|
|
108
|
+
let j = 0;
|
|
109
|
+
const n = raw.length;
|
|
110
|
+
while (j < n) {
|
|
111
|
+
if (inBlock) {
|
|
112
|
+
const k = raw.indexOf('*/', j);
|
|
113
|
+
if (k === -1) { j = n; break; }
|
|
114
|
+
inBlock = false;
|
|
115
|
+
j = k + 2;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const k = raw.indexOf('/*', j);
|
|
119
|
+
const m = CODE_EXT.includes(fileExt) ? raw.indexOf('//', j) : -1;
|
|
120
|
+
if (m !== -1 && (k === -1 || m < k)) {
|
|
121
|
+
stripped += raw.slice(j, m);
|
|
122
|
+
j = n;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
if (k === -1) {
|
|
126
|
+
stripped += raw.slice(j);
|
|
127
|
+
j = n;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
stripped += raw.slice(j, k);
|
|
131
|
+
inBlock = true;
|
|
132
|
+
j = k + 2;
|
|
133
|
+
}
|
|
134
|
+
out.push({ lineNo: idx + 1, raw, stripped });
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 1-based line number of a character offset into `text`. */
|
|
140
|
+
function lineOf(text, offset) {
|
|
141
|
+
let n = 1;
|
|
142
|
+
for (let i = 0; i < offset && i < text.length; i += 1) if (text[i] === '\n') n += 1;
|
|
143
|
+
return n;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Suppression comment recognized on the line itself or the line before a finding. */
|
|
147
|
+
const SUPPRESS_RE = /\/\*\s*adia-lint-disable\s+([A-Z0-9-]+)(?:\s*—\s*(.*?))?\s*\*\//;
|
|
148
|
+
|
|
149
|
+
/** Given all raw lines and a finding's line number, is it suppressed, and was a reason given? */
|
|
150
|
+
function suppressionFor(rawLines, lineNo, ruleId) {
|
|
151
|
+
const candidates = [rawLines[lineNo - 1], rawLines[lineNo - 2]].filter(Boolean);
|
|
152
|
+
for (const line of candidates) {
|
|
153
|
+
const m = SUPPRESS_RE.exec(line || '');
|
|
154
|
+
if (m && m[1] === ruleId) return { suppressed: true, hasReason: Boolean(m[2] && m[2].trim()) };
|
|
155
|
+
}
|
|
156
|
+
return { suppressed: false, hasReason: false };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Every suppression comment in the file, bare-reason ones flagged — used by
|
|
161
|
+
* the engine to both apply suppression and emit SUPPRESS-NO-REASON findings
|
|
162
|
+
* for a disable with no `<reason>` (LLD-0016 §Interfaces: "a bare `-disable`
|
|
163
|
+
* with no `<reason>` is itself a lint finding").
|
|
164
|
+
*/
|
|
165
|
+
function allSuppressions(rawLines) {
|
|
166
|
+
const out = [];
|
|
167
|
+
const re = new RegExp(SUPPRESS_RE.source, 'g');
|
|
168
|
+
rawLines.forEach((line, idx) => {
|
|
169
|
+
let m;
|
|
170
|
+
re.lastIndex = 0;
|
|
171
|
+
while ((m = re.exec(line))) {
|
|
172
|
+
out.push({ lineNo: idx + 1, ruleId: m[1], hasReason: Boolean(m[2] && m[2].trim()) });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const RE = {
|
|
179
|
+
HEXCOLOR: /#[0-9a-fA-F]{3,8}\b/,
|
|
180
|
+
FUNCCOLOR: /\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\s*\(/,
|
|
181
|
+
DEAD_FONT: /var\(\s*--a-font\s*[,)]/,
|
|
182
|
+
SCOPE_EXTENT: /:scope(?:\[[^\]]*\])?\s*\{[^{}]*?\b(?:width|height|inline-size|block-size)\s*:/s,
|
|
183
|
+
BOOL_TRUE: /\bdefault:\s*true\b/,
|
|
184
|
+
ATTR_TYPO: /\battr:\s*['"]/,
|
|
185
|
+
NATIVE_PRIMITIVE: /<(?:button|input|select|textarea|dialog)(?![\w-])/,
|
|
186
|
+
LEGACY_SHELL: /data-chat-(?:messages|input|empty|name)|data-editor-body|data-canvas\b|data-sidebar=|data-pane-(?:side|grow)|<aside-ui\b|<dialog\s+data-command/,
|
|
187
|
+
PX_GE: /(?<![\w.-])(\d+)px\b/g,
|
|
188
|
+
SELECTOR_RULE: /^\s*[.#:[&\w][^{}\n]*\{/m,
|
|
189
|
+
SSR_SIGNAL: /['"]use client['"]|\buseEffect\b|\bonMounted\b|\bonMount\b|from\s+['"](?:react|vue|svelte|next|nuxt|@sveltejs|astro)|getServerSideProps|defineNuxtComponent/i,
|
|
190
|
+
TOPLEVEL_IMPORT: /^\s*import\s+['"]@adia-ai\/web-components['"]\s*;?\s*$/m,
|
|
191
|
+
OVERLAY_OPEN: /<(?:modal|drawer)-ui\b[^>]*?(?<![:.\w])\bopen\b(?!\s*=\s*\{)/,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/** Per-declaration split on `;` — mirrors both Python scripts' `line.split(';')` loops. */
|
|
195
|
+
function declarations(line) {
|
|
196
|
+
return line.split(';');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Scans one double-quoted YAML scalar starting at text[startIdx] === '"',
|
|
201
|
+
* spanning as many physical lines as needed — used by extractYamlA2uiBlocks
|
|
202
|
+
* for the inline-quoted `a2ui: "[\n ... ]"` style, which the corpus authors
|
|
203
|
+
* as a long single YAML scalar wrapped across many lines via YAML's
|
|
204
|
+
* "escaped line break" rule: a lone `\` immediately before a newline drops
|
|
205
|
+
* both the backslash and the newline (no space inserted) and strips the
|
|
206
|
+
* next line's leading indentation — the shape `pane.yaml`/`input.yaml`/etc.
|
|
207
|
+
* actually use for long examples. A bare (non-escaped) newline inside the
|
|
208
|
+
* quotes does not occur in this corpus and is not specially handled here
|
|
209
|
+
* (falls through to JSON.parse failing on it, same fail-soft as any other
|
|
210
|
+
* unparseable block). Returns `{ raw, endIdx }` — `raw` is a single-line
|
|
211
|
+
* JSON-string-literal source (JSON.parse-able); `endIdx` is the index one
|
|
212
|
+
* past the closing quote, for the caller to resume scanning after.
|
|
213
|
+
*/
|
|
214
|
+
function scanYamlDoubleQuoted(text, startIdx) {
|
|
215
|
+
let i = startIdx + 1;
|
|
216
|
+
let out = '"';
|
|
217
|
+
while (i < text.length) {
|
|
218
|
+
const c = text[i];
|
|
219
|
+
if (c === '\\' && text[i + 1] === '\n') {
|
|
220
|
+
// escaped line break — drop `\` + newline, strip the continuation
|
|
221
|
+
// line's leading indentation, insert nothing.
|
|
222
|
+
i += 2;
|
|
223
|
+
while (i < text.length && (text[i] === ' ' || text[i] === '\t')) i += 1;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (c === '\\' && text[i + 1] === ' ') {
|
|
227
|
+
// YAML double-quoted scalar's `\ ` escape (literal space, used to
|
|
228
|
+
// pin down leading whitespace that line-folding would otherwise
|
|
229
|
+
// strip) has no JSON equivalent — JSON.parse rejects a bare
|
|
230
|
+
// backslash-space. Emit the space alone; JSON needs no escape for it.
|
|
231
|
+
out += ' ';
|
|
232
|
+
i += 2;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (c === '\\') {
|
|
236
|
+
out += c;
|
|
237
|
+
if (i + 1 < text.length) { out += text[i + 1]; i += 2; } else { i += 1; }
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
out += c;
|
|
241
|
+
i += 1;
|
|
242
|
+
if (c === '"') break;
|
|
243
|
+
}
|
|
244
|
+
return { raw: out, endIdx: i };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Yaml-corpus primitive (grows C2's primitive set for the .yaml fileType
|
|
249
|
+
* rules — LLD-0016 §C3): walks a component yaml's raw text for every
|
|
250
|
+
* `examples[].a2ui` scalar (block `>-`/`|`/`|-` or inline double-quoted,
|
|
251
|
+
* single- or multi-line) and JSON.parses each into its flat node array.
|
|
252
|
+
* Indentation-based, not a full YAML parse — rule modules stay stdlib-only
|
|
253
|
+
* (§C2), so this only recognizes the two scalar shapes the corpus actually
|
|
254
|
+
* uses, not the full YAML scalar grammar. A block whose content isn't
|
|
255
|
+
* JSON.parse-able, or doesn't parse to an array, is skipped (a malformed
|
|
256
|
+
* example is verify:a2ui-schema's concern, not this primitive's). Returns
|
|
257
|
+
* `{ line, nodes }[]`, `line` = the `a2ui:` key's own 1-based line number.
|
|
258
|
+
*/
|
|
259
|
+
function extractYamlA2uiBlocks(text) {
|
|
260
|
+
const lines = text.split('\n');
|
|
261
|
+
const out = [];
|
|
262
|
+
const KEY_RE = /^([ \t]*)a2ui:[ \t]*(.*)$/gm;
|
|
263
|
+
let m;
|
|
264
|
+
while ((m = KEY_RE.exec(text))) {
|
|
265
|
+
const indent = m[1].length;
|
|
266
|
+
const rest = m[2];
|
|
267
|
+
const keyLineNo = lineOf(text, m.index);
|
|
268
|
+
let jsonText = null;
|
|
269
|
+
if (rest.trim().startsWith('"')) {
|
|
270
|
+
const quoteOffset = m.index + (m[0].length - rest.length) + rest.indexOf('"');
|
|
271
|
+
const { raw, endIdx } = scanYamlDoubleQuoted(text, quoteOffset);
|
|
272
|
+
try {
|
|
273
|
+
jsonText = JSON.parse(raw);
|
|
274
|
+
} catch {
|
|
275
|
+
jsonText = null;
|
|
276
|
+
}
|
|
277
|
+
KEY_RE.lastIndex = endIdx;
|
|
278
|
+
} else {
|
|
279
|
+
const i = keyLineNo - 1;
|
|
280
|
+
const body = [];
|
|
281
|
+
let j = i + 1;
|
|
282
|
+
while (j < lines.length) {
|
|
283
|
+
const line = lines[j];
|
|
284
|
+
if (line.trim() === '') { body.push(''); j += 1; continue; }
|
|
285
|
+
const lineIndent = line.match(/^(\s*)/)[1].length;
|
|
286
|
+
if (lineIndent <= indent) break;
|
|
287
|
+
body.push(line);
|
|
288
|
+
j += 1;
|
|
289
|
+
}
|
|
290
|
+
jsonText = body.join('\n');
|
|
291
|
+
}
|
|
292
|
+
if (!jsonText || !jsonText.trim().startsWith('[')) continue;
|
|
293
|
+
try {
|
|
294
|
+
const nodes = JSON.parse(jsonText);
|
|
295
|
+
if (Array.isArray(nodes)) out.push({ line: keyLineNo, nodes });
|
|
296
|
+
} catch {
|
|
297
|
+
// malformed example JSON — not this primitive's concern
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const TAG_TOKEN_RE = /<(\/?)([a-zA-Z][\w-]*)((?:\s+[^<>]*)?)(\/?)>/g;
|
|
304
|
+
const VOID_TAGS = new Set([
|
|
305
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
306
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
307
|
+
]);
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* LLD-0016 §C4/Risk R4: a single-file tag-adjacency walk — a stack-based
|
|
311
|
+
* tokenizer, not a full HTML/DOM parser, matching SHELL-NESTING's existing
|
|
312
|
+
* markup-only, single-file, no-cross-file-resolution precedent (the
|
|
313
|
+
* closest prior art this design extends rather than building from
|
|
314
|
+
* scratch). Tolerant of malformed/truncated markup (unclosed tags at EOF
|
|
315
|
+
* are flushed as-is) so a small fixture snippet never throws.
|
|
316
|
+
*
|
|
317
|
+
* Returns one node per open (or self-closing/void) tag, in document
|
|
318
|
+
* order: `{ tag, parentTag, line, childTags }` — `parentTag` is `'ROOT'`
|
|
319
|
+
* for a top-level occurrence (the A2UI "Surface" case), `childTags` is
|
|
320
|
+
* the ordered list of direct-child tag names only (not descendants).
|
|
321
|
+
*/
|
|
322
|
+
function tagTree(text) {
|
|
323
|
+
const nodes = [];
|
|
324
|
+
const stack = []; // { tag, parentTag, line, children: string[] }
|
|
325
|
+
TAG_TOKEN_RE.lastIndex = 0;
|
|
326
|
+
let m;
|
|
327
|
+
while ((m = TAG_TOKEN_RE.exec(text))) {
|
|
328
|
+
const [, closing, tag, , selfClose] = m;
|
|
329
|
+
const line = lineOf(text, m.index);
|
|
330
|
+
if (closing) {
|
|
331
|
+
let at = -1;
|
|
332
|
+
for (let i = stack.length - 1; i >= 0; i -= 1) {
|
|
333
|
+
if (stack[i].tag === tag) { at = i; break; }
|
|
334
|
+
}
|
|
335
|
+
if (at === -1) continue; // stray close tag — ignore
|
|
336
|
+
const frame = stack[at];
|
|
337
|
+
nodes.push({ tag: frame.tag, parentTag: frame.parentTag, line: frame.line, childTags: frame.children });
|
|
338
|
+
stack.length = at; // pop this frame and any unclosed frames above it
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const parentTag = stack.length ? stack[stack.length - 1].tag : 'ROOT';
|
|
342
|
+
if (stack.length) stack[stack.length - 1].children.push(tag);
|
|
343
|
+
if (selfClose || VOID_TAGS.has(tag.toLowerCase())) {
|
|
344
|
+
nodes.push({ tag, parentTag, line, childTags: [] });
|
|
345
|
+
} else {
|
|
346
|
+
stack.push({ tag, parentTag, line, children: [] });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
while (stack.length) {
|
|
350
|
+
const frame = stack.pop();
|
|
351
|
+
nodes.push({ tag: frame.tag, parentTag: frame.parentTag, line: frame.line, childTags: frame.children });
|
|
352
|
+
}
|
|
353
|
+
return nodes;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* LLD-0016 §C4: check every occurrence of `tag` in `text` against its
|
|
358
|
+
* declared `allowedParents`/`allowedChildren` (already resolved from
|
|
359
|
+
* catalog `component:` names to tag names by the generator —
|
|
360
|
+
* scripts/build/gen-composition-rules.mjs — so this stays a pure
|
|
361
|
+
* string/tree matcher with no registry lookup at match-time, same as
|
|
362
|
+
* every other rule). The reserved catalog name `'Surface'` in
|
|
363
|
+
* `allowedParents` matches a `parentTag === 'ROOT'` occurrence.
|
|
364
|
+
*/
|
|
365
|
+
function compositionFindings(text, { tag, allowedParents, allowedChildren }) {
|
|
366
|
+
const out = [];
|
|
367
|
+
for (const node of tagTree(text)) {
|
|
368
|
+
if (node.tag !== tag) continue;
|
|
369
|
+
if (allowedParents && allowedParents.length) {
|
|
370
|
+
const parentOk = node.parentTag === 'ROOT'
|
|
371
|
+
? allowedParents.includes('Surface')
|
|
372
|
+
: allowedParents.includes(node.parentTag);
|
|
373
|
+
if (!parentOk) {
|
|
374
|
+
const parentLabel = node.parentTag === 'ROOT' ? '(surface root)' : `<${node.parentTag}>`;
|
|
375
|
+
out.push({
|
|
376
|
+
line: node.line,
|
|
377
|
+
snippet: `${parentLabel} > <${tag}>`,
|
|
378
|
+
why: `<${tag}> declares a2ui.allowedParents: [${allowedParents.join(', ')}] — ${parentLabel} is not one of them`,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (allowedChildren && allowedChildren.length) {
|
|
383
|
+
for (const child of node.childTags) {
|
|
384
|
+
if (!allowedChildren.includes(child)) {
|
|
385
|
+
out.push({
|
|
386
|
+
line: node.line,
|
|
387
|
+
snippet: `<${tag}> > <${child}>`,
|
|
388
|
+
why: `<${tag}> declares a2ui.allowedChildren: [${allowedChildren.join(', ')}] — <${child}> is not one of them`,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Yaml-corpus primitive (LLD-0016 §C3): reads one component yaml's own
|
|
399
|
+
* top-level `component:` scalar and its `props:` mapping's first-level key
|
|
400
|
+
* names (not the full per-prop schema) — enough for EXAMPLE-PROP-CONTRACT's
|
|
401
|
+
* cross-file corpus map. Indentation-based, not a full YAML parse (§C2).
|
|
402
|
+
*/
|
|
403
|
+
function extractYamlComponentPropKeys(text) {
|
|
404
|
+
const lines = text.split('\n');
|
|
405
|
+
let component = null;
|
|
406
|
+
const propKeys = new Set();
|
|
407
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
408
|
+
const cm = /^component:\s*(.+?)\s*$/.exec(lines[i]);
|
|
409
|
+
if (cm && component === null) {
|
|
410
|
+
// Strip a trailing `# comment` outside quotes — a bare split would
|
|
411
|
+
// otherwise bake it into the captured component name.
|
|
412
|
+
component = cm[1].replace(/\s+#.*$/, '').replace(/^['"]|['"]$/g, '');
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (/^props:\s*$/.test(lines[i])) {
|
|
416
|
+
let j = i + 1;
|
|
417
|
+
let keyIndent = null;
|
|
418
|
+
while (j < lines.length) {
|
|
419
|
+
const line = lines[j];
|
|
420
|
+
if (line.trim() === '') { j += 1; continue; }
|
|
421
|
+
const lineIndent = line.match(/^(\s*)/)[1].length;
|
|
422
|
+
if (lineIndent === 0) break;
|
|
423
|
+
if (keyIndent === null) keyIndent = lineIndent;
|
|
424
|
+
if (lineIndent < keyIndent) break;
|
|
425
|
+
if (lineIndent === keyIndent) {
|
|
426
|
+
const km = /^\s*(?:'([^']+)'|"([^"]+)"|([\w-]+)):/.exec(line);
|
|
427
|
+
if (km) propKeys.add(km[1] || km[2] || km[3]);
|
|
428
|
+
}
|
|
429
|
+
j += 1;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return { component, propKeys };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* scripts/lint/engine/run.mjs — the rule-bank runner. Takes a list of rule
|
|
439
|
+
* modules (LLD-0016 §C1 shape) plus a scope filter, and turns
|
|
440
|
+
* `lintText(text, path)` into the same `{ name, line, snippet, why }`
|
|
441
|
+
* Finding[] shape both Python scripts returned — so `_render()`'s output
|
|
442
|
+
* format, and everything downstream of it (PostToolUse additionalContext,
|
|
443
|
+
* `npm run lint` console output), does not have to change in this slice.
|
|
444
|
+
*/
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* @param {Array<{id:string, severity:string, scope:string, fileTypes:string[], match:Function}>} rules
|
|
449
|
+
* @param {{scopes?: string[]}} [opts] which rule scopes to run — omit for all
|
|
450
|
+
*/
|
|
451
|
+
function makeLinter(rules, opts = {}) {
|
|
452
|
+
const scopes = opts.scopes || null;
|
|
453
|
+
const active = rules.filter((r) => !scopes || scopes.includes(r.scope));
|
|
454
|
+
|
|
455
|
+
function lintText(text, path = '') {
|
|
456
|
+
const fileExt = ext(path);
|
|
457
|
+
const rawLines = text.split('\n');
|
|
458
|
+
let findings = [];
|
|
459
|
+
for (const rule of active) {
|
|
460
|
+
if (rule.fileTypes && rule.fileTypes.length && !rule.fileTypes.includes(fileExt)) continue;
|
|
461
|
+
const hits = rule.match(text, path, { scopes }) || [];
|
|
462
|
+
for (const h of hits) {
|
|
463
|
+
findings.push({ name: rule.id, line: h.line, snippet: h.snippet, why: h.why });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Suppression syntax (LLD-0016 §Interfaces):
|
|
468
|
+
// /* adia-lint-disable <rule-id> — <reason> */ on the finding's own line
|
|
469
|
+
// or the line before it drops that one finding. A bare disable with no
|
|
470
|
+
// <reason> is itself a finding (SUPPRESS-NO-REASON, warn) — checked
|
|
471
|
+
// once per suppression comment in the file, independent of whether it
|
|
472
|
+
// actually suppressed anything this run.
|
|
473
|
+
findings = findings.filter((f) => !suppressionFor(rawLines, f.line, f.name).suppressed);
|
|
474
|
+
for (const s of allSuppressions(rawLines)) {
|
|
475
|
+
if (!s.hasReason) {
|
|
476
|
+
findings.push({
|
|
477
|
+
name: 'SUPPRESS-NO-REASON',
|
|
478
|
+
line: s.lineNo,
|
|
479
|
+
snippet: (rawLines[s.lineNo - 1] || '').trim().slice(0, 90),
|
|
480
|
+
why: `annotate why ${s.ruleId} is suppressed here — a bare adia-lint-disable with no reason is load-bearing, not cosmetic`,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
findings.sort((a, b) => (a.line - b.line) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
486
|
+
return findings;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return { lintText, rules: active };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function render(path, findings) {
|
|
493
|
+
const out = [`${findings.__label || 'lint'}: ${findings.length} structural smell(s) in ${path || '<stdin>'}`];
|
|
494
|
+
for (const f of findings) {
|
|
495
|
+
out.push(` [${f.name}] line ${f.line}: ${f.snippet}`);
|
|
496
|
+
out.push(` → ${f.why}`);
|
|
497
|
+
}
|
|
498
|
+
return out.join('\n');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
const RULES = [];
|
|
503
|
+
{
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
const APPLIES_EXT = ['.html', '.htm', '.vue', '.svelte', '.astro', '.tsx', '.jsx', '.js', '.mjs', '.ts'];
|
|
507
|
+
|
|
508
|
+
const genuiUnvalidated = {
|
|
509
|
+
id: 'GENUI-UNVALIDATED',
|
|
510
|
+
severity: 'error',
|
|
511
|
+
scope: 'consumer-only',
|
|
512
|
+
fileTypes: APPLIES_EXT,
|
|
513
|
+
match(text) {
|
|
514
|
+
const m = /\.doc\s*=/.exec(text);
|
|
515
|
+
if (!m) return [];
|
|
516
|
+
const line = lineOf(text, m.index);
|
|
517
|
+
if (text.includes('validate_schema') || text.includes('check_anti_patterns')) return [];
|
|
518
|
+
return [{
|
|
519
|
+
line,
|
|
520
|
+
snippet: '.doc = … with no validate in file',
|
|
521
|
+
why: 'A2UI fed to .doc must pass validate_schema + check_anti_patterns first (gen-ui-wiring\'s hard gate) — validate in the same module or cite where it happened',
|
|
522
|
+
}];
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const genuiDocConflict = {
|
|
527
|
+
id: 'GENUI-DOC-CONFLICT',
|
|
528
|
+
severity: 'error',
|
|
529
|
+
scope: 'consumer-only',
|
|
530
|
+
fileTypes: APPLIES_EXT,
|
|
531
|
+
match(text) {
|
|
532
|
+
const m = /\.doc\s*=/.exec(text);
|
|
533
|
+
if (!m) return [];
|
|
534
|
+
const line = lineOf(text, m.index);
|
|
535
|
+
if (/<(?:a2ui-root|gen-root)[^>]*\b(?:src|transport)=/.test(text)) {
|
|
536
|
+
return [{
|
|
537
|
+
line,
|
|
538
|
+
snippet: '.doc = … alongside src=/transport=',
|
|
539
|
+
why: 'two feed paths fight — a root is fed EITHER declaratively (src/transport) OR programmatically (.doc), never both',
|
|
540
|
+
}];
|
|
541
|
+
}
|
|
542
|
+
return [];
|
|
543
|
+
},
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
RULES.push(genuiUnvalidated, genuiDocConflict);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
{
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
// `*.examples.html` is always a component-gallery page (packages/*/
|
|
554
|
+
// components, apps/overview.examples.html) — never live wired app code.
|
|
555
|
+
// `*.contents.html` is NOT universally a demo: ADR-0021 makes it the live
|
|
556
|
+
// production content-fragment convention fetched and injected verbatim by
|
|
557
|
+
// every apps/* and playgrounds/* page shell, so a stuck-open modal there is
|
|
558
|
+
// a real bug, not a gallery artifact. Only catalog/**/*.contents.html — the
|
|
559
|
+
// pattern-catalog's own chunk demos, captured statically for the gallery —
|
|
560
|
+
// gets the exemption; scope it to that tree, not the filename alone.
|
|
561
|
+
const EXAMPLES_FILE = /\.examples\.html$/;
|
|
562
|
+
const CATALOG_CONTENTS_FILE = /\.contents\.html$/;
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
RULES.push({
|
|
566
|
+
id: 'HARDCODED-OPEN',
|
|
567
|
+
severity: 'error',
|
|
568
|
+
scope: 'consumer-only',
|
|
569
|
+
fileTypes: MARKUP_EXT,
|
|
570
|
+
match(text, path) {
|
|
571
|
+
const p = (path || '').replace(/\\/g, '/');
|
|
572
|
+
if (EXAMPLES_FILE.test(p)) return [];
|
|
573
|
+
if (CATALOG_CONTENTS_FILE.test(p) && p.split('/')[0] === 'catalog') return [];
|
|
574
|
+
const out = [];
|
|
575
|
+
text.split('\n').forEach((line, i) => {
|
|
576
|
+
if (RE.OVERLAY_OPEN.test(line)) {
|
|
577
|
+
out.push({
|
|
578
|
+
line: i + 1,
|
|
579
|
+
snippet: line.trim().slice(0, 90),
|
|
580
|
+
why: 'drive overlays via the .open property, not a hardcoded `open` attribute (it bricks the page)',
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
return out;
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
{
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
const APPLIES_EXT = ['.html', '.htm', '.vue', '.svelte', '.astro', '.tsx', '.jsx', '.js', '.mjs', '.ts'];
|
|
593
|
+
|
|
594
|
+
// Only a quoted string literal is a candidate secret — `process.env.X`, a
|
|
595
|
+
// bare identifier (`opts.apiKey`), and a TS type annotation (`apiKey:
|
|
596
|
+
// string`) are all references, not values, and never match this capture.
|
|
597
|
+
const KEY_ASSIGN = /\bapi[-_]?[Kk]ey\b\s*[:=]\s*(['"`])((?:\\.|(?!\1).)*)\1/g;
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
RULES.push({
|
|
601
|
+
id: 'LLM-KEY-IN-CLIENT',
|
|
602
|
+
severity: 'error',
|
|
603
|
+
scope: 'consumer-only',
|
|
604
|
+
fileTypes: APPLIES_EXT,
|
|
605
|
+
match(text, path) {
|
|
606
|
+
const segs = (path || '').replace(/\\/g, '/').toLowerCase().split('/');
|
|
607
|
+
const base = segs[segs.length - 1] || '';
|
|
608
|
+
// server-side execution contexts: an explicit server/proxy/api dir, a
|
|
609
|
+
// CLI/MCP script tree, or a test file (fake keys in test fixtures never
|
|
610
|
+
// ship to a browser).
|
|
611
|
+
if (segs.some((s) => s.includes('server') || s.includes('proxy') || s === 'api' || s === 'mcp' || s === 'cli' || s === 'scripts')) return [];
|
|
612
|
+
if (/\.(test|spec)\.[jt]sx?$/.test(base)) return [];
|
|
613
|
+
const out = [];
|
|
614
|
+
for (const { lineNo, stripped } of stripCommentLines(text, ext(path))) {
|
|
615
|
+
let m;
|
|
616
|
+
KEY_ASSIGN.lastIndex = 0;
|
|
617
|
+
while ((m = KEY_ASSIGN.exec(stripped))) {
|
|
618
|
+
const value = m[2];
|
|
619
|
+
// an empty string is a default/reset, not a secret; a value that
|
|
620
|
+
// documents itself as non-secret (a proxy sentinel) isn't one either
|
|
621
|
+
if (!value || /server-side|proxy/i.test(value)) continue;
|
|
622
|
+
out.push({
|
|
623
|
+
line: lineNo,
|
|
624
|
+
snippet: stripped.trim().slice(0, 90),
|
|
625
|
+
why: 'a provider key in client source ships to the browser — production uses a same-origin smart proxy (proxy-url); keys live server-side (llm-wiring)',
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return out;
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
{
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
const APPLIES_EXT = ['.html', '.htm', '.vue', '.svelte', '.astro', '.tsx', '.jsx', '.js', '.mjs', '.ts'];
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
RULES.push({
|
|
641
|
+
id: 'SHELL-NESTING',
|
|
642
|
+
severity: 'error',
|
|
643
|
+
scope: 'consumer-only',
|
|
644
|
+
fileTypes: APPLIES_EXT,
|
|
645
|
+
match(text) {
|
|
646
|
+
const out = [];
|
|
647
|
+
const pages = (text.match(/<admin-page[\s/>]/g) || []).length;
|
|
648
|
+
const scrolls = (text.match(/<admin-scroll[\s/>]/g) || []).length;
|
|
649
|
+
if (scrolls && pages > scrolls) {
|
|
650
|
+
out.push({
|
|
651
|
+
line: 1,
|
|
652
|
+
snippet: '<admin-page> × N inside <admin-scroll>',
|
|
653
|
+
why: 'each <admin-scroll> hosts exactly one <admin-page> — multiple pages need multiple scroll regions',
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
const re = /<(?:col|row)-ui[^>]*>\s*<(?:admin|chat|editor)-/g;
|
|
657
|
+
let m;
|
|
658
|
+
while ((m = re.exec(text))) {
|
|
659
|
+
out.push({
|
|
660
|
+
line: lineOf(text, m.index),
|
|
661
|
+
snippet: text.slice(m.index, m.index + 60).trim().slice(0, 90),
|
|
662
|
+
why: 'shell children are positioned by tag selectors — wrapping them in <col-ui>/<row-ui> breaks the shell grid; generics go inside admin-content/admin-page-body',
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
return out;
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
{
|
|
671
|
+
const APPLIES_EXT = ['.html', '.htm', '.vue', '.svelte', '.astro', '.tsx', '.jsx', '.js', '.mjs', '.ts'];
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
RULES.push({
|
|
675
|
+
id: 'SHELL-RESIZE',
|
|
676
|
+
severity: 'error',
|
|
677
|
+
scope: 'consumer-only',
|
|
678
|
+
fileTypes: APPLIES_EXT,
|
|
679
|
+
match(text) {
|
|
680
|
+
if (/<admin-sidebar[^>]*\bresizable\b/.test(text) && !text.includes('data-sidebar-resize')) {
|
|
681
|
+
return [{
|
|
682
|
+
line: 1,
|
|
683
|
+
snippet: '<admin-sidebar resizable> without [data-sidebar-resize]',
|
|
684
|
+
why: '[resizable] needs a child <div data-sidebar-resize> or there is no drag handle',
|
|
685
|
+
}];
|
|
686
|
+
}
|
|
687
|
+
return [];
|
|
688
|
+
},
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
{
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
function isSsr(text, fileExt) {
|
|
696
|
+
return RE.SSR_SIGNAL.test(text) || ['.vue', '.svelte', '.astro'].includes(fileExt);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const ssrDoubleRouter = {
|
|
700
|
+
id: 'SSR-DOUBLE-ROUTER',
|
|
701
|
+
severity: 'error',
|
|
702
|
+
scope: 'consumer-only',
|
|
703
|
+
fileTypes: [],
|
|
704
|
+
match(text, path) {
|
|
705
|
+
const fileExt = ext(path);
|
|
706
|
+
if (!isSsr(text, fileExt)) return [];
|
|
707
|
+
const out = [];
|
|
708
|
+
text.split('\n').forEach((line, i) => {
|
|
709
|
+
if (line.includes('<router-ui')) {
|
|
710
|
+
out.push({
|
|
711
|
+
line: i + 1,
|
|
712
|
+
snippet: line.trim().slice(0, 90),
|
|
713
|
+
why: "in SSR the framework router owns routing — don't also mount <router-ui> (two route owners)",
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
return out;
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const ssrToplevelImport = {
|
|
722
|
+
id: 'SSR-TOPLEVEL-IMPORT',
|
|
723
|
+
severity: 'error',
|
|
724
|
+
scope: 'consumer-only',
|
|
725
|
+
fileTypes: [],
|
|
726
|
+
match(text, path) {
|
|
727
|
+
const fileExt = ext(path);
|
|
728
|
+
if (!isSsr(text, fileExt)) return [];
|
|
729
|
+
const out = [];
|
|
730
|
+
text.split('\n').forEach((line, i) => {
|
|
731
|
+
if (RE.TOPLEVEL_IMPORT.test(line)) {
|
|
732
|
+
out.push({
|
|
733
|
+
line: i + 1,
|
|
734
|
+
snippet: line.trim().slice(0, 90),
|
|
735
|
+
why: 'a top-level kit import throws `HTMLElement is not defined` on the server — defer it into a client hook',
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
return out;
|
|
740
|
+
},
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
RULES.push(ssrDoubleRouter, ssrToplevelImport);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
{
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
RULES.push({
|
|
752
|
+
id: 'MISSING-SCOPE',
|
|
753
|
+
severity: 'error',
|
|
754
|
+
scope: 'forge-only',
|
|
755
|
+
fileTypes: STYLE_EXT,
|
|
756
|
+
match(text, path) {
|
|
757
|
+
const isTokenish = isFoundationCss(path, text, 'forge-lint');
|
|
758
|
+
if (isTokenish) return [];
|
|
759
|
+
if (text.includes('@scope')) return [];
|
|
760
|
+
if (!RE.SELECTOR_RULE.test(text)) return [];
|
|
761
|
+
return [{
|
|
762
|
+
line: 1,
|
|
763
|
+
snippet: '(file has CSS rules but no @scope block)',
|
|
764
|
+
why: 'component CSS must be wrapped in `@scope (<tag>) { … }` so styles don\'t leak in light DOM (foundation/token sheets excepted; opt out with /* forge-lint: foundation */)',
|
|
765
|
+
}];
|
|
766
|
+
},
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
{
|
|
771
|
+
// GENERATED — do not hand-edit. Source: packages/web-modules/chat/chat-shell/chat-shell.yaml's a2ui.allowedParents/allowedChildren.
|
|
772
|
+
// Rebuild: node scripts/build/gen-composition-rules.mjs
|
|
773
|
+
// Freshness gate: node scripts/verify/check-composition-rules-fresh.mjs --verify
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
const TAG = "chat-shell";
|
|
777
|
+
const ALLOWED_PARENTS = ["Surface"];
|
|
778
|
+
const ALLOWED_CHILDREN = [];
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
RULES.push({
|
|
782
|
+
id: "COMPOSITION-CHAT-SHELL",
|
|
783
|
+
severity: 'warn', // LLD-0016 §C4: new, unproven-at-scale matcher (Risk R4) — advisory until a corpus-wide rollout promotes it, same posture check-example-ids.mjs's Phase-1 rule started at.
|
|
784
|
+
scope: 'shared',
|
|
785
|
+
fileTypes: MARKUP_EXT,
|
|
786
|
+
match(text) {
|
|
787
|
+
return compositionFindings(text, { tag: TAG, allowedParents: ALLOWED_PARENTS, allowedChildren: ALLOWED_CHILDREN });
|
|
788
|
+
},
|
|
789
|
+
fix: null, // moving markup to satisfy a composition constraint is a judgment call, not a mechanical rename (LLD-0016 §Risks R4/R5)
|
|
790
|
+
fixtures: {
|
|
791
|
+
"smelly": [
|
|
792
|
+
"<col-ui><chat-shell></chat-shell></col-ui>"
|
|
793
|
+
],
|
|
794
|
+
"clean": [
|
|
795
|
+
"<chat-shell></chat-shell>"
|
|
796
|
+
]
|
|
797
|
+
},
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
{
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* gh#2648 (LLD-0016 §C3) — the 12-entry hand-authored registry formerly in
|
|
806
|
+
* packages/gen-ui/engine/retrieval/anti-patterns.js, absorbed as rule
|
|
807
|
+
* modules with `scope: 'genui-output'`. `noInventedComponents` stays OUT of
|
|
808
|
+
* this file and out of the generated bank on purpose: it needs the live
|
|
809
|
+
* `@adia-ai/a2ui` registry + component-catalog.js sidecar data at check
|
|
810
|
+
* time, not a stdlib-only text/regex match — the same reasoning LLD-0016's
|
|
811
|
+
* own Non-goals section gives for leaving check-genui-catalog.mjs's
|
|
812
|
+
* runtime-state assertions out of the shared bank. It stays authored
|
|
813
|
+
* directly in anti-patterns.js, which composes it with these 11.
|
|
814
|
+
*
|
|
815
|
+
* `id` is kept as the original camelCase pattern name (not the bank's usual
|
|
816
|
+
* UPPER-KEBAB convention) because `checkAntiPattern(name, html)`'s public
|
|
817
|
+
* contract keys on that exact string (LLD-0016 §Interfaces: "public name
|
|
818
|
+
* and input/output shape unchanged") — a callable a2ui-maintenance skills
|
|
819
|
+
* and anti-patterns.test.js both depend on today.
|
|
820
|
+
*
|
|
821
|
+
* `description`/`guidance` are extra fields the generic engine (run.mjs)
|
|
822
|
+
* never reads — anti-patterns.js reads them to reconstruct the legacy
|
|
823
|
+
* `{name, description, fix}` shape. `fix` is deliberately left unset: it is
|
|
824
|
+
* the C1 auto-fixer slot `(text, finding) => text`, a different thing from
|
|
825
|
+
* the human-readable remediation text these patterns call `fix` in their
|
|
826
|
+
* old shape (renamed `guidance` here to avoid the collision).
|
|
827
|
+
*/
|
|
828
|
+
|
|
829
|
+
const HTML_LIKE = [];
|
|
830
|
+
|
|
831
|
+
const noBareDivs = {
|
|
832
|
+
id: 'noBareDivs',
|
|
833
|
+
severity: 'error',
|
|
834
|
+
scope: 'genui-output',
|
|
835
|
+
fileTypes: HTML_LIKE,
|
|
836
|
+
description: 'All elements should be AdiaUI components, not bare <div> elements.',
|
|
837
|
+
guidance: 'Replace <div> with the appropriate AdiaUI layout component: Row, Column, Grid, Card, or Section.',
|
|
838
|
+
match(text) {
|
|
839
|
+
const m = /<div[\s>]/i.exec(text);
|
|
840
|
+
if (!m) return [];
|
|
841
|
+
return [{ line: lineOf(text, m.index), snippet: text.slice(m.index, m.index + 60).trim(), why: 'bare <div> — AdiaUI elements only' }];
|
|
842
|
+
},
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
const noBareInputs = {
|
|
846
|
+
id: 'noBareInputs',
|
|
847
|
+
severity: 'error',
|
|
848
|
+
scope: 'genui-output',
|
|
849
|
+
fileTypes: HTML_LIKE,
|
|
850
|
+
description: 'Form controls should use component wrappers, not native <input>/<select>/<textarea>.',
|
|
851
|
+
guidance: 'Use input-ui, select-ui, textarea-ui, check-ui, switch-ui, slider-ui, or datetime-ui instead.',
|
|
852
|
+
match(text) {
|
|
853
|
+
const m = /<(input|select|textarea)[\s>]/i.exec(text);
|
|
854
|
+
if (!m) return [];
|
|
855
|
+
return [{ line: lineOf(text, m.index), snippet: text.slice(m.index, m.index + 60).trim(), why: 'native form control — AdiaUI wrapper only' }];
|
|
856
|
+
},
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
const cardStructure = {
|
|
860
|
+
id: 'cardStructure',
|
|
861
|
+
severity: 'error',
|
|
862
|
+
scope: 'genui-output',
|
|
863
|
+
fileTypes: HTML_LIKE,
|
|
864
|
+
description: 'Card children must follow the header/section/footer anatomy. Header and footer are direct children of card.',
|
|
865
|
+
guidance: 'Header and Footer must be direct children of Card, not nested inside Section. Structure: Card > Header + Section + Footer.',
|
|
866
|
+
match(text) {
|
|
867
|
+
const m = /<section[^>]*>\s*<header/i.exec(text) || /<section[^>]*>\s*<footer/i.exec(text);
|
|
868
|
+
if (!m) return [];
|
|
869
|
+
return [{ line: lineOf(text, m.index), snippet: text.slice(m.index, m.index + 60).trim(), why: 'header/footer nested inside section' }];
|
|
870
|
+
},
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
const flatAdjacency = {
|
|
874
|
+
id: 'flatAdjacency',
|
|
875
|
+
severity: 'error',
|
|
876
|
+
scope: 'genui-output',
|
|
877
|
+
fileTypes: HTML_LIKE,
|
|
878
|
+
description: 'AdiaUI uses flat component lists with ID references, not deeply nested component trees.',
|
|
879
|
+
guidance: 'Use a flat adjacency list with unique IDs and children arrays referencing IDs.',
|
|
880
|
+
match(text) {
|
|
881
|
+
const m = /<[a-z]+-(?:n|ui)[^>]*>\s*<[a-z]+-(?:n|ui)[^>]*>\s*<[a-z]+-(?:n|ui)[^>]*>\s*<[a-z]+-(?:n|ui)[^>]*>\s*<[a-z]+-(?:n|ui)/i.exec(text);
|
|
882
|
+
if (!m) return [];
|
|
883
|
+
return [{ line: lineOf(text, m.index), snippet: text.slice(m.index, m.index + 60).trim(), why: '5+ levels of nested AdiaUI components' }];
|
|
884
|
+
},
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
const columnWrap = {
|
|
888
|
+
id: 'columnWrap',
|
|
889
|
+
severity: 'error',
|
|
890
|
+
scope: 'genui-output',
|
|
891
|
+
fileTypes: HTML_LIKE,
|
|
892
|
+
description: 'Content inside Section must be wrapped in a Column (col-ui), not placed directly.',
|
|
893
|
+
guidance: 'Wrap section content in <col-ui>: Section > Column > Text/content.',
|
|
894
|
+
match(text) {
|
|
895
|
+
const m = /<section[^>]*>\s*<(text-ui|p |p>|h[1-6])/i.exec(text);
|
|
896
|
+
if (!m) return [];
|
|
897
|
+
return [{ line: lineOf(text, m.index), snippet: text.slice(m.index, m.index + 60).trim(), why: 'section content not wrapped in col-ui' }];
|
|
898
|
+
},
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
const noHardcodedColors = {
|
|
902
|
+
id: 'noHardcodedColors',
|
|
903
|
+
severity: 'error',
|
|
904
|
+
scope: 'genui-output',
|
|
905
|
+
fileTypes: HTML_LIKE,
|
|
906
|
+
description: 'Use design token colors (--a-*) instead of hardcoded hex/rgb/hsl values.',
|
|
907
|
+
guidance: 'Use token-based colors via variant attributes or CSS custom properties (--a-canvas-*, --a-primary-*, etc.).',
|
|
908
|
+
match(text) {
|
|
909
|
+
const m = /(style\s*=\s*"[^"]*(?:color|background)\s*:\s*(?:#[0-9a-f]{3,8}|rgb|hsl))/i.exec(text);
|
|
910
|
+
if (!m) return [];
|
|
911
|
+
return [{ line: lineOf(text, m.index), snippet: text.slice(m.index, m.index + 60).trim(), why: 'hardcoded color literal in inline style' }];
|
|
912
|
+
},
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
const noInlineLayout = {
|
|
916
|
+
id: 'noInlineLayout',
|
|
917
|
+
severity: 'error',
|
|
918
|
+
scope: 'genui-output',
|
|
919
|
+
fileTypes: HTML_LIKE,
|
|
920
|
+
description: 'Use declarative layout attributes instead of inline display/flex/grid styles.',
|
|
921
|
+
guidance: 'Use Row, Column, Grid components with gap/columns attributes instead of inline CSS layout.',
|
|
922
|
+
match(text) {
|
|
923
|
+
const m = /(style\s*=\s*"[^"]*(?:display\s*:|flex|grid-template))/i.exec(text);
|
|
924
|
+
if (!m) return [];
|
|
925
|
+
return [{ line: lineOf(text, m.index), snippet: text.slice(m.index, m.index + 60).trim(), why: 'inline CSS layout instead of a layout component' }];
|
|
926
|
+
},
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
const noCssPropAttrs = {
|
|
930
|
+
id: 'noCssPropAttrs',
|
|
931
|
+
severity: 'error',
|
|
932
|
+
scope: 'genui-output',
|
|
933
|
+
fileTypes: HTML_LIKE,
|
|
934
|
+
description: 'CSS properties (display, visibility) are not markup attributes. Components silently ignore unknown attributes, so display="none" content renders VISIBLY — overlapping its siblings.',
|
|
935
|
+
guidance: 'Remove the attribute. To hide content conditionally, omit the element from the tree (or use the component\'s documented state attribute, e.g. [hidden] on plain containers).',
|
|
936
|
+
match(text) {
|
|
937
|
+
const m = /\s(?:display|visibility)\s*=\s*"/i.exec(text);
|
|
938
|
+
if (!m) return [];
|
|
939
|
+
return [{ line: lineOf(text, m.index), snippet: text.slice(m.index, m.index + 60).trim(), why: 'CSS property used as a markup attribute' }];
|
|
940
|
+
},
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
const inputTypeTextarea = {
|
|
944
|
+
id: 'inputTypeTextarea',
|
|
945
|
+
severity: 'error',
|
|
946
|
+
scope: 'genui-output',
|
|
947
|
+
fileTypes: HTML_LIKE,
|
|
948
|
+
description: 'input-ui has no type="textarea" — multi-line input is its own component.',
|
|
949
|
+
guidance: 'Use <textarea-ui rows="…"> for multi-line text input.',
|
|
950
|
+
match(text) {
|
|
951
|
+
const m = /<input-ui[^>]*\btype\s*=\s*"textarea"/i.exec(text);
|
|
952
|
+
if (!m) return [];
|
|
953
|
+
return [{ line: lineOf(text, m.index), snippet: text.slice(m.index, m.index + 60).trim(), why: 'input-ui type="textarea" does not exist' }];
|
|
954
|
+
},
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
const fieldOwnsLabelHintError = {
|
|
958
|
+
id: 'fieldOwnsLabelHintError',
|
|
959
|
+
severity: 'error',
|
|
960
|
+
scope: 'genui-output',
|
|
961
|
+
fileTypes: HTML_LIKE,
|
|
962
|
+
description: 'field-ui OWNS the label / hint / error chrome via its label= / hint= / error= attributes. Emitting a sibling text-ui "label", a raw <label>, or a <small> error inside field-ui double-renders and breaks the label-to-control binding (field-ui binds the first slot-less child as the control — a text-ui label steals that binding).',
|
|
963
|
+
guidance: 'Use <field-ui label="Full Name" hint="…" error="…"><input-ui …/></field-ui> — exactly ONE slot-less child (the control); field-ui renders the label, hint, and error itself.',
|
|
964
|
+
match(text) {
|
|
965
|
+
// Excludes the stamped internal label (data-field-label) so post-render
|
|
966
|
+
// HTML doesn't false-positive — matches the original check's own comment.
|
|
967
|
+
let m = /<field-ui[^>]*>\s*(?:<text-ui[^>]*variant\s*=\s*"label"|<label(?![^>]*data-field-label))/i.exec(text);
|
|
968
|
+
if (!m) m = /<field-ui[^>]*>[\s\S]*?<small[\s>]/i.exec(text);
|
|
969
|
+
if (!m) return [];
|
|
970
|
+
return [{ line: lineOf(text, m.index), snippet: text.slice(m.index, m.index + 60).trim(), why: 'field-ui already owns label/hint/error chrome' }];
|
|
971
|
+
},
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
const slotOnContainer = {
|
|
975
|
+
id: 'slotOnContainer',
|
|
976
|
+
severity: 'error',
|
|
977
|
+
scope: 'genui-output',
|
|
978
|
+
fileTypes: HTML_LIKE,
|
|
979
|
+
description: 'Slot attributes (slot="heading", slot="description", slot="action") must go on the content children inside Header/Footer, not on Header/Footer elements themselves.',
|
|
980
|
+
guidance: 'Move slot="heading" from <header> to the Text/heading child inside it. Header is a grid container — slots go on its children.',
|
|
981
|
+
match(text) {
|
|
982
|
+
let m = /<header[^>]*\bslot\s*=/i.exec(text);
|
|
983
|
+
if (!m) m = /<footer[^>]*\bslot\s*=/i.exec(text);
|
|
984
|
+
if (!m) return [];
|
|
985
|
+
return [{ line: lineOf(text, m.index), snippet: text.slice(m.index, m.index + 60).trim(), why: 'slot attribute on header/footer instead of its child' }];
|
|
986
|
+
},
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
RULES.push(noBareDivs,
|
|
991
|
+
noBareInputs,
|
|
992
|
+
cardStructure,
|
|
993
|
+
flatAdjacency,
|
|
994
|
+
columnWrap,
|
|
995
|
+
noHardcodedColors,
|
|
996
|
+
noInlineLayout,
|
|
997
|
+
noCssPropAttrs,
|
|
998
|
+
inputTypeTextarea,
|
|
999
|
+
fieldOwnsLabelHintError,
|
|
1000
|
+
slotOnContainer,);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
{
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
// A UIElement `static properties = {...}` (or the legacy `static props =`
|
|
1007
|
+
// spelling) block — `attr:` is only a real typo for `attribute:` inside one
|
|
1008
|
+
// of these; the same key shows up on plenty of unrelated APIs
|
|
1009
|
+
// (observeBreakpoint's config, example-data objects) where it's correct.
|
|
1010
|
+
const PROPS_BLOCK_OPEN = /\bstatic\s+prop(?:s|erties)\s*=\s*\{/g;
|
|
1011
|
+
|
|
1012
|
+
/** Char-offset [start, end) ranges of every static-properties block body. */
|
|
1013
|
+
function propsBlockRanges(stripped) {
|
|
1014
|
+
const ranges = [];
|
|
1015
|
+
let m;
|
|
1016
|
+
PROPS_BLOCK_OPEN.lastIndex = 0;
|
|
1017
|
+
while ((m = PROPS_BLOCK_OPEN.exec(stripped))) {
|
|
1018
|
+
const braceStart = m.index + m[0].length - 1;
|
|
1019
|
+
let depth = 1;
|
|
1020
|
+
let i = braceStart + 1;
|
|
1021
|
+
let quote = null;
|
|
1022
|
+
// brace-count outside string literals only — a `{`/`}` inside a quoted
|
|
1023
|
+
// default value must not throw off the block's real close.
|
|
1024
|
+
while (i < stripped.length && depth > 0) {
|
|
1025
|
+
const c = stripped[i];
|
|
1026
|
+
if (quote) {
|
|
1027
|
+
if (c === '\\') i += 1;
|
|
1028
|
+
else if (c === quote) quote = null;
|
|
1029
|
+
} else if (c === '"' || c === "'" || c === '`') {
|
|
1030
|
+
quote = c;
|
|
1031
|
+
} else if (c === '{') {
|
|
1032
|
+
depth += 1;
|
|
1033
|
+
} else if (c === '}') {
|
|
1034
|
+
depth -= 1;
|
|
1035
|
+
}
|
|
1036
|
+
i += 1;
|
|
1037
|
+
}
|
|
1038
|
+
ranges.push([braceStart, i]);
|
|
1039
|
+
}
|
|
1040
|
+
return ranges;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
RULES.push({
|
|
1045
|
+
id: 'ATTR-TYPO',
|
|
1046
|
+
severity: 'error',
|
|
1047
|
+
scope: 'shared',
|
|
1048
|
+
fileTypes: CODE_EXT,
|
|
1049
|
+
match(text, path) {
|
|
1050
|
+
const out = [];
|
|
1051
|
+
const stripped = stripCommentLines(text, ext(path)).map((l) => l.stripped).join('\n');
|
|
1052
|
+
const ranges = propsBlockRanges(stripped);
|
|
1053
|
+
if (!ranges.length) return out;
|
|
1054
|
+
const rawLines = text.split('\n');
|
|
1055
|
+
const re = new RegExp(RE.ATTR_TYPO.source, 'g');
|
|
1056
|
+
let m;
|
|
1057
|
+
while ((m = re.exec(stripped))) {
|
|
1058
|
+
if (!ranges.some(([s, e]) => m.index >= s && m.index < e)) continue;
|
|
1059
|
+
const lineNo = lineOf(stripped, m.index);
|
|
1060
|
+
out.push({
|
|
1061
|
+
line: lineNo,
|
|
1062
|
+
snippet: (rawLines[lineNo - 1] || '').trim().slice(0, 90),
|
|
1063
|
+
why: 'did you mean `attribute:`? `attr:` is silently ignored in a property definition',
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
return out;
|
|
1067
|
+
},
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
{
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
RULES.push({
|
|
1076
|
+
id: 'BOOL-DEFAULT-TRUE',
|
|
1077
|
+
severity: 'warn',
|
|
1078
|
+
scope: 'shared',
|
|
1079
|
+
fileTypes: CODE_EXT,
|
|
1080
|
+
match(text, path) {
|
|
1081
|
+
const out = [];
|
|
1082
|
+
for (const { lineNo, raw, stripped } of stripCommentLines(text, ext(path))) {
|
|
1083
|
+
if (!stripped.trim()) continue;
|
|
1084
|
+
if (RE.BOOL_TRUE.test(stripped)) {
|
|
1085
|
+
out.push({
|
|
1086
|
+
line: lineNo,
|
|
1087
|
+
snippet: raw.trim().slice(0, 90),
|
|
1088
|
+
why: "a boolean prop defaulting true can't be turned off by absence — flip the name so absent = false; if the shape is deliberate, note that UIElement stamps the attribute on connect since gh#961 (attribute-keyed CSS is safe), and the attribute reflects truth",
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return out;
|
|
1093
|
+
},
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
{
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
RULES.push({
|
|
1102
|
+
id: 'DEAD-FONT-TOKEN',
|
|
1103
|
+
severity: 'warn',
|
|
1104
|
+
scope: 'shared',
|
|
1105
|
+
fileTypes: STYLE_EXT,
|
|
1106
|
+
match(text, path) {
|
|
1107
|
+
const out = [];
|
|
1108
|
+
for (const { lineNo, raw, stripped } of stripCommentLines(text, ext(path))) {
|
|
1109
|
+
if (!stripped.trim()) continue;
|
|
1110
|
+
if (RE.DEAD_FONT.test(stripped)) {
|
|
1111
|
+
out.push({
|
|
1112
|
+
line: lineNo,
|
|
1113
|
+
snippet: raw.trim().slice(0, 90),
|
|
1114
|
+
why: '--a-font is not a real token (resolves to UA serif) — use var(--a-font-family-ui)',
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
return out;
|
|
1119
|
+
},
|
|
1120
|
+
// LLD-0016 §Risks R5: the one rule this design proves `fix()` against —
|
|
1121
|
+
// its own `why` already names the single correct replacement
|
|
1122
|
+
// (`--a-font-family-ui`), and RE.DEAD_FONT only matches the bare dead
|
|
1123
|
+
// token immediately followed by `,`/`)` (optional whitespace between),
|
|
1124
|
+
// so it can never re-match an already-correct `--a-font-family-ui`
|
|
1125
|
+
// reference — no ambiguity, unlike ATTR-TYPO's `attr:` regex, which
|
|
1126
|
+
// this ticket's grounding pass found collides with unrelated option
|
|
1127
|
+
// keys named `attr` in real corpus code (packages/web-components/core/
|
|
1128
|
+
// breakpoint-observer.test.js) and is therefore NOT safe to auto-fix
|
|
1129
|
+
// as shipped — filed as a follow-up, not built here (out of gh#2647's
|
|
1130
|
+
// scope, a Phase-0 rule's own matcher precision).
|
|
1131
|
+
fix(text, finding) {
|
|
1132
|
+
const lines = text.split('\n');
|
|
1133
|
+
const idx = finding.line - 1;
|
|
1134
|
+
if (idx < 0 || idx >= lines.length) return text;
|
|
1135
|
+
lines[idx] = lines[idx].replace(/--a-font(\s*[,)])/, '--a-font-family-ui$1');
|
|
1136
|
+
return lines.join('\n');
|
|
1137
|
+
},
|
|
1138
|
+
fixtures: {
|
|
1139
|
+
smelly: [':root { font-family: var(--a-font, sans-serif); }'],
|
|
1140
|
+
clean: [':root { font-family: var(--a-font-family-ui, sans-serif); }'],
|
|
1141
|
+
},
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
{
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
// gh#2492 Phase 1 — advisory gate: walks every component yaml's
|
|
1149
|
+
// `examples[].a2ui` JSON blocks and reports node ids that fail the
|
|
1150
|
+
// semantic-id grammar documented in
|
|
1151
|
+
// packages/plugins/adia-ui-forge/skills/primitive-authoring/references/
|
|
1152
|
+
// yaml-contract.md's `## examples: field` section (kebab-case, no
|
|
1153
|
+
// positional-abbreviation shape like k1/c2/q1, min 3 chars, unique within
|
|
1154
|
+
// its own example — do not restate the rule's rationale here, only its
|
|
1155
|
+
// shape). gh#2492 Phase 2's corpus-wide sweep will promote this rule's
|
|
1156
|
+
// severity to error once every yaml is renamed — do not flip early.
|
|
1157
|
+
|
|
1158
|
+
// A bare 1-2 char id, or a short letter run immediately followed by digits
|
|
1159
|
+
// (positional-abbreviation shape: k1, k1v, c2, q1, hdr2). Intentionally
|
|
1160
|
+
// does NOT flag every short id — a single real word like `card`/`row` is
|
|
1161
|
+
// the documented root/wrapper exception.
|
|
1162
|
+
const POSITIONAL_RE = /^[a-z]{1,3}\d+[a-z]*$/;
|
|
1163
|
+
const KEBAB_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Validate one example's flat id list against the grammar. Returns an array
|
|
1167
|
+
* of { id, reason } violations.
|
|
1168
|
+
*/
|
|
1169
|
+
function checkExampleIds(ids) {
|
|
1170
|
+
const violations = [];
|
|
1171
|
+
const seen = new Map();
|
|
1172
|
+
for (const id of ids) {
|
|
1173
|
+
seen.set(id, (seen.get(id) ?? 0) + 1);
|
|
1174
|
+
}
|
|
1175
|
+
const handled = new Set();
|
|
1176
|
+
for (const id of ids) {
|
|
1177
|
+
if (typeof id !== 'string' || id.length === 0) continue;
|
|
1178
|
+
if (handled.has(id)) continue;
|
|
1179
|
+
if (seen.get(id) > 1) {
|
|
1180
|
+
violations.push({ id, reason: 'duplicate-in-example' });
|
|
1181
|
+
handled.add(id);
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
if (id.length <= 2) {
|
|
1185
|
+
violations.push({ id, reason: 'too-short' });
|
|
1186
|
+
handled.add(id);
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
if (POSITIONAL_RE.test(id)) {
|
|
1190
|
+
violations.push({ id, reason: 'positional-pattern' });
|
|
1191
|
+
handled.add(id);
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
if (!KEBAB_RE.test(id)) {
|
|
1195
|
+
violations.push({ id, reason: 'not-kebab-case' });
|
|
1196
|
+
handled.add(id);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
return violations;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
RULES.push({
|
|
1204
|
+
id: 'EXAMPLE-ID-GRAMMAR',
|
|
1205
|
+
severity: 'advisory',
|
|
1206
|
+
scope: 'shared',
|
|
1207
|
+
fileTypes: ['.yaml'],
|
|
1208
|
+
match(text) {
|
|
1209
|
+
const findings = [];
|
|
1210
|
+
for (const block of extractYamlA2uiBlocks(text)) {
|
|
1211
|
+
const ids = block.nodes
|
|
1212
|
+
.filter((n) => n && typeof n === 'object')
|
|
1213
|
+
.map((n) => n.id)
|
|
1214
|
+
.filter((id) => typeof id === 'string');
|
|
1215
|
+
for (const v of checkExampleIds(ids)) {
|
|
1216
|
+
findings.push({
|
|
1217
|
+
line: block.line,
|
|
1218
|
+
snippet: `"${v.id}" — ${v.reason}`.slice(0, 90),
|
|
1219
|
+
why: 'example node id fails the semantic-id grammar (kebab-case, no positional pattern like k1/c2, 3+ chars, unique within its example) — see yaml-contract.md',
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
return findings;
|
|
1224
|
+
},
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
{
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
// No static `node:fs`/`node:path` import — this rule module is inlined
|
|
1232
|
+
// into a shared generated bank copied byte-for-byte to 4 destinations
|
|
1233
|
+
// (LLD-0016 §C5), one of which (anti-patterns.generated.js) is
|
|
1234
|
+
// browser-reachable and check:browser-safe forbids a top-level `node:*`
|
|
1235
|
+
// import there. process.getBuiltinModule() reaches the same builtins with
|
|
1236
|
+
// zero import statements — undefined in a browser, so the corpus walk
|
|
1237
|
+
// below fails soft (`[]`/`null`) instead of throwing there.
|
|
1238
|
+
function nodeFsPath() {
|
|
1239
|
+
if (typeof process === 'undefined' || typeof process.getBuiltinModule !== 'function') return null;
|
|
1240
|
+
return { fs: process.getBuiltinModule('node:fs'), path: process.getBuiltinModule('node:path') };
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// gh#2540 — example-node prop-key contract (advisory). A hand-authored
|
|
1244
|
+
// `a2ui.examples` node can set a prop key the target component never
|
|
1245
|
+
// declares (`"text"` instead of the real `textContent`) and nothing else
|
|
1246
|
+
// catches it: the wire schema's `additionalProperties: true` accepts it,
|
|
1247
|
+
// the renderer's generic `#applyProps` loop hands it to
|
|
1248
|
+
// `applyResolvedProp` which just stamps a dead HTML attribute, and no
|
|
1249
|
+
// other build gate diffs a node's keys against the component's own prop
|
|
1250
|
+
// contract. This bug class has already bitten kbd, the shells slice
|
|
1251
|
+
// (#2504), and table (`rows` instead of `data`) — all silently. This rule
|
|
1252
|
+
// makes a new instance visible at lint time without blocking the build on
|
|
1253
|
+
// the (many) pre-existing instances the corpus already carries — those
|
|
1254
|
+
// stay advisory, not error, until a corpus-wide sweep clears them.
|
|
1255
|
+
//
|
|
1256
|
+
// Every node key is legal if it is one of:
|
|
1257
|
+
// - a structural key: id, component, children, child, slot
|
|
1258
|
+
// - a universal prop synthesized onto every component regardless of its
|
|
1259
|
+
// own sidecar: slot, hidden, ariaLive (derive-genui-catalog.mjs's
|
|
1260
|
+
// deriveProps()), plus traits (ADR-0097)
|
|
1261
|
+
// - a prop the TARGET component's own yaml declares under `props:`
|
|
1262
|
+
//
|
|
1263
|
+
// The target component is resolved by the node's own `"component"` value
|
|
1264
|
+
// against every yaml SoT's own `component:` field (the same name an
|
|
1265
|
+
// example author writes — NOT the registry's canonical wire name). A node
|
|
1266
|
+
// whose `component` matches no yaml at all is a different bug class
|
|
1267
|
+
// (unresolvable component name) and is skipped here, not flagged.
|
|
1268
|
+
const STRUCTURAL_KEYS = new Set(['id', 'component', 'children', 'child', 'slot']);
|
|
1269
|
+
const UNIVERSAL_PROP_KEYS = new Set(['slot', 'hidden', 'ariaLive', 'traits']);
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* Check one example's flat node array against the componentPropMap.
|
|
1273
|
+
* Returns { id, component, key } findings for keys that are neither
|
|
1274
|
+
* structural, universal, nor declared by the target component. A node
|
|
1275
|
+
* whose component resolves to nothing in the map is skipped (different
|
|
1276
|
+
* bug class).
|
|
1277
|
+
*/
|
|
1278
|
+
function checkExamplePropContract(nodes, componentPropMap) {
|
|
1279
|
+
const findings = [];
|
|
1280
|
+
if (!Array.isArray(nodes)) return findings;
|
|
1281
|
+
for (const node of nodes) {
|
|
1282
|
+
if (!node || typeof node !== 'object' || typeof node.component !== 'string') continue;
|
|
1283
|
+
const declared = componentPropMap.get(node.component);
|
|
1284
|
+
if (!declared) continue; // unresolvable component name — not this rule's concern
|
|
1285
|
+
for (const key of Object.keys(node)) {
|
|
1286
|
+
if (STRUCTURAL_KEYS.has(key)) continue;
|
|
1287
|
+
if (UNIVERSAL_PROP_KEYS.has(key)) continue;
|
|
1288
|
+
if (declared.has(key)) continue;
|
|
1289
|
+
findings.push({ id: typeof node.id === 'string' ? node.id : '(no id)', component: node.component, key });
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
return findings;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// Same two SoT roots scripts/build/components.mjs (and check-genui-catalog.mjs)
|
|
1296
|
+
// scan (ADR-0012: primitives + composite modules share one yaml contract).
|
|
1297
|
+
const CORPUS_ROOTS = ['packages/web-components/components', 'packages/web-modules'];
|
|
1298
|
+
|
|
1299
|
+
function findRepoRoot(fs, path, filePath) {
|
|
1300
|
+
let dir = path.isAbsolute(filePath) ? path.dirname(filePath) : path.resolve(process.cwd(), path.dirname(filePath || '.'));
|
|
1301
|
+
for (let i = 0; i < 15; i += 1) {
|
|
1302
|
+
if (CORPUS_ROOTS.every((r) => fs.existsSync(path.join(dir, r)))) return dir;
|
|
1303
|
+
const parent = path.dirname(dir);
|
|
1304
|
+
if (parent === dir) return null;
|
|
1305
|
+
dir = parent;
|
|
1306
|
+
}
|
|
1307
|
+
return null;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function walkYamlFiles(fs, path, dir) {
|
|
1311
|
+
const out = [];
|
|
1312
|
+
let entries;
|
|
1313
|
+
try {
|
|
1314
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1315
|
+
} catch {
|
|
1316
|
+
return out;
|
|
1317
|
+
}
|
|
1318
|
+
for (const e of entries) {
|
|
1319
|
+
const full = path.join(dir, e.name);
|
|
1320
|
+
if (e.isDirectory()) out.push(...walkYamlFiles(fs, path, full));
|
|
1321
|
+
else if (e.isFile() && e.name.endsWith('.yaml')) out.push(full);
|
|
1322
|
+
}
|
|
1323
|
+
return out;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// Module-level cache (this rule module is a process-lifetime singleton):
|
|
1327
|
+
// the corpus componentPropMap is read from disk once per repo root, not
|
|
1328
|
+
// once per linted file — building it fresh for every one of ~190 yaml
|
|
1329
|
+
// files in a `npm run lint` pass would be O(n^2) disk IO for no benefit.
|
|
1330
|
+
let cachedRoot;
|
|
1331
|
+
let cachedMap;
|
|
1332
|
+
function corpusPropMap(filePath) {
|
|
1333
|
+
const builtins = nodeFsPath();
|
|
1334
|
+
if (!builtins) return null; // browser/non-Node runtime — fail soft
|
|
1335
|
+
const { fs, path } = builtins;
|
|
1336
|
+
const root = findRepoRoot(fs, path, filePath);
|
|
1337
|
+
if (!root) return null; // no repo root found (e.g. a synthetic/fixture path) — fail soft
|
|
1338
|
+
if (cachedRoot === root && cachedMap) return cachedMap;
|
|
1339
|
+
const files = CORPUS_ROOTS.flatMap((r) => walkYamlFiles(fs, path, path.join(root, r)));
|
|
1340
|
+
const map = new Map();
|
|
1341
|
+
for (const f of files) {
|
|
1342
|
+
let text;
|
|
1343
|
+
try {
|
|
1344
|
+
text = fs.readFileSync(f, 'utf8');
|
|
1345
|
+
} catch {
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
const { component, propKeys } = extractYamlComponentPropKeys(text);
|
|
1349
|
+
if (component) map.set(component, propKeys);
|
|
1350
|
+
}
|
|
1351
|
+
cachedRoot = root;
|
|
1352
|
+
cachedMap = map;
|
|
1353
|
+
return map;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
RULES.push({
|
|
1358
|
+
id: 'EXAMPLE-PROP-CONTRACT',
|
|
1359
|
+
severity: 'advisory',
|
|
1360
|
+
scope: 'shared',
|
|
1361
|
+
fileTypes: ['.yaml'],
|
|
1362
|
+
match(text, path) {
|
|
1363
|
+
const map = corpusPropMap(path || '');
|
|
1364
|
+
if (!map) return [];
|
|
1365
|
+
const findings = [];
|
|
1366
|
+
for (const block of extractYamlA2uiBlocks(text)) {
|
|
1367
|
+
for (const hit of checkExamplePropContract(block.nodes, map)) {
|
|
1368
|
+
findings.push({
|
|
1369
|
+
line: block.line,
|
|
1370
|
+
snippet: `node "${hit.id}" (${hit.component}) sets unknown key "${hit.key}"`.slice(0, 90),
|
|
1371
|
+
why: 'example sets a prop key its target component does not declare (structural/universal/component-own props only) — fix the source yaml and re-run npm run build:components',
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
return findings;
|
|
1376
|
+
},
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
{
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
RULES.push({
|
|
1385
|
+
id: 'LEGACY-SHELL',
|
|
1386
|
+
severity: 'error',
|
|
1387
|
+
scope: 'shared',
|
|
1388
|
+
fileTypes: [],
|
|
1389
|
+
match(text, path) {
|
|
1390
|
+
const out = [];
|
|
1391
|
+
for (const { lineNo, raw, stripped } of stripCommentLines(text, ext(path))) {
|
|
1392
|
+
if (!stripped.trim()) continue;
|
|
1393
|
+
if (RE.LEGACY_SHELL.test(stripped)) {
|
|
1394
|
+
out.push({
|
|
1395
|
+
line: lineNo,
|
|
1396
|
+
snippet: raw.trim().slice(0, 90),
|
|
1397
|
+
why: 'retired shell shape (ADR-0024, v0.4.0) — use the bespoke tag (chat-thread / chat-composer / chat-empty · admin-sidebar / admin-command · editor-canvas · pane-ui)',
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
return out;
|
|
1402
|
+
},
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
{
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
// `<!-- -->` can span many lines; a native tag *mentioned* inside one
|
|
1410
|
+
// (prose, a commented-out example) isn't a live violation. Blank the
|
|
1411
|
+
// comment body char-for-char (newlines kept, so line numbers survive) —
|
|
1412
|
+
// stripCommentLines only understands `/* */` and `//`, not HTML comments.
|
|
1413
|
+
function blankHtmlComments(text) {
|
|
1414
|
+
let out = '';
|
|
1415
|
+
let i = 0;
|
|
1416
|
+
while (i < text.length) {
|
|
1417
|
+
const start = text.indexOf('<!--', i);
|
|
1418
|
+
if (start === -1) {
|
|
1419
|
+
out += text.slice(i);
|
|
1420
|
+
break;
|
|
1421
|
+
}
|
|
1422
|
+
out += text.slice(i, start);
|
|
1423
|
+
const close = text.indexOf('-->', start + 4);
|
|
1424
|
+
const end = close === -1 ? text.length : close + 3;
|
|
1425
|
+
for (let j = start; j < end; j += 1) out += text[j] === '\n' ? '\n' : ' ';
|
|
1426
|
+
i = end;
|
|
1427
|
+
}
|
|
1428
|
+
return out;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// a bare `data-native-ok=""` opts out of nothing — require a real reason,
|
|
1432
|
+
// mirroring how `SUPPRESS_RE`/`suppressionFor` treat a reason-less disable
|
|
1433
|
+
// elsewhere in this engine.
|
|
1434
|
+
const NATIVE_OK = /data-native-ok\s*=\s*(['"])((?:(?!\1).)*)\1/;
|
|
1435
|
+
function hasNativeOk(line) {
|
|
1436
|
+
const m = NATIVE_OK.exec(line);
|
|
1437
|
+
return Boolean(m && m[2].trim());
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
RULES.push({
|
|
1442
|
+
id: 'NATIVE-PRIMITIVE',
|
|
1443
|
+
severity: 'error',
|
|
1444
|
+
scope: 'shared',
|
|
1445
|
+
fileTypes: MARKUP_EXT,
|
|
1446
|
+
match(text, path) {
|
|
1447
|
+
const out = [];
|
|
1448
|
+
const blanked = blankHtmlComments(text);
|
|
1449
|
+
const rawLines = text.split('\n');
|
|
1450
|
+
let lineNo = 0;
|
|
1451
|
+
for (const { raw, stripped } of stripCommentLines(blanked, ext(path))) {
|
|
1452
|
+
lineNo += 1;
|
|
1453
|
+
const s = stripped.trim().slice(0, 90);
|
|
1454
|
+
if (!s) continue;
|
|
1455
|
+
if (stripped.includes('slot=')) continue;
|
|
1456
|
+
if (s.startsWith('//') || s.startsWith('*') || s.startsWith('/*')) continue;
|
|
1457
|
+
if (hasNativeOk(rawLines[lineNo - 1] || raw)) continue;
|
|
1458
|
+
if (RE.NATIVE_PRIMITIVE.test(stripped)) {
|
|
1459
|
+
out.push({
|
|
1460
|
+
line: lineNo,
|
|
1461
|
+
snippet: (rawLines[lineNo - 1] || raw).trim().slice(0, 90),
|
|
1462
|
+
why: 'a composite must build from *-ui primitives (button-ui / input-ui / select-ui / textarea-ui / modal-ui) — raw natives skip focus rings, theming, and form association; a deliberate slotted trigger (with slot=) is the exception, and a genuinely native surface can opt out with data-native-ok="<reason>"',
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
return out;
|
|
1467
|
+
},
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
{
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
// Parity with the two retired scripts: forge-lint recognized only
|
|
1475
|
+
// `forge-lint: foundation`, adia-lint only `adia-lint: foundation` — never
|
|
1476
|
+
// either tag under the other script. Since this rule is shared, the active
|
|
1477
|
+
// caller scope (passed by the engine's `opts.scopes`) picks which single
|
|
1478
|
+
// tag applies; a run with both scopes active (not a real CLI shape today)
|
|
1479
|
+
// accepts either, the closest available reading.
|
|
1480
|
+
function optInTagsFor(scopes) {
|
|
1481
|
+
if (scopes && scopes.includes('forge-only') && !scopes.includes('consumer-only')) return ['forge-lint'];
|
|
1482
|
+
if (scopes && scopes.includes('consumer-only') && !scopes.includes('forge-only')) return ['adia-lint'];
|
|
1483
|
+
return ['forge-lint', 'adia-lint'];
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
RULES.push({
|
|
1488
|
+
id: 'RAW-COLOR',
|
|
1489
|
+
severity: 'error',
|
|
1490
|
+
scope: 'shared',
|
|
1491
|
+
fileTypes: STYLE_EXT,
|
|
1492
|
+
match(text, path, opts = {}) {
|
|
1493
|
+
const isTokenish = optInTagsFor(opts.scopes).some((tag) => isFoundationCss(path, text, tag));
|
|
1494
|
+
if (isTokenish) return [];
|
|
1495
|
+
const out = [];
|
|
1496
|
+
for (const { lineNo, raw, stripped } of stripCommentLines(text, ext(path))) {
|
|
1497
|
+
if (!stripped.trim()) continue;
|
|
1498
|
+
for (const decl of stripped.split(';')) {
|
|
1499
|
+
const d = decl.trim();
|
|
1500
|
+
if (!d || d.startsWith('//') || d.startsWith('/*') || d.startsWith('*')) continue;
|
|
1501
|
+
if (decl.includes('var(') || decl.includes('light-dark(')) continue;
|
|
1502
|
+
if (RE.HEXCOLOR.test(decl) || RE.FUNCCOLOR.test(decl)) {
|
|
1503
|
+
out.push({
|
|
1504
|
+
line: lineNo,
|
|
1505
|
+
snippet: raw.trim().slice(0, 90),
|
|
1506
|
+
why: 'component CSS is token-only — replace the literal with var(--a-*) (foundation/token files excepted)',
|
|
1507
|
+
});
|
|
1508
|
+
break;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
return out;
|
|
1513
|
+
},
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
{
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
// Parity with the two retired scripts: forge-lint recognized only
|
|
1521
|
+
// `forge-lint: foundation`, adia-lint only `adia-lint: foundation` — never
|
|
1522
|
+
// either tag under the other script. Since this rule is shared, the active
|
|
1523
|
+
// caller scope (passed by the engine's `opts.scopes`) picks which single
|
|
1524
|
+
// tag applies; a run with both scopes active (not a real CLI shape today)
|
|
1525
|
+
// accepts either, the closest available reading.
|
|
1526
|
+
function optInTagsFor(scopes) {
|
|
1527
|
+
if (scopes && scopes.includes('forge-only') && !scopes.includes('consumer-only')) return ['forge-lint'];
|
|
1528
|
+
if (scopes && scopes.includes('consumer-only') && !scopes.includes('forge-only')) return ['adia-lint'];
|
|
1529
|
+
return ['forge-lint', 'adia-lint'];
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
RULES.push({
|
|
1534
|
+
id: 'RAW-PX',
|
|
1535
|
+
severity: 'warn',
|
|
1536
|
+
scope: 'shared',
|
|
1537
|
+
fileTypes: STYLE_EXT,
|
|
1538
|
+
match(text, path, opts = {}) {
|
|
1539
|
+
const isTokenish = optInTagsFor(opts.scopes).some((tag) => isFoundationCss(path, text, tag));
|
|
1540
|
+
if (isTokenish) return [];
|
|
1541
|
+
const out = [];
|
|
1542
|
+
for (const { lineNo, raw, stripped } of stripCommentLines(text, ext(path))) {
|
|
1543
|
+
if (!stripped.trim()) continue;
|
|
1544
|
+
if (stripped.trimStart().startsWith('@')) continue; // skip @media/@container/@scope
|
|
1545
|
+
for (const decl of stripped.split(';')) {
|
|
1546
|
+
if (decl.includes('/*')) continue; // author-annotated carve-out
|
|
1547
|
+
const matches = [...decl.matchAll(new RegExp(RE.PX_GE.source, 'g'))];
|
|
1548
|
+
if (matches.some((m) => Number(m[1]) >= 3)) {
|
|
1549
|
+
out.push({
|
|
1550
|
+
line: lineNo,
|
|
1551
|
+
snippet: raw.trim().slice(0, 90),
|
|
1552
|
+
why: 'no raw px >= 3 in component CSS — use var(--a-space-*); 1-2px hairlines exempt, annotate a deliberate exception with a comment',
|
|
1553
|
+
});
|
|
1554
|
+
break;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
return out;
|
|
1559
|
+
},
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
{
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
RULES.push({
|
|
1568
|
+
id: 'SCOPE-EXTENT',
|
|
1569
|
+
severity: 'error',
|
|
1570
|
+
scope: 'shared',
|
|
1571
|
+
fileTypes: STYLE_EXT,
|
|
1572
|
+
match(text) {
|
|
1573
|
+
const out = [];
|
|
1574
|
+
const re = new RegExp(RE.SCOPE_EXTENT.source, 'gs');
|
|
1575
|
+
let m;
|
|
1576
|
+
while ((m = re.exec(text))) {
|
|
1577
|
+
out.push({
|
|
1578
|
+
line: lineOf(text, m.index),
|
|
1579
|
+
snippet: ':scope { … width/height … }',
|
|
1580
|
+
why: "a primitive is size-agnostic — let the consumer own width/height; don't set extent on :scope",
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
return out;
|
|
1584
|
+
},
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
{
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
RULES.push({
|
|
1593
|
+
id: 'SHADOW-DOM',
|
|
1594
|
+
severity: 'error',
|
|
1595
|
+
scope: 'shared',
|
|
1596
|
+
fileTypes: [],
|
|
1597
|
+
match(text, path) {
|
|
1598
|
+
const out = [];
|
|
1599
|
+
for (const { lineNo, raw, stripped } of stripCommentLines(text, ext(path))) {
|
|
1600
|
+
if (!stripped.trim()) continue;
|
|
1601
|
+
if (stripped.includes('attachShadow')) {
|
|
1602
|
+
out.push({
|
|
1603
|
+
line: lineNo,
|
|
1604
|
+
snippet: raw.trim().slice(0, 90),
|
|
1605
|
+
why: 'adia-ui is light-DOM — never attachShadow; it breaks the token cascade + @scope',
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
return out;
|
|
1610
|
+
},
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
{
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
RULES.push({
|
|
1619
|
+
id: 'SLOTTED',
|
|
1620
|
+
severity: 'error',
|
|
1621
|
+
scope: 'shared',
|
|
1622
|
+
fileTypes: [],
|
|
1623
|
+
match(text, path) {
|
|
1624
|
+
const out = [];
|
|
1625
|
+
const marker = '::' + 'slotted(';
|
|
1626
|
+
for (const { lineNo, raw, stripped } of stripCommentLines(text, ext(path))) {
|
|
1627
|
+
if (!stripped.trim()) continue;
|
|
1628
|
+
if (stripped.includes(marker)) {
|
|
1629
|
+
out.push({
|
|
1630
|
+
line: lineNo,
|
|
1631
|
+
snippet: raw.trim().slice(0, 90),
|
|
1632
|
+
why: 'light DOM has no ::slotted — style projected content via :scope > [slot="x"]',
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
return out;
|
|
1637
|
+
},
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
{
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
// gh#2507 — the v1.0 renderer has no `attrs`/`slots` handling and no
|
|
1645
|
+
// `content` prop on any real component (`content` only ever appears as a
|
|
1646
|
+
// SLOT NAME, targeted via `"slot": "content"` — never as a node's own
|
|
1647
|
+
// literal key). A yaml example authored in the pre-v1.0 dialect wraps
|
|
1648
|
+
// props in an `attrs: {}` object, wraps children position in a `slots: {}`
|
|
1649
|
+
// map, and stuffs text into a bare `content` key instead of
|
|
1650
|
+
// `text`/`textContent`. The renderer silently drops all three.
|
|
1651
|
+
const STALE_DIALECT_KEYS = ['attrs', 'slots', 'content'];
|
|
1652
|
+
|
|
1653
|
+
// Walks the parsed a2ui JSON (an array of flat node objects) and reports
|
|
1654
|
+
// every node that carries one of the stale wrapper keys as its OWN
|
|
1655
|
+
// property. Recurses into any nested object/array values too, in case an
|
|
1656
|
+
// example nests node-shaped objects directly rather than only via the
|
|
1657
|
+
// flat id-array convention.
|
|
1658
|
+
function findStaleDialectNodes(value, path_ = '$') {
|
|
1659
|
+
const findings = [];
|
|
1660
|
+
if (Array.isArray(value)) {
|
|
1661
|
+
value.forEach((v, i) => findings.push(...findStaleDialectNodes(v, `${path_}[${i}]`)));
|
|
1662
|
+
return findings;
|
|
1663
|
+
}
|
|
1664
|
+
if (value && typeof value === 'object') {
|
|
1665
|
+
const hit = STALE_DIALECT_KEYS.filter((k) => Object.prototype.hasOwnProperty.call(value, k));
|
|
1666
|
+
if (hit.length > 0) {
|
|
1667
|
+
const id = typeof value.id === 'string' ? value.id : '(no id)';
|
|
1668
|
+
findings.push({ id, path: path_, keys: hit });
|
|
1669
|
+
}
|
|
1670
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1671
|
+
findings.push(...findStaleDialectNodes(v, `${path_}.${k}`));
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
return findings;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
RULES.push({
|
|
1679
|
+
id: 'STALE-DIALECT',
|
|
1680
|
+
severity: 'error',
|
|
1681
|
+
scope: 'shared',
|
|
1682
|
+
fileTypes: ['.yaml'],
|
|
1683
|
+
match(text) {
|
|
1684
|
+
const findings = [];
|
|
1685
|
+
for (const block of extractYamlA2uiBlocks(text)) {
|
|
1686
|
+
for (const hit of findStaleDialectNodes(block.nodes)) {
|
|
1687
|
+
findings.push({
|
|
1688
|
+
line: block.line,
|
|
1689
|
+
snippet: `node "${hit.id}" at ${hit.path} carries stale key(s): ${hit.keys.join(', ')}`.slice(0, 90),
|
|
1690
|
+
why: 'pre-v1.0 dialect (attrs:{} / slots:{} / bare content key) — rewrite to the flat v1.0 wire shape: props at top level, text/textContent per the component\'s own yaml, children arrays with a "slot" attribute per child',
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
return findings;
|
|
1695
|
+
},
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
export { CODE_EXT, STYLE_EXT, MARKUP_EXT, LINT_EXT, ext, isFoundationCss, stripCommentLines, lineOf, suppressionFor, RE, declarations, makeLinter, render, RULES };
|