@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
@@ -114,13 +114,106 @@ stricter than the source is a defect, not documentation.
114
114
 
115
115
  Pipeline: `components.mjs` validates the shape per-yaml, cross-checks every
116
116
  referenced name against the full catalog on a full build, and forwards the
117
- lists onto `x-adiaui` → `catalog-a2ui_0_9.json`.
117
+ lists onto `x-adiaui` → `catalog-a2ui_1_0.json`.
118
118
  `scripts/build/derive-genui-catalog.mjs` then translates them into the
119
- canonical v1.0 key space (yaml `Segmented` → catalog `SegmentedControl`) on
120
- `base.json`/`adia-pack.json`, where the vendored `@genui/core` validator
121
- enforces them (`UNALLOWED_PARENT`/`UNALLOWED_CHILD`). Module-tier yamls
122
- (web-modules) carry constraints as dialect-catalog metadata only — modules
123
- have no v1.0 sidecar.
119
+ canonical v1.0 key space (yaml `Segmented` → catalog `Segmented`, canonical
120
+ per gh#2116) across the five opt-out-scoped catalogs (`adia.core.json`,
121
+ `adia.navigation.json`, `adia.data.json`, `adia.agent.json`,
122
+ `adia.shells.json`, gh#2211/ADR-0093), where the vendored `@genui/core`
123
+ validator enforces them (`UNALLOWED_PARENT`/`UNALLOWED_CHILD`). Module-tier
124
+ yamls (web-modules) now carry a v1.0 sidecar too and land in `adia.shells`.
125
+
126
+ Once a component earns a `component.md` (both `screenReader:` and
127
+ `behavioral:` authored, gh#2615), a non-empty `allowedParents`/
128
+ `allowedChildren` also generates an enforced lint rule (LLD-0016 §C4,
129
+ gh#2647): `scripts/build/gen-composition-rules.mjs` emits `scripts/lint/
130
+ rules/generated/composition/<name>.mjs`, which flags a markup file where
131
+ the tag nests under (or contains) a non-declared tag. Advisory (`warn`)
132
+ until a corpus-wide rollout promotes it (`npm run build:composition-rules`
133
+ / `check:composition-rules-fresh`).
134
+
135
+ ---
136
+
137
+ ## `examples:` field — a2ui example ids (semantic-id grammar, gh#2492)
138
+
139
+ Each `examples[].a2ui` block is a JSON array of component nodes (the same
140
+ `updateComponents.components[]` shape `.claude/docs/specs/a2ui-editor.md`
141
+ documents for the editor's live doc store). That spec's line "other
142
+ component ids are free-form (convention: `c-{n}` for generated ids)" governs
143
+ **editor-generated** ids only — ids the editor mints when a human drags a
144
+ component onto the canvas. It was never a license for **authored** ids
145
+ inside a component's own yaml examples, and treating it as one produced a
146
+ corpus-wide drift toward cryptic, positional ids (`q`, `k1`, `k1v`, `hdr`)
147
+ that don't describe what they are once an example has more than one or two
148
+ nodes — reported in gh#2492 against `blockquote.yaml`, `badge.yaml`,
149
+ `aside.yaml`, `alert.yaml`, and `anchor-bar.yaml` (the last four
150
+ corroborating it as a corpus-wide pattern, not a one-off).
151
+
152
+ **Grammar — authored `a2ui.examples[]` ids only:**
153
+
154
+ - **Kebab-case, role-descriptive.** The id names what the node IS or DOES in
155
+ the example, not its position in the array. `quote-body`, not `q` or
156
+ `node-2`.
157
+ - **Unique per example.** Scoped to one `examples[]` entry, not the whole
158
+ yaml — reusing `header` across two examples in the same file is fine;
159
+ reusing an id twice inside one example is not (the renderer's flat
160
+ `children: string[]` lookup would collide).
161
+ - **Compound ids read parent-then-role** for a node that belongs to a named
162
+ cluster: `kpi-revenue`, `kpi-revenue-value`, `kpi-revenue-label` — not
163
+ `k1`, `k1v`, `k1l`. This is what lets a reader studying the copy-paste-able
164
+ example understand the structure from the ids alone, without cross-
165
+ referencing the tree.
166
+ - **Exception — the root/wrapper id may stay short when the example has
167
+ exactly one top-level container and the short id is still a real word**,
168
+ e.g. `card`, `row`, `panel`, as long as every id it contains follows the
169
+ grammar. A single generic wrapper doesn't need `card-wrapper-root`; a
170
+ wrapper's *children* still do.
171
+ - Ids are internal wiring keys (`children: string[]` references, rendered
172
+ only as `data-a2ui-id`) — never user-visible copy — but they double as the
173
+ readable structure of the example a consumer studies, which is the whole
174
+ reason this grammar exists.
175
+
176
+ **Before / after** (`blockquote.yaml`'s `default` example, gh#2492's
177
+ reported repro):
178
+
179
+ ```jsonc
180
+ // before
181
+ [
182
+ { "id": "q", "component": "Blockquote", "cite": "…", "children": ["body"] },
183
+ { "id": "body", "component": "Text", "textContent": "Stay hungry. Stay foolish." }
184
+ ]
185
+
186
+ // after
187
+ [
188
+ { "id": "quote", "component": "Blockquote", "cite": "…", "children": ["quote-body"] },
189
+ { "id": "quote-body", "component": "Text", "textContent": "Stay hungry. Stay foolish." }
190
+ ]
191
+ ```
192
+
193
+ ```jsonc
194
+ // before (badge.yaml chart-dashboard, positional style mixed with semantic
195
+ // ids in the SAME example — k1/k1h/k1v alongside header-row/dash-title)
196
+ { "id": "k1", "component": "Card", "children": ["k1h", "k1v"] }
197
+
198
+ // after
199
+ { "id": "kpi-revenue", "component": "Card", "children": ["kpi-revenue-label", "kpi-revenue-value"] }
200
+ ```
201
+
202
+ **Enforcement**: the `EXAMPLE-ID-GRAMMAR` rule
203
+ (`scripts/lint/rules/shared/example-id-grammar.mjs`, gh#2649 — moved out of
204
+ the retired `scripts/verify/check-example-ids.mjs` into the shared lint
205
+ rule bank per LLD-0016 §C3) walks every yaml's `examples[].a2ui` nodes and
206
+ reports ids that fail the grammar (a bare 1-2 char id, a `^[a-z]\d+`
207
+ positional pattern like `k1`/`c2`, non-kebab-case, or a within-example
208
+ duplicate). gh#2492 Phase 2 swept the corpus-wide 526 violations across 48
209
+ files to zero; `npm run check:example-id-grammar` keeps that promotion
210
+ build-blocking in the `npm run check` chain (non-zero exit on any
211
+ violation) even though the rule's own bank-default severity is
212
+ `advisory` — a new example that violates the grammar now fails the build
213
+ immediately, not just on a future sweep. Known gap: the checker's
214
+ positional-pattern regex catches `letter+digits` (`k1`, `c2`) but not a
215
+ `letter-digit-letter-digit` chain like `g1i1` — out of scope for this
216
+ sweep (nothing flagged it), left for a future refinement.
124
217
 
125
218
  ---
126
219
 
@@ -235,7 +328,7 @@ manifest format. ADR-0066 refines it three ways:
235
328
 
236
329
  - **`origin: primitive | module` on every L0 entry** — both YAML source
237
330
  roots (web-components primitives AND web-modules composites) compile into
238
- the same `catalog-a2ui_0_9.json` through one shared contract;
331
+ the same `catalog-a2ui_1_0.json` through one shared contract;
239
332
  `derive-catalog-tiers.mjs` stamps which root an entry came from onto
240
333
  `tier-index.json`. A module is legitimately a member of TWO rungs at two
241
334
  grains: its component API (props/events/slots) is L0, its assembled shell
@@ -283,6 +376,31 @@ props:
283
376
  - ghost
284
377
  ```
285
378
 
379
+ ### Synthesized universal props — `slot` / `hidden` / `ariaLive` / `traits`
380
+
381
+ `deriveProps()` (`scripts/build/derive-genui-catalog.mjs`) prepends three
382
+ props to every component's generated catalog schema before it ever reads a
383
+ yaml's own `props:` block: `slot: {type: string}`, `hidden: {type:
384
+ boolean}`, `ariaLive: {type: string}` (REQ-013 accessibility pair, gh#1353).
385
+ **No yaml SoT declares any of these** — they're synthesized, not authored,
386
+ and a sidecar that ever DOES declare one of these keys itself simply
387
+ overwrites the synthesized definition.
388
+
389
+ **Decided but not yet implemented:** ADR-0097
390
+ (`docs/ops/adr/adr-0097-traits-as-a2ui-synthesized-universal-prop.md`,
391
+ ratified via PR #2524) rules that `traits: {type: string}` becomes a fourth
392
+ member of this synthesized set, carrying the same space-separated grammar
393
+ as the existing HTML `[traits="…"]` declarative attribute
394
+ (`.claude/docs/specs/traits.md`, "Method 3" — `"ripple confetti-burst"`,
395
+ not a JSON array) — making `traits` legal on every component's A2UI wire
396
+ schema with no renderer change, and, once wired, checked by
397
+ `catalog-validator.js` against the live trait registry
398
+ (`packages/web-components/traits/_catalog.json`) with a **hard FAIL on an
399
+ unknown trait name** (the same rejection behavior the raw HTML mechanism
400
+ already has). As of this writing `deriveProps()` still synthesizes only the
401
+ three props above and `catalog-validator.js` has no trait-name check —
402
+ gh#2513 tracks the build.
403
+
286
404
  ### `required: true` field
287
405
 
288
406
  **When to use**: only for props where omitting them makes the component meaningless or inaccessible.
@@ -397,9 +515,9 @@ section IS that call. The family stays `data-*`, undeclared in any yaml,
397
515
  with its contract documented at the shared source instead: `core/form.js`'s
398
516
  own header JSDoc (the mixin all consumers share) and the canonical
399
517
  `form-system` pattern doc (`packages/web-components/patterns/form-system/
400
- form-system.examples.html`, mirrored at `site-a2ui/pages/
401
- site__patterns__form-system.a2ui.json`) — both already enumerate the full
402
- family with a worked example. A future architectural pass that wants to
518
+ form-system.examples.html`, rendered directly at `/site/patterns/form-system`)
519
+ — both already enumerate the full family with a worked example. A future
520
+ architectural pass that wants to
403
521
  promote this to a declared per-component contract needs its own ADR (the
404
522
  scope is a mixin-wide contract change, not a small-ticket edit); nothing
405
523
  here forecloses that, it only records today's call.
@@ -427,15 +545,15 @@ The build:
427
545
  **Downstream of the sidecars — two more derived artifacts (gh#970's release-PR stop):**
428
546
 
429
547
  ```bash
430
- node scripts/build/derive-genui-catalog.mjs # genui base.json + adia-pack.json derive FROM the sidecars
548
+ node scripts/build/derive-genui-catalog.mjs # genui's five opt-out-scoped catalogs derive FROM the sidecars
431
549
  npm run check:genui-catalog # the drift gate that fails PR CI if you skip the above
432
550
  ```
433
551
 
434
552
  Any yaml change that alters a sidecar's prop schemas STALES the genui
435
553
  catalog. The main regen chain (`harvest:chunks` → `build:embeddings:chunks`
436
- → `site-a2ui --stale` → `build:patterns-index`) does NOT cover it — it's a
437
- separate derivation with its own gate, and skipping it passes every local
438
- check that isn't `check:genui-catalog` itself.
554
+ → `build:patterns-index`) does NOT cover it — it's a separate derivation
555
+ with its own gate, and skipping it passes every local check that isn't
556
+ `check:genui-catalog` itself.
439
557
 
440
558
  ---
441
559
 
@@ -47,21 +47,19 @@ skill routes to it and enforces it, it does not restate it.
47
47
  ## Verify after any change
48
48
 
49
49
  - `npm run check:links` — intra-repo links across the touched pages.
50
- - `node scripts/build/site-a2ui.mjs --page <route>` for every page whose
51
- **markup** changed regenerates the compiled A2UI artifact the
52
- docs-transpiler produces from this source; a CSS-only fix needs no regen.
50
+ (Before ADR-0072 Decision 2 / gh#2410 retired site-a2ui, a markup change
51
+ also needed `node scripts/build/site-a2ui.mjs --page <route>` to
52
+ regenerate a compiled A2UI artifact. The docs site now renders each
53
+ route's fragment directly — no compile step, no artifact to regenerate.)
53
54
  - `npm run verify:contrast` for any new or changed token used for text or
54
55
  link contrast (that gate covers text/link AA pairs only — a non-text 3:1
55
56
  claim needs a hand-proved, cited ratio; no mechanical runner exists).
56
57
  - `npm run check:lightningcss-build` after any CSS change (`site/site.css`
57
58
  or a component's own `.css`).
58
59
  - **Visually verify any `alert-ui`/rich-slotted-content change in a
59
- browser after regenerating** — a `valid:true` transpiler result and a
60
- correct raw-HTML source do not prove the rendered order is correct;
61
- the compiled `site-a2ui/pages/*.json` artifact is what's served, and
62
- a wrong container tag (`<span>` instead of `<div>`) silently reorders
63
- content while still passing schema validation. This is not optional
64
- for this one content shape.
60
+ browser** — a wrong container tag (`<span>` instead of `<div>`) still
61
+ silently reorders mixed inline content on render even without a compile
62
+ step; this is not optional for this one content shape.
65
63
 
66
64
  A check that cannot run (missing script, no network for a build step) is a
67
65
  named blocker in the report — flag it and stop; never mark the page done on
package/hooks/hooks.json DELETED
@@ -1,44 +0,0 @@
1
- {
2
- "hooks": {
3
- "PreToolUse": [
4
- {
5
- "matcher": "Write|Edit",
6
- "hooks": [
7
- {
8
- "type": "command",
9
- "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/sidecar-prewrite-guard\" --hook"
10
- }
11
- ]
12
- },
13
- {
14
- "matcher": "Bash",
15
- "hooks": [
16
- {
17
- "type": "command",
18
- "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/release-pretag-docs-gate\" --hook",
19
- "timeout": 90
20
- }
21
- ]
22
- }
23
- ],
24
- "PostToolUse": [
25
- {
26
- "matcher": "Write|Edit",
27
- "hooks": [
28
- {
29
- "type": "command",
30
- "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/forge-lint\" --hook"
31
- },
32
- {
33
- "type": "command",
34
- "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/demo-postwrite-pattern-gate\" --hook"
35
- },
36
- {
37
- "type": "command",
38
- "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/site-postwrite-derivation-gate\" --hook"
39
- }
40
- ]
41
- }
42
- ]
43
- }
44
- }
@@ -1,315 +0,0 @@
1
- #!/usr/bin/env python3
2
- """forge-lint — advisory authoring-smell checker for the adia-ui FRAMEWORK source (maintainer slice).
3
-
4
- This is the producer-side counterpart to the consumer plugin's app-lint: it checks the framework's
5
- OWN component source as a maintainer authors it (primitives in packages/web-components, composites in
6
- packages/web-modules) for the framework's structural invariants — light-DOM only (no attachShadow,
7
- no ::slotted), component CSS is `@scope`-wrapped and token-only (no raw colors, no raw px >= 3, no
8
- extent on :scope, no dead `--a-font`), property definitions are well-formed (no `default: true`, no
9
- `attr:` typo), composites use `*-ui` primitives (no native-primitive leak), and no retired shell
10
- shapes (ADR-0024 data-attribute forms). Foundation/token sheets (a styles/ or tokens/ dir, a
11
- tokens/theme/host/palette/… stem, or `/* forge-lint: foundation */`) are exempt from the CSS-literal
12
- and scope checks.
13
-
14
- It deliberately does NOT carry the consumer/app traps (SSR double-router, top-level kit import,
15
- hardcoded overlay open) — those are about CONSUMING the framework in an app and live in the
16
- consumer plugin. forge-lint judges only authoring structure, never whether a component is good,
17
- accessible, or on-spec — that lives in the skills (adia-ui-authoring / -a2ui / -gen-review / -dogfood).
18
- A clean forge-lint says "no structural tells," never "this is right."
19
-
20
- Shared core: the regex bank below (RAW-COLOR/PX, SCOPE-EXTENT, NATIVE-PRIMITIVE, LEGACY-SHELL,
21
- the _is_foundation_css exemption) is mirrored in the sibling CONSUMER plugin's
22
- adia-ui-factory/scripts/adia-lint. They are deliberate VENDORED copies — the catalog forbids
23
- cross-plugin imports (each plugin installs copy-alone) — so any change to a shared rule must be
24
- reconciled in BOTH files. forge-lint drops the consumer/app traps (SSR double-router, top-level
25
- import, hardcoded overlay open) and adds MISSING-SCOPE; that divergence is the point.
26
-
27
- Usage:
28
- forge-lint <file>... # lint files; exit 1 if any smell found, else 0
29
- forge-lint - # lint stdin as a generic source file
30
- forge-lint --hook # PostToolUse hook mode: read event JSON on stdin, lint the written
31
- # source file, print advisory findings, ALWAYS exit 0 (never blocks)
32
- forge-lint selftest # run built-in fixtures (seeded smells + clean files + the hook exit-0 invariant)
33
- Stdlib only (Python 3.8+).
34
- """
35
- import json
36
- import os
37
- import re
38
- import sys
39
-
40
- CODE_EXT = (".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx")
41
- STYLE_EXT = (".css",)
42
- MARKUP_EXT = (".html", ".htm", ".vue", ".svelte", ".astro", ".tsx", ".jsx")
43
- LINT_EXT = tuple(sorted(set(CODE_EXT + STYLE_EXT + MARKUP_EXT)))
44
-
45
- HEXCOLOR = re.compile(r"#[0-9a-fA-F]{3,8}\b")
46
- FUNCCOLOR = re.compile(r"\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\s*\(")
47
- DEAD_FONT = re.compile(r"var\(\s*--a-font\s*[,)]")
48
- SCOPE_EXTENT = re.compile(
49
- r":scope(?:\[[^\]]*\])?\s*\{[^{}]*?\b(?:width|height|inline-size|block-size)\s*:", re.S)
50
- BOOL_TRUE = re.compile(r"\bdefault:\s*true\b")
51
- ATTR_TYPO = re.compile(r"\battr:\s*['\"]")
52
- NATIVE_PRIMITIVE = re.compile(r"<(?:button|input|select|textarea|dialog)(?![\w-])") # raw native, not a *-ui
53
- LEGACY_SHELL = re.compile(
54
- r"data-chat-(?:messages|input|empty|name)|data-editor-body|data-canvas\b|data-sidebar="
55
- r"|data-pane-(?:side|grow)|<aside-ui\b|<dialog\s+data-command") # retired shell shapes (ADR-0024)
56
- PX_GE = re.compile(r"(?<![\w.-])(\d+)px\b") # integer px; fractional (1.5px) deliberately not matched
57
- SELECTOR_RULE = re.compile(r"(?m)^\s*[.#:\[&\w][^{}\n]*\{") # a selector opening a rule block
58
- FOUNDATION_OPT_IN = re.compile(r"forge-lint:\s*foundation", re.I)
59
- # Genuine token/foundation SHEETS are exempt from the CSS-literal + scope checks — matched by exact
60
- # stem or a styles/tokens dir, NOT a path substring (so a `color-picker` component is still linted).
61
- FOUNDATION_STEMS = {"tokens", "token", "theme", "themes", "foundation", "foundations",
62
- "palette", "palettes", "host", "reset", "resets", "scheme", "schemes",
63
- "color", "colors"}
64
-
65
-
66
- def _ext(path):
67
- return os.path.splitext(path or "")[1].lower()
68
-
69
-
70
- def _is_foundation_css(path, text):
71
- """A genuine token/foundation sheet — exempt from the CSS-literal + scope checks."""
72
- segs = (path or "").replace("\\", "/").split("/")
73
- if "styles" in segs or "tokens" in segs:
74
- return True
75
- stem = os.path.splitext(segs[-1])[0].lower() if segs else ""
76
- if stem in FOUNDATION_STEMS:
77
- return True
78
- return bool(FOUNDATION_OPT_IN.search(text[:1000]))
79
-
80
-
81
- def lint_text(text, path=""):
82
- """Return a list of (NAME, line, snippet, why) advisory findings."""
83
- ext = _ext(path)
84
- findings = []
85
- is_tokenish = _is_foundation_css(path, text)
86
-
87
- # Comment stripping (gh#976): the v0.8.34 session logged ~30 RAW-PX /
88
- # RAW-COLOR / LEGACY-SHELL findings whose "literal" lived inside a
89
- # comment (px values and hex codes QUOTED in prose). Track /* */ state
90
- # across lines and match rules against the code that remains; JS line
91
- # comments (//) are stripped for CODE_EXT files too.
92
- in_block = False
93
-
94
- def _strip_comments(raw):
95
- nonlocal in_block
96
- out, j, n = [], 0, len(raw)
97
- while j < n:
98
- if in_block:
99
- k = raw.find("*/", j)
100
- if k == -1:
101
- return "".join(out)
102
- in_block = False
103
- j = k + 2
104
- continue
105
- k = raw.find("/*", j)
106
- m = raw.find("//", j) if ext in CODE_EXT else -1
107
- if m != -1 and (k == -1 or m < k):
108
- out.append(raw[j:m])
109
- return "".join(out)
110
- if k == -1:
111
- out.append(raw[j:])
112
- return "".join(out)
113
- out.append(raw[j:k])
114
- in_block = True
115
- j = k + 2
116
- return "".join(out)
117
-
118
- for i, line in enumerate(text.splitlines(), 1):
119
- s = line.strip()[:90]
120
- line = _strip_comments(line)
121
- if not line.strip():
122
- continue
123
- if "attachShadow" in line:
124
- findings.append(("SHADOW-DOM", i, s,
125
- "adia-ui is light-DOM — never attachShadow; it breaks the token cascade + @scope"))
126
- if "::slotted(" in line:
127
- findings.append(("SLOTTED", i, s,
128
- "light DOM has no ::slotted — style projected content via :scope > [slot=\"x\"]"))
129
- if ext in STYLE_EXT:
130
- if DEAD_FONT.search(line):
131
- findings.append(("DEAD-FONT-TOKEN", i, s,
132
- "--a-font is not a real token (resolves to UA serif) — use var(--a-font-family-ui)"))
133
- if not is_tokenish:
134
- for decl in line.split(";"): # per-declaration: a literal on a line that also has a var() still counts
135
- d = decl.strip()
136
- if not d or d.startswith(("//", "/*", "*")) or "var(" in decl or "light-dark(" in decl:
137
- continue
138
- if HEXCOLOR.search(decl) or FUNCCOLOR.search(decl):
139
- findings.append(("RAW-COLOR", i, s,
140
- "component CSS is token-only — replace the literal with var(--a-*) (foundation/token files excepted)"))
141
- break
142
- if not is_tokenish and not line.lstrip().startswith("@"): # skip @media/@container/@scope at-rules
143
- for decl in line.split(";"):
144
- if "/*" in decl: # author-annotated carve-out
145
- continue
146
- if any(int(v) >= 3 for v in PX_GE.findall(decl)):
147
- findings.append(("RAW-PX", i, s,
148
- "no raw px >= 3 in component CSS — use var(--a-space-*); 1-2px hairlines exempt, annotate a deliberate exception with a comment"))
149
- break
150
- if ext in CODE_EXT:
151
- if BOOL_TRUE.search(line):
152
- findings.append(("BOOL-DEFAULT-TRUE", i, s,
153
- "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"))
154
- if ATTR_TYPO.search(line):
155
- findings.append(("ATTR-TYPO", i, s,
156
- "did you mean `attribute:`? `attr:` is silently ignored in a property definition"))
157
- if (ext in MARKUP_EXT and "slot=" not in line and not s.startswith(("<!--", "//", "*", "/*"))
158
- and NATIVE_PRIMITIVE.search(line)):
159
- findings.append(("NATIVE-PRIMITIVE", i, s,
160
- "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"))
161
- if LEGACY_SHELL.search(line):
162
- findings.append(("LEGACY-SHELL", i, s,
163
- "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)"))
164
-
165
- if ext in STYLE_EXT:
166
- for m in SCOPE_EXTENT.finditer(text):
167
- ln = text.count("\n", 0, m.start()) + 1
168
- findings.append(("SCOPE-EXTENT", ln, ":scope { … width/height … }",
169
- "a primitive is size-agnostic — let the consumer own width/height; don't set extent on :scope"))
170
- # MISSING-SCOPE (maintainer-authoring): a component sheet with rules but no @scope wrapper.
171
- if not is_tokenish and "@scope" not in text and SELECTOR_RULE.search(text):
172
- findings.append(("MISSING-SCOPE", 1, "(file has CSS rules but no @scope block)",
173
- "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 */)"))
174
-
175
- findings.sort(key=lambda f: (f[1], f[0]))
176
- return findings
177
-
178
-
179
- def _render(path, findings):
180
- out = [f"forge-lint: {len(findings)} structural smell(s) in {path or '<stdin>'}"]
181
- for name, ln, snip, why in findings:
182
- out.append(f" [{name}] line {ln}: {snip}")
183
- out.append(f" → {why}")
184
- return "\n".join(out)
185
-
186
-
187
- def _lint_path(path):
188
- try:
189
- with open(path, encoding="utf-8", errors="replace") as f:
190
- return lint_text(f.read(), path)
191
- except OSError as err:
192
- msg = getattr(err, "strerror", None) or str(err)
193
- return [("READ-ERROR", 1, path[:90], f"failed to read file: {msg}")]
194
-
195
-
196
- def _hook():
197
- try:
198
- event = json.load(sys.stdin)
199
- except (json.JSONDecodeError, ValueError):
200
- return 0
201
- ti = event.get("tool_input", {}) or {}
202
- path = ti.get("file_path", "") or ""
203
- norm = path.replace("\\", "/")
204
- if "packages/web-components/" not in norm and "packages/web-modules/" not in norm:
205
- return 0 # scope anchor: framework component source only (the docstring's claim)
206
- if _ext(path) not in LINT_EXT:
207
- return 0 # only component source; stay quiet otherwise
208
- text = ti.get("content")
209
- if text is None:
210
- if not os.path.isfile(path):
211
- return 0
212
- try:
213
- with open(path, encoding="utf-8", errors="replace") as f:
214
- text = f.read()
215
- except OSError:
216
- return 0
217
- findings = lint_text(text, path)
218
- if findings:
219
- # PostToolUse exit-0 stdout is NOT fed to the model — only structured
220
- # JSON reaches it. additionalContext delivers the advisory repair loop
221
- # while preserving the never-block invariant.
222
- context = (
223
- _render(path, findings)
224
- + "\n (forge-lint advisory — adia-ui framework authoring smells; the skills own the judgment)"
225
- )
226
- print(json.dumps({
227
- "hookSpecificOutput": {
228
- "hookEventName": "PostToolUse",
229
- "additionalContext": context,
230
- }
231
- }))
232
- return 0 # NEVER block
233
-
234
-
235
- def _selftest():
236
- """Built-in fixtures: each seeded smell must fire, clean files must stay quiet, --hook must exit 0."""
237
- import io
238
- cases = [
239
- ("packages/web-components/components/x/x.css",
240
- "@scope (x) {\n"
241
- " :scope { color:#f00; width:100%; font-family:var(--a-font); padding:24px; }\n"
242
- " :scope > [slot=a]::slotted(p) { margin:0; }\n"
243
- "}",
244
- {"RAW-COLOR", "SCOPE-EXTENT", "DEAD-FONT-TOKEN", "SLOTTED", "RAW-PX"}),
245
- ("packages/web-components/styles/theme.css", ":root { --a-bg: light-dark(#fff, #000); }", set()),
246
- ("packages/web-components/components/y/y.css",
247
- ".y-thing { margin: 0; }\n.y-thing__row { gap: 4px; }",
248
- {"MISSING-SCOPE"}),
249
- ("packages/web-components/components/z/z.js",
250
- "class Z extends UIElement {\n static props = { open: { default: true }, label: { attr: 'label' } };\n"
251
- " connected() { this.attachShadow({ mode: 'open' }); }\n}",
252
- {"SHADOW-DOM", "BOOL-DEFAULT-TRUE", "ATTR-TYPO"}),
253
- ("packages/web-components/components/clean/clean.js",
254
- "import { UIElement } from '../../core/element.js';\n"
255
- "class Clean extends UIElement { connected() { this.innerHTML = '<col-ui></col-ui>'; } }",
256
- set()),
257
- ("packages/web-modules/shell/markup.html",
258
- "<admin-shell>\n <button>Save</button>\n <input type=\"text\">\n <button-ui slot=\"trigger\">ok</button-ui>\n</admin-shell>",
259
- {"NATIVE-PRIMITIVE"}),
260
- ("packages/web-modules/chat/legacy.html",
261
- "<chat-shell>\n <section data-chat-messages></section>\n <chat-input-ui data-chat-input></chat-input-ui>\n</chat-shell>",
262
- {"LEGACY-SHELL"}),
263
- ]
264
- ok = True
265
- for name, text, expected in cases:
266
- got = {f[0] for f in lint_text(text, name)}
267
- if expected - got:
268
- ok = False
269
- print(f"selftest: {name} MISSING {sorted(expected - got)} (got {sorted(got)})", file=sys.stderr)
270
- if not expected and got:
271
- ok = False
272
- print(f"selftest: {name} expected clean, got {sorted(got)}", file=sys.stderr)
273
- saved_in, saved_out = sys.stdin, sys.stdout # never-block invariant: --hook exits 0 even on smelly input
274
- try:
275
- sys.stdin = io.StringIO(json.dumps({"tool_input": {"file_path": "c.css",
276
- "content": ".a { color:#f00; }"}}))
277
- sys.stdout = io.StringIO()
278
- rc = _hook()
279
- hook_out = sys.stdout.getvalue()
280
- finally:
281
- sys.stdin, sys.stdout = saved_in, saved_out
282
- if rc != 0:
283
- ok = False
284
- print("selftest: --hook did not exit 0 on smelly input", file=sys.stderr)
285
- if rc == 0 and hook_out and '"hookSpecificOutput"' not in hook_out:
286
- ok = False
287
- print("selftest: --hook stdout is not structured hookSpecificOutput JSON", file=sys.stderr)
288
- print("selftest: PASS" if ok else "selftest: FAIL")
289
- return 0 if ok else 1
290
-
291
-
292
- def main(argv):
293
- if argv and argv[0] == "selftest":
294
- return _selftest()
295
- if "--hook" in argv:
296
- return _hook()
297
- args = [a for a in argv if a == "-" or not a.startswith("-")]
298
- if not args:
299
- print(__doc__.strip().split("\n\n", 1)[0], file=sys.stderr)
300
- return 2
301
- total = 0
302
- for path in args:
303
- if path == "-":
304
- findings = lint_text(sys.stdin.read(), "<stdin>")
305
- path = "<stdin>"
306
- else:
307
- findings = _lint_path(path)
308
- if findings:
309
- total += len(findings)
310
- print(_render(path, findings))
311
- return 1 if total else 0
312
-
313
-
314
- if __name__ == "__main__":
315
- sys.exit(main(sys.argv[1:]))