@adia-ai/adia-ui-forge 0.8.56 → 0.8.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +16 -0
- package/README.md +1 -1
- package/commands/demo-audit.md +1 -1
- package/commands/gen-ui-review.md +1 -1
- package/commands/package-release.md +1 -1
- package/commands/site-deployment.md +1 -1
- package/package.json +1 -1
- package/plugin.yaml +1 -1
- package/prompts/demo-audit.md +1 -1
- package/prompts/gen-ui-review.md +1 -1
- package/prompts/package-release.md +1 -1
- package/prompts/site-deployment.md +1 -1
- package/scripts/forge-lint.mjs +168 -0
- package/scripts/lint-rules.generated.mjs +1700 -0
- package/scripts/site-postwrite-derivation-gate +23 -127
- package/skills/a2ui-maintenance/references/pipeline-overview.md +12 -10
- package/skills/demo-audit/references/visual-probe-triage.md +4 -1
- package/skills/gen-ui-review/SKILL.md +4 -1
- package/skills/gen-ui-review/references/loop-protocol.md +6 -5
- package/skills/package-release/references/changelog-discipline.md +6 -3
- package/skills/package-release/references/cut-procedure.md +15 -13
- package/skills/package-release/references/gates-catalog.md +5 -2
- package/skills/package-release/references/recovery-paths.md +6 -3
- package/skills/package-release/scripts/gate-roster.mjs +10 -7
- package/skills/primitive-authoring/references/common-gotchas.md +6 -6
- package/skills/primitive-authoring/references/yaml-contract.md +27 -14
- package/skills/site-docs-authoring/SKILL.md +7 -9
- package/hooks/hooks.json +0 -44
- package/scripts/forge-lint +0 -315
|
@@ -123,6 +123,15 @@ per gh#2116) across the five opt-out-scoped catalogs (`adia.core.json`,
|
|
|
123
123
|
validator enforces them (`UNALLOWED_PARENT`/`UNALLOWED_CHILD`). Module-tier
|
|
124
124
|
yamls (web-modules) now carry a v1.0 sidecar too and land in `adia.shells`.
|
|
125
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
|
+
|
|
126
135
|
---
|
|
127
136
|
|
|
128
137
|
## `examples:` field — a2ui example ids (semantic-id grammar, gh#2492)
|
|
@@ -190,14 +199,18 @@ reported repro):
|
|
|
190
199
|
{ "id": "kpi-revenue", "component": "Card", "children": ["kpi-revenue-label", "kpi-revenue-value"] }
|
|
191
200
|
```
|
|
192
201
|
|
|
193
|
-
**Enforcement**: `
|
|
194
|
-
`
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
201
214
|
positional-pattern regex catches `letter+digits` (`k1`, `c2`) but not a
|
|
202
215
|
`letter-digit-letter-digit` chain like `g1i1` — out of scope for this
|
|
203
216
|
sweep (nothing flagged it), left for a future refinement.
|
|
@@ -502,9 +515,9 @@ section IS that call. The family stays `data-*`, undeclared in any yaml,
|
|
|
502
515
|
with its contract documented at the shared source instead: `core/form.js`'s
|
|
503
516
|
own header JSDoc (the mixin all consumers share) and the canonical
|
|
504
517
|
`form-system` pattern doc (`packages/web-components/patterns/form-system/
|
|
505
|
-
form-system.examples.html`,
|
|
506
|
-
|
|
507
|
-
|
|
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
|
|
508
521
|
promote this to a declared per-component contract needs its own ADR (the
|
|
509
522
|
scope is a mixin-wide contract change, not a small-ticket edit); nothing
|
|
510
523
|
here forecloses that, it only records today's call.
|
|
@@ -538,9 +551,9 @@ npm run check:genui-catalog # the drift gate that fails PR CI
|
|
|
538
551
|
|
|
539
552
|
Any yaml change that alters a sidecar's prop schemas STALES the genui
|
|
540
553
|
catalog. The main regen chain (`harvest:chunks` → `build:embeddings:chunks`
|
|
541
|
-
→ `
|
|
542
|
-
|
|
543
|
-
|
|
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.
|
|
544
557
|
|
|
545
558
|
---
|
|
546
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
|
-
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
}
|
package/scripts/forge-lint
DELETED
|
@@ -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:]))
|