@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.
Files changed (44) hide show
  1. package/.claude-plugin/plugin.json +3 -2
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +40 -0
  4. package/README.md +1 -1
  5. package/__init__.py +5 -0
  6. package/commands/demo-audit.md +1 -1
  7. package/commands/gen-ui-review.md +1 -1
  8. package/commands/package-release.md +1 -1
  9. package/commands/site-deployment.md +1 -1
  10. package/package.json +1 -1
  11. package/plugin.yaml +1 -1
  12. package/prompts/demo-audit.md +1 -1
  13. package/prompts/gen-ui-review.md +1 -1
  14. package/prompts/package-release.md +1 -1
  15. package/prompts/site-deployment.md +1 -1
  16. package/scripts/forge-lint.mjs +168 -0
  17. package/scripts/lint-rules.generated.mjs +1700 -0
  18. package/scripts/site-postwrite-derivation-gate +23 -127
  19. package/skills/a2ui-maintenance/SKILL.md +1 -1
  20. package/skills/a2ui-maintenance/references/data-model-reactivity.md +49 -29
  21. package/skills/a2ui-maintenance/references/pipeline-overview.md +58 -22
  22. package/skills/a2ui-maintenance/references/surface-lifecycle.md +14 -7
  23. package/skills/component-md-authoring/SKILL.md +116 -0
  24. package/skills/component-md-authoring/agents/openai.yaml +3 -0
  25. package/skills/demo-audit/references/visual-probe-triage.md +4 -1
  26. package/skills/gen-ui-review/SKILL.md +4 -1
  27. package/skills/gen-ui-review/references/loop-protocol.md +6 -5
  28. package/skills/package-release/references/changelog-discipline.md +6 -3
  29. package/skills/package-release/references/cut-procedure.md +61 -14
  30. package/skills/package-release/references/gates-catalog.md +6 -2
  31. package/skills/package-release/references/recovery-paths.md +6 -3
  32. package/skills/package-release/scripts/gate-roster.mjs +10 -7
  33. package/skills/package-release/scripts/release-pack.mjs +289 -17
  34. package/skills/primitive-authoring/references/anti-patterns.md +2 -2
  35. package/skills/primitive-authoring/references/api-contract.md +17 -4
  36. package/skills/primitive-authoring/references/authoring-cycle.md +1 -1
  37. package/skills/primitive-authoring/references/code-style.md +1 -1
  38. package/skills/primitive-authoring/references/common-gotchas.md +6 -6
  39. package/skills/primitive-authoring/references/form-control-sizing.md +22 -7
  40. package/skills/primitive-authoring/references/token-contract.md +5 -0
  41. package/skills/primitive-authoring/references/yaml-contract.md +132 -14
  42. package/skills/site-docs-authoring/SKILL.md +7 -9
  43. package/hooks/hooks.json +0 -44
  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 };