delimit-cli 4.3.4 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/README.md +25 -18
- package/adapters/codex-security.js +64 -0
- package/adapters/codex-skill.js +78 -0
- package/adapters/cursor-rules.js +73 -0
- package/bin/delimit-setup.js +23 -0
- package/gateway/ai/backends/governance_bridge.py +168 -2
- package/gateway/ai/backends/memory_bridge.py +218 -3
- package/gateway/ai/backends/tools_design.py +563 -83
- package/gateway/ai/backends/tools_infra.py +21 -7
- package/gateway/ai/backends/tools_real.py +3 -1
- package/gateway/ai/content_grounding/__init__.py +98 -0
- package/gateway/ai/content_grounding/build.py +350 -0
- package/gateway/ai/content_grounding/consume.py +280 -0
- package/gateway/ai/content_grounding/features.py +218 -0
- package/gateway/ai/content_grounding/fixtures/fail/01_missing_evidence.json +9 -0
- package/gateway/ai/content_grounding/fixtures/fail/02_unknown_evidence_prefix.json +9 -0
- package/gateway/ai/content_grounding/fixtures/fail/03_banned_comparative.json +17 -0
- package/gateway/ai/content_grounding/fixtures/fail/04_banned_adoption.json +17 -0
- package/gateway/ai/content_grounding/fixtures/fail/05_aggregate_no_numeric.json +17 -0
- package/gateway/ai/content_grounding/fixtures/fail/06_unversioned_inference_rule.json +18 -0
- package/gateway/ai/content_grounding/fixtures/pass/01_feature_shipped.json +18 -0
- package/gateway/ai/content_grounding/fixtures/pass/02_aggregate_claim.json +23 -0
- package/gateway/ai/content_grounding/fixtures/pass/03_attestation.json +16 -0
- package/gateway/ai/content_grounding/schemas/claim.schema.json +40 -0
- package/gateway/ai/content_grounding/schemas/event.schema.json +23 -0
- package/gateway/ai/content_grounding/schemas.py +276 -0
- package/gateway/ai/content_grounding/telemetry.py +221 -0
- package/gateway/ai/governance.py +89 -0
- package/gateway/ai/hot_reload.py +148 -7
- package/gateway/ai/inbox_drafts/__init__.py +61 -0
- package/gateway/ai/inbox_drafts/registry.py +412 -0
- package/gateway/ai/inbox_drafts/schema.py +374 -0
- package/gateway/ai/inbox_executor.py +565 -0
- package/gateway/ai/ledger_manager.py +1483 -25
- package/gateway/ai/license_core.py +3 -1
- package/gateway/ai/mcp_bridge.py +1 -1
- package/gateway/ai/reddit_proxy.py +8 -6
- package/gateway/ai/server.py +451 -9
- package/gateway/ai/supabase_sync.py +47 -7
- package/gateway/ai/swarm.py +1 -1
- package/gateway/ai/workers/executor.py +1 -1
- package/gateway/core/diff_engine_v2.py +45 -10
- package/gateway/core/zero_spec/express_extractor.py +1 -1
- package/lib/delimit-template.js +5 -0
- package/package.json +1 -1
|
@@ -70,30 +70,111 @@ def _read_text(path: Path, limit: int = 200_000) -> str:
|
|
|
70
70
|
_CSS_VAR_RE = re.compile(r"--([a-zA-Z0-9_-]+)\s*:\s*([^;]+);")
|
|
71
71
|
_MEDIA_QUERY_RE = re.compile(r"@media[^{]*\(\s*(?:min|max)-width\s*:\s*([^)]+)\)")
|
|
72
72
|
|
|
73
|
+
# LED-1010: selector-aware extraction. Captures the selector (e.g. `:root`,
|
|
74
|
+
# `.dark`, `[data-theme="dark"]`) that owns each custom property block so
|
|
75
|
+
# light/dark variants of the same token name don't silently dedupe into one.
|
|
76
|
+
_CSS_BLOCK_RE = re.compile(
|
|
77
|
+
r"(?P<selector>[^{}@\n][^{}]{0,200})\{(?P<body>[^{}]*)\}",
|
|
78
|
+
re.DOTALL,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _mode_from_selector(selector: str) -> str:
|
|
83
|
+
"""Best-effort mode guess from a CSS selector: 'dark' / 'light' / 'base'."""
|
|
84
|
+
s = selector.strip().lower()
|
|
85
|
+
if any(t in s for t in (".dark", "[data-theme=\"dark\"]", "[data-mode=\"dark\"]", "prefers-color-scheme: dark")):
|
|
86
|
+
return "dark"
|
|
87
|
+
if any(t in s for t in (".light", "[data-theme=\"light\"]", "[data-mode=\"light\"]", "prefers-color-scheme: light")):
|
|
88
|
+
return "light"
|
|
89
|
+
if s in (":root", "html", "body", "*"):
|
|
90
|
+
return "base"
|
|
91
|
+
return "scoped"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# LED-1010: common domain/semantic token prefixes. Anything starting with these
|
|
95
|
+
# reads as application-meaning rather than theme-primitive, and belongs in the
|
|
96
|
+
# `semantic` bucket, not `other`. Downstream generators need this split to know
|
|
97
|
+
# which tokens are safe to remap vs which carry app meaning.
|
|
98
|
+
_SEMANTIC_PREFIXES = (
|
|
99
|
+
"score-", "status-", "blur-", "price-", "rank-", "tier-", "risk-",
|
|
100
|
+
"badge-", "level-", "alert-",
|
|
101
|
+
)
|
|
102
|
+
|
|
73
103
|
|
|
74
|
-
def
|
|
75
|
-
"""
|
|
104
|
+
def _token_taxonomy(name: str) -> str:
|
|
105
|
+
"""Classify a token name as primitive | semantic | other."""
|
|
106
|
+
n = name.lower().lstrip("-")
|
|
107
|
+
if any(n.startswith(p) for p in _SEMANTIC_PREFIXES):
|
|
108
|
+
return "semantic"
|
|
109
|
+
# Core theme primitives
|
|
110
|
+
if any(n.startswith(p) for p in (
|
|
111
|
+
"color-", "bg-", "text-", "border-", "fill-", "stroke-",
|
|
112
|
+
"accent", "primary", "secondary", "muted", "foreground",
|
|
113
|
+
"background", "surface", "ring-", "input-",
|
|
114
|
+
"space-", "spacing-", "gap-", "size-", "radius-",
|
|
115
|
+
"font-", "leading-", "tracking-",
|
|
116
|
+
)):
|
|
117
|
+
return "primitive"
|
|
118
|
+
return "other"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _extract_css_variables(text: str, source: str = "") -> Dict[str, List[Dict[str, str]]]:
|
|
122
|
+
"""Extract CSS custom properties grouped by category.
|
|
123
|
+
|
|
124
|
+
LED-1010: now selector-aware. Each returned entry carries `selector`
|
|
125
|
+
(the CSS rule it was declared in), `mode` (dark/light/base/scoped), and
|
|
126
|
+
`taxonomy` (primitive/semantic/other) so consumers can tell
|
|
127
|
+
`--bg-base` in `:root` from `--bg-base` in `.dark`.
|
|
128
|
+
"""
|
|
76
129
|
colors: List[Dict[str, str]] = []
|
|
77
130
|
spacing: List[Dict[str, str]] = []
|
|
78
131
|
typography: List[Dict[str, str]] = []
|
|
79
132
|
other: List[Dict[str, str]] = []
|
|
133
|
+
semantic: List[Dict[str, str]] = []
|
|
134
|
+
|
|
135
|
+
# Walk each CSS rule block so we know which selector owns each declaration.
|
|
136
|
+
for m in _CSS_BLOCK_RE.finditer(text):
|
|
137
|
+
selector = m.group("selector").strip()
|
|
138
|
+
mode = _mode_from_selector(selector)
|
|
139
|
+
body = m.group("body")
|
|
140
|
+
for name, value in _CSS_VAR_RE.findall(body):
|
|
141
|
+
value = value.strip()
|
|
142
|
+
taxonomy = _token_taxonomy(name)
|
|
143
|
+
entry = {
|
|
144
|
+
"name": f"--{name}",
|
|
145
|
+
"value": value,
|
|
146
|
+
"selector": selector,
|
|
147
|
+
"mode": mode,
|
|
148
|
+
"taxonomy": taxonomy,
|
|
149
|
+
}
|
|
150
|
+
if source:
|
|
151
|
+
entry["source"] = source
|
|
152
|
+
lower = name.lower()
|
|
153
|
+
|
|
154
|
+
# Semantic tokens bypass the keyword bucket: they carry app meaning
|
|
155
|
+
# and should be routed to the semantic bucket regardless of value type.
|
|
156
|
+
if taxonomy == "semantic":
|
|
157
|
+
semantic.append(entry)
|
|
158
|
+
continue
|
|
80
159
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
elif _is_color_value(value):
|
|
92
|
-
colors.append(entry)
|
|
93
|
-
else:
|
|
94
|
-
other.append(entry)
|
|
160
|
+
if any(k in lower for k in ("color", "bg", "text", "border", "fill", "stroke", "accent", "primary", "secondary", "surface", "foreground", "background", "ring")):
|
|
161
|
+
colors.append(entry)
|
|
162
|
+
elif any(k in lower for k in ("space", "gap", "margin", "padding", "size", "width", "height", "radius")):
|
|
163
|
+
spacing.append(entry)
|
|
164
|
+
elif any(k in lower for k in ("font", "line", "letter", "text", "heading", "leading", "tracking")):
|
|
165
|
+
typography.append(entry)
|
|
166
|
+
elif _is_color_value(value):
|
|
167
|
+
colors.append(entry)
|
|
168
|
+
else:
|
|
169
|
+
other.append(entry)
|
|
95
170
|
|
|
96
|
-
return {
|
|
171
|
+
return {
|
|
172
|
+
"colors": colors,
|
|
173
|
+
"spacing": spacing,
|
|
174
|
+
"typography": typography,
|
|
175
|
+
"other": other,
|
|
176
|
+
"semantic": semantic,
|
|
177
|
+
}
|
|
97
178
|
|
|
98
179
|
|
|
99
180
|
def _is_color_value(v: str) -> bool:
|
|
@@ -128,6 +209,127 @@ def _parse_tailwind_config(text: str) -> Dict[str, Any]:
|
|
|
128
209
|
return {"colors_count": colors_count, "spacing_count": spacing_count, "breakpoints": breakpoints}
|
|
129
210
|
|
|
130
211
|
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# LED-1010: Tailwind-awareness helpers
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
# Tailwind ships an opinionated default scale. When a repo has tailwind.config.js
|
|
217
|
+
# and doesn't override these, the framework defaults are the design tokens in
|
|
218
|
+
# use — `extract_tokens` previously reported 0 for each because it only scanned
|
|
219
|
+
# CSS `--*` variables.
|
|
220
|
+
TAILWIND_DEFAULT_BREAKPOINTS = ["sm=640px", "md=768px", "lg=1024px", "xl=1280px", "2xl=1536px"]
|
|
221
|
+
TAILWIND_DEFAULT_SPACING = [
|
|
222
|
+
"0=0px", "px=1px", "0.5=0.125rem", "1=0.25rem", "1.5=0.375rem", "2=0.5rem",
|
|
223
|
+
"2.5=0.625rem", "3=0.75rem", "3.5=0.875rem", "4=1rem", "5=1.25rem", "6=1.5rem",
|
|
224
|
+
"7=1.75rem", "8=2rem", "9=2.25rem", "10=2.5rem", "11=2.75rem", "12=3rem",
|
|
225
|
+
"14=3.5rem", "16=4rem", "20=5rem", "24=6rem", "28=7rem", "32=8rem", "36=9rem",
|
|
226
|
+
"40=10rem", "44=11rem", "48=12rem", "52=13rem", "56=14rem", "60=15rem",
|
|
227
|
+
"64=16rem", "72=18rem", "80=20rem", "96=24rem",
|
|
228
|
+
]
|
|
229
|
+
TAILWIND_DEFAULT_FONT_SIZES = [
|
|
230
|
+
"text-xs=0.75rem", "text-sm=0.875rem", "text-base=1rem", "text-lg=1.125rem",
|
|
231
|
+
"text-xl=1.25rem", "text-2xl=1.5rem", "text-3xl=1.875rem", "text-4xl=2.25rem",
|
|
232
|
+
"text-5xl=3rem", "text-6xl=3.75rem", "text-7xl=4.5rem", "text-8xl=6rem",
|
|
233
|
+
"text-9xl=8rem",
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
# Responsive prefix regex. Matches `sm:`, `md:`, `lg:`, `xl:`, `2xl:` in a
|
|
237
|
+
# JSX className/string context. Tailwind is mobile-first by default: these
|
|
238
|
+
# prefixes apply at and above the named breakpoint.
|
|
239
|
+
_TAILWIND_RESPONSIVE_PREFIX_RE = re.compile(r"(?<![\w-])(sm|md|lg|xl|2xl):[\w\[\]-]+", re.IGNORECASE)
|
|
240
|
+
|
|
241
|
+
# Dark-mode class usage in JSX: `dark:bg-black` etc.
|
|
242
|
+
_TAILWIND_DARK_PREFIX_RE = re.compile(r"(?<![\w-])dark:[\w\[\]-]+")
|
|
243
|
+
|
|
244
|
+
# Tailwind spacing/layout utility class usage — rough counter for the
|
|
245
|
+
# responsive_units_count signal.
|
|
246
|
+
_TAILWIND_UTILITY_RE = re.compile(
|
|
247
|
+
r"(?<![\w-])(?:w|h|m|p|mx|my|mt|mb|ml|mr|px|py|pt|pb|pl|pr|gap|space|"
|
|
248
|
+
r"max-w|min-w|max-h|min-h|inset|top|bottom|left|right|z|grid-cols|col-span)"
|
|
249
|
+
r"-[\w./\[\]%-]+"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _has_tailwind_config(root: Path) -> Optional[Path]:
|
|
254
|
+
"""Return the first found tailwind.config.{js,ts,mjs,cjs} or None."""
|
|
255
|
+
for tw_name in ("tailwind.config.js", "tailwind.config.ts", "tailwind.config.mjs", "tailwind.config.cjs"):
|
|
256
|
+
tw_path = root / tw_name
|
|
257
|
+
if tw_path.exists():
|
|
258
|
+
return tw_path
|
|
259
|
+
# Tailwind v4 uses @import "tailwindcss" in CSS with no separate config
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _detect_tailwind_v4(root: Path) -> bool:
|
|
264
|
+
"""Detect Tailwind v4 (no config file, uses @import "tailwindcss" in CSS)."""
|
|
265
|
+
for cf in list(root.rglob("*.css"))[:20]:
|
|
266
|
+
try:
|
|
267
|
+
text = cf.read_text(errors="replace")[:5000]
|
|
268
|
+
if '@import "tailwindcss"' in text or "@import 'tailwindcss'" in text:
|
|
269
|
+
return True
|
|
270
|
+
except Exception:
|
|
271
|
+
continue
|
|
272
|
+
return False
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _scan_tailwind_utilities(root: Path) -> Dict[str, Any]:
|
|
276
|
+
"""Scan JSX/TSX/Vue/Svelte files for Tailwind utility class usage.
|
|
277
|
+
|
|
278
|
+
Returns counts of responsive-prefixed classes, dark-prefix classes,
|
|
279
|
+
and general utility classes — what the old validate_responsive missed
|
|
280
|
+
entirely when it only looked at raw CSS.
|
|
281
|
+
"""
|
|
282
|
+
component_files = _find_files(root, [".tsx", ".jsx", ".ts", ".js", ".vue", ".svelte", ".html"])
|
|
283
|
+
responsive_hits = 0
|
|
284
|
+
dark_hits = 0
|
|
285
|
+
utility_hits = 0
|
|
286
|
+
breakpoints_seen: set = set()
|
|
287
|
+
files_with_utilities = 0
|
|
288
|
+
# Cap to keep runtime bounded on huge monorepos
|
|
289
|
+
for cf in component_files[:500]:
|
|
290
|
+
text = _read_text(cf, limit=200_000)
|
|
291
|
+
if not text:
|
|
292
|
+
continue
|
|
293
|
+
# Require a Tailwind signal before counting (otherwise every JS file
|
|
294
|
+
# matches via collisions like `py-pi`)
|
|
295
|
+
if "className" not in text and "class=" not in text and "tw`" not in text:
|
|
296
|
+
continue
|
|
297
|
+
file_had_utility = False
|
|
298
|
+
for m in _TAILWIND_RESPONSIVE_PREFIX_RE.finditer(text):
|
|
299
|
+
responsive_hits += 1
|
|
300
|
+
breakpoints_seen.add(m.group(1).lower())
|
|
301
|
+
file_had_utility = True
|
|
302
|
+
dark_hits += len(_TAILWIND_DARK_PREFIX_RE.findall(text))
|
|
303
|
+
for m in _TAILWIND_UTILITY_RE.finditer(text):
|
|
304
|
+
utility_hits += 1
|
|
305
|
+
file_had_utility = True
|
|
306
|
+
if file_had_utility:
|
|
307
|
+
files_with_utilities += 1
|
|
308
|
+
return {
|
|
309
|
+
"responsive_prefix_count": responsive_hits,
|
|
310
|
+
"dark_prefix_count": dark_hits,
|
|
311
|
+
"utility_count": utility_hits,
|
|
312
|
+
"files_with_utilities": files_with_utilities,
|
|
313
|
+
"files_scanned": len(component_files),
|
|
314
|
+
"breakpoints_seen": sorted(breakpoints_seen, key=lambda b: ["sm", "md", "lg", "xl", "2xl"].index(b) if b in ["sm", "md", "lg", "xl", "2xl"] else 99),
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
# LED-1010: status taxonomy
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
# Consumers branch on `status` to decide whether to gate CI. A tool that
|
|
322
|
+
# returns `ok` while producing partial/wrong results is a silent failure —
|
|
323
|
+
# the biggest gap identified by the 2026-04-24 pilot run. These constants
|
|
324
|
+
# define the explicit vocabulary.
|
|
325
|
+
|
|
326
|
+
STATUS_OK = "ok" # all checks ran, no gaps
|
|
327
|
+
STATUS_DEGRADED = "degraded" # ran, but known gaps (e.g. only CSS scanned, not JSX)
|
|
328
|
+
STATUS_PARTIAL_COVERAGE = "partial_coverage" # ran a subset of the requested standard
|
|
329
|
+
STATUS_TOOLCHAIN_MISSING = "toolchain_missing" # required external tool absent
|
|
330
|
+
STATUS_ERROR = "error" # unrecoverable
|
|
331
|
+
|
|
332
|
+
|
|
131
333
|
# ---------------------------------------------------------------------------
|
|
132
334
|
# Component scanning helpers
|
|
133
335
|
# ---------------------------------------------------------------------------
|
|
@@ -209,61 +411,139 @@ def design_extract_tokens(
|
|
|
209
411
|
if not root.is_dir():
|
|
210
412
|
return {"tool": "design.extract_tokens", "error": f"Directory not found: {root}"}
|
|
211
413
|
|
|
212
|
-
all_tokens: Dict[str, List] = {
|
|
414
|
+
all_tokens: Dict[str, List] = {
|
|
415
|
+
"colors": [], "spacing": [], "typography": [], "breakpoints": [],
|
|
416
|
+
"other": [], "semantic": [],
|
|
417
|
+
}
|
|
213
418
|
source_files: List[str] = []
|
|
419
|
+
coverage: Dict[str, Any] = {
|
|
420
|
+
"css_scanned": False,
|
|
421
|
+
"tailwind_config_found": False,
|
|
422
|
+
"tailwind_v4_detected": False,
|
|
423
|
+
"tailwind_defaults_emitted": False,
|
|
424
|
+
"selector_aware": True,
|
|
425
|
+
"semantic_taxonomy": True,
|
|
426
|
+
}
|
|
427
|
+
gaps: List[str] = []
|
|
428
|
+
|
|
429
|
+
# 1. Tailwind config (LED-1010: emit framework defaults when present)
|
|
430
|
+
tw_path = _has_tailwind_config(root)
|
|
431
|
+
tw_v4 = False
|
|
432
|
+
tw_config_text = ""
|
|
433
|
+
if tw_path:
|
|
434
|
+
coverage["tailwind_config_found"] = True
|
|
435
|
+
tw_config_text = _read_text(tw_path)
|
|
436
|
+
parsed = _parse_tailwind_config(tw_config_text)
|
|
437
|
+
source_files.append(str(tw_path))
|
|
438
|
+
# User-defined breakpoints override defaults
|
|
439
|
+
if parsed["breakpoints"]:
|
|
440
|
+
all_tokens["breakpoints"].extend(
|
|
441
|
+
[{"name": bp, "source": str(tw_path), "origin": "tailwind_config"} for bp in parsed["breakpoints"]]
|
|
442
|
+
)
|
|
443
|
+
else:
|
|
444
|
+
tw_v4 = _detect_tailwind_v4(root)
|
|
445
|
+
coverage["tailwind_v4_detected"] = tw_v4
|
|
446
|
+
|
|
447
|
+
# If Tailwind is in play at all, surface the framework's default token
|
|
448
|
+
# scales so downstream consumers can see what utility classes reference.
|
|
449
|
+
# Previously these returned zero and the caller couldn't tell if the
|
|
450
|
+
# design system was truly empty or just invisible to us.
|
|
451
|
+
if tw_path or tw_v4:
|
|
452
|
+
coverage["tailwind_defaults_emitted"] = True
|
|
453
|
+
framework_source = str(tw_path) if tw_path else "tailwind-v4 (@import)"
|
|
454
|
+
|
|
455
|
+
# Emit defaults only when user config didn't already cover them
|
|
456
|
+
if not all_tokens["breakpoints"]:
|
|
457
|
+
for bp_def in TAILWIND_DEFAULT_BREAKPOINTS:
|
|
458
|
+
name, value = bp_def.split("=", 1)
|
|
459
|
+
all_tokens["breakpoints"].append({
|
|
460
|
+
"name": name, "value": value, "source": framework_source,
|
|
461
|
+
"origin": "tailwind_default",
|
|
462
|
+
})
|
|
214
463
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
source_files.append(str(tw_path))
|
|
222
|
-
if parsed["breakpoints"]:
|
|
223
|
-
all_tokens["breakpoints"].extend(
|
|
224
|
-
[{"name": bp, "source": str(tw_path)} for bp in parsed["breakpoints"]]
|
|
225
|
-
)
|
|
226
|
-
break
|
|
464
|
+
for sp_def in TAILWIND_DEFAULT_SPACING:
|
|
465
|
+
name, value = sp_def.split("=", 1)
|
|
466
|
+
all_tokens["spacing"].append({
|
|
467
|
+
"name": f"spacing.{name}", "value": value, "source": framework_source,
|
|
468
|
+
"origin": "tailwind_default", "taxonomy": "primitive",
|
|
469
|
+
})
|
|
227
470
|
|
|
228
|
-
|
|
471
|
+
for fs_def in TAILWIND_DEFAULT_FONT_SIZES:
|
|
472
|
+
name, value = fs_def.split("=", 1)
|
|
473
|
+
all_tokens["typography"].append({
|
|
474
|
+
"name": name, "value": value, "source": framework_source,
|
|
475
|
+
"origin": "tailwind_default", "taxonomy": "primitive",
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
# 2. CSS / SCSS files (now selector-aware via _extract_css_variables)
|
|
229
479
|
css_files = _find_files(root, [".css", ".scss", ".sass"])
|
|
230
480
|
for cf in css_files:
|
|
231
481
|
text = _read_text(cf)
|
|
232
482
|
if "--" not in text and "@media" not in text:
|
|
233
483
|
continue
|
|
234
484
|
source_files.append(str(cf))
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
all_tokens[cat].append(entry)
|
|
485
|
+
coverage["css_scanned"] = True
|
|
486
|
+
vars_found = _extract_css_variables(text, source=str(cf))
|
|
487
|
+
for cat in ("colors", "spacing", "typography", "other", "semantic"):
|
|
488
|
+
all_tokens[cat].extend(vars_found.get(cat, []))
|
|
240
489
|
|
|
241
|
-
# breakpoints from media queries
|
|
490
|
+
# breakpoints from media queries (user-authored, origin=css_media)
|
|
242
491
|
for bp_val in _MEDIA_QUERY_RE.findall(text):
|
|
243
|
-
all_tokens["breakpoints"].append({
|
|
492
|
+
all_tokens["breakpoints"].append({
|
|
493
|
+
"value": bp_val.strip(), "source": str(cf),
|
|
494
|
+
"origin": "css_media",
|
|
495
|
+
})
|
|
244
496
|
|
|
245
497
|
# 3. Filter by token_types if specified
|
|
246
498
|
if token_types:
|
|
247
499
|
all_tokens = {k: v for k, v in all_tokens.items() if k in token_types}
|
|
248
500
|
|
|
249
|
-
#
|
|
250
|
-
|
|
251
|
-
|
|
501
|
+
# LED-1010 dark-mode dedup: collapse (name, mode) duplicates rather than
|
|
502
|
+
# eliding mode altogether. A token with the same name in `:root` and
|
|
503
|
+
# `.dark` is one logical token with two values — surface both.
|
|
504
|
+
for cat in ("colors", "spacing", "typography", "other", "semantic"):
|
|
505
|
+
if cat not in all_tokens:
|
|
506
|
+
continue
|
|
507
|
+
seen: Dict[tuple, Dict] = {}
|
|
508
|
+
for entry in all_tokens[cat]:
|
|
509
|
+
key = (entry.get("name", ""), entry.get("mode", ""))
|
|
510
|
+
if key in seen:
|
|
511
|
+
continue
|
|
512
|
+
seen[key] = entry
|
|
513
|
+
all_tokens[cat] = list(seen.values())
|
|
514
|
+
|
|
515
|
+
# Breakpoint dedup by (name|value, origin)
|
|
516
|
+
seen_bp: set = set()
|
|
517
|
+
unique_bp: List[Dict[str, Any]] = []
|
|
252
518
|
for bp in all_tokens.get("breakpoints", []):
|
|
253
|
-
key = bp.get("name", bp.get("value", ""))
|
|
254
|
-
if key
|
|
255
|
-
|
|
256
|
-
|
|
519
|
+
key = (bp.get("name", bp.get("value", "")), bp.get("origin", ""))
|
|
520
|
+
if key in seen_bp:
|
|
521
|
+
continue
|
|
522
|
+
seen_bp.add(key)
|
|
523
|
+
unique_bp.append(bp)
|
|
257
524
|
if "breakpoints" in all_tokens:
|
|
258
525
|
all_tokens["breakpoints"] = unique_bp
|
|
259
526
|
|
|
260
|
-
|
|
527
|
+
# Coverage gaps → status taxonomy (LED-1010)
|
|
528
|
+
if not coverage["css_scanned"] and not (tw_path or tw_v4):
|
|
529
|
+
gaps.append("No CSS variables and no Tailwind config found — project may not use standard design tokens")
|
|
530
|
+
if (tw_path or tw_v4) and not coverage["css_scanned"]:
|
|
531
|
+
gaps.append("Tailwind detected but no user CSS variables found — only framework defaults emitted")
|
|
532
|
+
|
|
533
|
+
if gaps:
|
|
534
|
+
status = STATUS_PARTIAL_COVERAGE
|
|
535
|
+
else:
|
|
536
|
+
status = STATUS_OK
|
|
537
|
+
|
|
538
|
+
total = sum(len(v) for v in all_tokens.values() if isinstance(v, list))
|
|
261
539
|
result = {
|
|
262
540
|
"tool": "design.extract_tokens",
|
|
263
|
-
"status":
|
|
541
|
+
"status": status,
|
|
264
542
|
"tokens": all_tokens,
|
|
265
543
|
"total_tokens": total,
|
|
266
544
|
"source_files": sorted(set(source_files)),
|
|
545
|
+
"coverage": coverage,
|
|
546
|
+
"gaps": gaps,
|
|
267
547
|
"figma_used": False,
|
|
268
548
|
}
|
|
269
549
|
# If user passed a figma_file_key but no token is available, add a hint
|
|
@@ -496,7 +776,13 @@ def design_validate_responsive(
|
|
|
496
776
|
project_path: str,
|
|
497
777
|
check_types: Optional[List[str]] = None,
|
|
498
778
|
) -> Dict[str, Any]:
|
|
499
|
-
"""Validate responsive design patterns via static analysis
|
|
779
|
+
"""Validate responsive design patterns via static analysis.
|
|
780
|
+
|
|
781
|
+
LED-1010: now Tailwind-aware. Scans JSX/TSX/Vue/Svelte for utility-class
|
|
782
|
+
responsive prefixes (sm:/md:/lg:/xl:/2xl:) in addition to @media CSS.
|
|
783
|
+
Tailwind is mobile-first by default; previously the tool reported
|
|
784
|
+
`mobile_first: false` on any Tailwind-only codebase, which was wrong.
|
|
785
|
+
"""
|
|
500
786
|
root = Path(project_path)
|
|
501
787
|
if not root.is_dir():
|
|
502
788
|
return {"tool": "design.validate_responsive", "error": f"Directory not found: {root}"}
|
|
@@ -504,14 +790,23 @@ def design_validate_responsive(
|
|
|
504
790
|
issues: List[Dict[str, str]] = []
|
|
505
791
|
breakpoints_found: List[str] = []
|
|
506
792
|
viewport_meta = False
|
|
793
|
+
viewport_blocks_zoom = False
|
|
507
794
|
responsive_units_count = 0
|
|
795
|
+
coverage: Dict[str, Any] = {
|
|
796
|
+
"css_scanned": False,
|
|
797
|
+
"jsx_scanned": False,
|
|
798
|
+
"tailwind_detected": False,
|
|
799
|
+
}
|
|
508
800
|
|
|
509
801
|
# Scan HTML files for viewport meta
|
|
510
802
|
html_files = _find_files(root, [".html", ".htm"])
|
|
511
803
|
for hf in html_files:
|
|
512
804
|
text = _read_text(hf)
|
|
513
|
-
|
|
805
|
+
m = _VIEWPORT_META_RE.search(text)
|
|
806
|
+
if m:
|
|
514
807
|
viewport_meta = True
|
|
808
|
+
if "maximum-scale" in m.group() or "user-scalable=no" in m.group():
|
|
809
|
+
viewport_blocks_zoom = True
|
|
515
810
|
break
|
|
516
811
|
|
|
517
812
|
# Also check Next.js layout files
|
|
@@ -522,6 +817,8 @@ def design_validate_responsive(
|
|
|
522
817
|
text = _read_text(c)
|
|
523
818
|
if "viewport" in text.lower():
|
|
524
819
|
viewport_meta = True
|
|
820
|
+
if "maximum-scale" in text.lower() or "userScalable: false" in text:
|
|
821
|
+
viewport_blocks_zoom = True
|
|
525
822
|
break
|
|
526
823
|
if viewport_meta:
|
|
527
824
|
break
|
|
@@ -529,10 +826,20 @@ def design_validate_responsive(
|
|
|
529
826
|
if not viewport_meta:
|
|
530
827
|
issues.append({"severity": "warning", "message": "No viewport meta tag detected", "fix": "Add <meta name='viewport' content='width=device-width, initial-scale=1'>"})
|
|
531
828
|
|
|
829
|
+
# LED-1010: WCAG2AA (1.4.4) — zoom-blocking viewport
|
|
830
|
+
if viewport_meta and viewport_blocks_zoom:
|
|
831
|
+
issues.append({
|
|
832
|
+
"severity": "error",
|
|
833
|
+
"message": "Viewport blocks user zoom (maximum-scale=1 or user-scalable=no) — WCAG 1.4.4 violation",
|
|
834
|
+
"fix": "Remove maximum-scale and user-scalable restrictions so users can zoom to 200%",
|
|
835
|
+
})
|
|
836
|
+
|
|
532
837
|
# Scan CSS for media queries and responsive patterns
|
|
533
838
|
css_files = _find_files(root, [".css", ".scss", ".sass"])
|
|
534
839
|
for cf in css_files:
|
|
535
840
|
text = _read_text(cf)
|
|
841
|
+
if text:
|
|
842
|
+
coverage["css_scanned"] = True
|
|
536
843
|
for bp_val in _MEDIA_QUERY_RE.findall(text):
|
|
537
844
|
bp_val = bp_val.strip()
|
|
538
845
|
if bp_val not in breakpoints_found:
|
|
@@ -547,6 +854,39 @@ def design_validate_responsive(
|
|
|
547
854
|
min_width_count += len(re.findall(r"min-width\s*:", text))
|
|
548
855
|
max_width_count += len(re.findall(r"max-width\s*:", text))
|
|
549
856
|
|
|
857
|
+
# LED-1010: Tailwind utility class scan. The old mobile_first heuristic
|
|
858
|
+
# only considered raw CSS and returned `false` on any Tailwind codebase.
|
|
859
|
+
# Tailwind prefixes (sm:/md:/...) are mobile-first by design: they apply
|
|
860
|
+
# AT OR ABOVE the named breakpoint.
|
|
861
|
+
tw_path = _has_tailwind_config(root)
|
|
862
|
+
tw_v4 = False if tw_path else _detect_tailwind_v4(root)
|
|
863
|
+
coverage["tailwind_detected"] = bool(tw_path or tw_v4)
|
|
864
|
+
|
|
865
|
+
tw_stats: Dict[str, Any] = {}
|
|
866
|
+
if coverage["tailwind_detected"]:
|
|
867
|
+
tw_stats = _scan_tailwind_utilities(root)
|
|
868
|
+
coverage["jsx_scanned"] = tw_stats.get("files_scanned", 0) > 0
|
|
869
|
+
|
|
870
|
+
# Add Tailwind default breakpoints that were actually USED in the codebase
|
|
871
|
+
for bp in tw_stats.get("breakpoints_seen", []):
|
|
872
|
+
bp_label = f"tailwind:{bp}"
|
|
873
|
+
if bp_label not in breakpoints_found:
|
|
874
|
+
breakpoints_found.append(bp_label)
|
|
875
|
+
|
|
876
|
+
# Utility classes count as responsive units (layout-responsive classes
|
|
877
|
+
# like w-full/h-screen/max-w-*)
|
|
878
|
+
responsive_units_count += tw_stats.get("utility_count", 0)
|
|
879
|
+
|
|
880
|
+
# Determine mobile_first. Tailwind prefixes → mobile-first. Raw CSS: prefer
|
|
881
|
+
# min-width over max-width.
|
|
882
|
+
if coverage["tailwind_detected"] and tw_stats.get("responsive_prefix_count", 0) > 0:
|
|
883
|
+
mobile_first = True
|
|
884
|
+
elif min_width_count == 0 and max_width_count == 0:
|
|
885
|
+
# No explicit breakpoints at all — don't claim mobile_first either way
|
|
886
|
+
mobile_first = None
|
|
887
|
+
else:
|
|
888
|
+
mobile_first = min_width_count >= max_width_count
|
|
889
|
+
|
|
550
890
|
if max_width_count > min_width_count * 2 and max_width_count > 3:
|
|
551
891
|
issues.append({
|
|
552
892
|
"severity": "info",
|
|
@@ -554,9 +894,7 @@ def design_validate_responsive(
|
|
|
554
894
|
"fix": "Consider mobile-first approach using min-width media queries",
|
|
555
895
|
})
|
|
556
896
|
|
|
557
|
-
if not breakpoints_found and not
|
|
558
|
-
(root / n).exists() for n in ("tailwind.config.js", "tailwind.config.ts", "tailwind.config.mjs")
|
|
559
|
-
):
|
|
897
|
+
if not breakpoints_found and not coverage["tailwind_detected"]:
|
|
560
898
|
issues.append({
|
|
561
899
|
"severity": "warning",
|
|
562
900
|
"message": "No CSS breakpoints or Tailwind config detected",
|
|
@@ -574,15 +912,30 @@ def design_validate_responsive(
|
|
|
574
912
|
"fix": "Use max-width or responsive units instead",
|
|
575
913
|
})
|
|
576
914
|
|
|
577
|
-
|
|
915
|
+
# Status taxonomy (LED-1010)
|
|
916
|
+
gaps: List[str] = []
|
|
917
|
+
if coverage["tailwind_detected"] and not coverage["jsx_scanned"]:
|
|
918
|
+
gaps.append("Tailwind detected but no JSX/TSX/Vue/Svelte files found — responsive coverage may be underreported")
|
|
919
|
+
if not coverage["css_scanned"] and not coverage["tailwind_detected"]:
|
|
920
|
+
gaps.append("No CSS and no Tailwind detected — nothing to validate against")
|
|
921
|
+
|
|
922
|
+
status = STATUS_PARTIAL_COVERAGE if gaps else STATUS_OK
|
|
923
|
+
|
|
924
|
+
result = {
|
|
578
925
|
"tool": "design.validate_responsive",
|
|
579
|
-
"status":
|
|
926
|
+
"status": status,
|
|
580
927
|
"breakpoints_found": breakpoints_found,
|
|
581
928
|
"responsive_issues": issues,
|
|
582
929
|
"viewport_meta": viewport_meta,
|
|
930
|
+
"viewport_blocks_zoom": viewport_blocks_zoom,
|
|
583
931
|
"responsive_units_count": responsive_units_count,
|
|
584
|
-
"mobile_first":
|
|
932
|
+
"mobile_first": mobile_first,
|
|
933
|
+
"coverage": coverage,
|
|
934
|
+
"gaps": gaps,
|
|
585
935
|
}
|
|
936
|
+
if tw_stats:
|
|
937
|
+
result["tailwind_stats"] = tw_stats
|
|
938
|
+
return result
|
|
586
939
|
|
|
587
940
|
|
|
588
941
|
# ---------------------------------------------------------------------------
|
|
@@ -764,11 +1117,31 @@ def _puppeteer_screenshot_fallback(url: str, baselines_dir: Path) -> Dict[str, A
|
|
|
764
1117
|
timeout=30,
|
|
765
1118
|
)
|
|
766
1119
|
if result.returncode != 0:
|
|
1120
|
+
stderr = result.stderr.decode(errors="replace")[:500]
|
|
1121
|
+
# LED-1010: distinguish "module not found" (toolchain missing) from
|
|
1122
|
+
# runtime errors (toolchain present but failed). Consumers need
|
|
1123
|
+
# this distinction to know whether to install or debug.
|
|
1124
|
+
if "Cannot find module 'puppeteer'" in stderr or "Error: Cannot find module" in stderr:
|
|
1125
|
+
return {
|
|
1126
|
+
"tool": "story.visual_test",
|
|
1127
|
+
"status": STATUS_TOOLCHAIN_MISSING,
|
|
1128
|
+
"missing": ["puppeteer", "playwright"],
|
|
1129
|
+
"error": stderr,
|
|
1130
|
+
"install_commands": [
|
|
1131
|
+
"pip install playwright && python -m playwright install chromium",
|
|
1132
|
+
"npm install -g puppeteer",
|
|
1133
|
+
],
|
|
1134
|
+
"hint": (
|
|
1135
|
+
"No screenshot engine available. Install Playwright (preferred) "
|
|
1136
|
+
"or Puppeteer. After install, re-run — the tool auto-detects."
|
|
1137
|
+
),
|
|
1138
|
+
}
|
|
767
1139
|
return {
|
|
768
1140
|
"tool": "story.visual_test",
|
|
769
|
-
"status": "
|
|
770
|
-
"
|
|
771
|
-
"
|
|
1141
|
+
"status": "error",
|
|
1142
|
+
"engine": "puppeteer",
|
|
1143
|
+
"error": stderr,
|
|
1144
|
+
"hint": "Puppeteer is installed but the screenshot call failed at runtime. Check the URL reachability, sandbox permissions, or memory limits.",
|
|
772
1145
|
}
|
|
773
1146
|
|
|
774
1147
|
return {
|
|
@@ -813,9 +1186,14 @@ def story_visual_test(
|
|
|
813
1186
|
|
|
814
1187
|
return {
|
|
815
1188
|
"tool": "story.visual_test",
|
|
816
|
-
"status":
|
|
1189
|
+
"status": STATUS_TOOLCHAIN_MISSING,
|
|
1190
|
+
"missing": ["playwright", "puppeteer"],
|
|
1191
|
+
"install_commands": [
|
|
1192
|
+
"pip install playwright && python -m playwright install chromium",
|
|
1193
|
+
"npm install -g puppeteer",
|
|
1194
|
+
],
|
|
817
1195
|
"message": (
|
|
818
|
-
"No screenshot
|
|
1196
|
+
"No screenshot engine available. Install one:\n"
|
|
819
1197
|
" - Playwright (recommended): pip install playwright && python -m playwright install chromium\n"
|
|
820
1198
|
" - Puppeteer (fallback): npm install -g puppeteer"
|
|
821
1199
|
),
|
|
@@ -909,19 +1287,76 @@ def story_visual_test(
|
|
|
909
1287
|
# 26. story_accessibility
|
|
910
1288
|
# ---------------------------------------------------------------------------
|
|
911
1289
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1290
|
+
# LED-1010 FIX: the original patterns used `<tag` with re.IGNORECASE but no
|
|
1291
|
+
# word boundary after the tag name. That meant `<ArrowLeft>` matched `<a` +
|
|
1292
|
+
# `rrowLeft>`, producing 128 false-positive link-href "errors" on a single
|
|
1293
|
+
# DomainVested scan. Require the character AFTER the tag name to be
|
|
1294
|
+
# whitespace, `/`, or `>` so PascalCase React components can't collide with
|
|
1295
|
+
# HTML anchors / images / inputs / buttons.
|
|
1296
|
+
_IMG_NO_ALT_RE = re.compile(r"<img(?=[\s/>])(?![^>]*\salt=)[^>]*>", re.IGNORECASE)
|
|
1297
|
+
_INPUT_NO_LABEL_RE = re.compile(r"<input(?=[\s/>])(?![^>]*(?:\saria-label|\saria-labelledby|\sid=|type=[\"']hidden[\"']))[^>]*>", re.IGNORECASE)
|
|
1298
|
+
_BUTTON_EMPTY_RE = re.compile(r"<button(?=[\s>])[^>]*>\s*</button>", re.IGNORECASE)
|
|
1299
|
+
_A_NO_HREF_RE = re.compile(r"<a(?=[\s/>])(?![^>]*\shref=)[^>]*>", re.IGNORECASE)
|
|
1300
|
+
_HEADING_SKIP_RE = re.compile(r"<h([1-6])(?=[\s/>])")
|
|
1301
|
+
_ARIA_HIDDEN_FOCUSABLE_RE = re.compile(r'aria-hidden=["\']true["\'][^>]*(?:tabindex=["\']0["\']|<button(?=[\s>])|<a(?=[\s>]))', re.IGNORECASE)
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
# LED-1010: WCAG coverage map. The old tool accepted standards="WCAG2AA"
|
|
1305
|
+
# but only implemented 3 of ~50 AA rules, and stamped every issue "WCAG2A"
|
|
1306
|
+
# regardless. This map declares exactly which rules the scanner covers so
|
|
1307
|
+
# the response can surface `coverage` honestly and `standard_requested` vs
|
|
1308
|
+
# `standard_implemented` are separate fields.
|
|
1309
|
+
#
|
|
1310
|
+
# Source references per rule: https://www.w3.org/WAI/WCAG21/quickref/
|
|
1311
|
+
IMPLEMENTED_WCAG_RULES = {
|
|
1312
|
+
"img-alt": {"criterion": "1.1.1", "level": "A", "title": "Non-text Content"},
|
|
1313
|
+
"input-label": {"criterion": "1.3.1", "level": "A", "title": "Info and Relationships"},
|
|
1314
|
+
"button-content": {"criterion": "4.1.2", "level": "A", "title": "Name, Role, Value"},
|
|
1315
|
+
"link-href": {"criterion": "2.4.4", "level": "A", "title": "Link Purpose (In Context)"},
|
|
1316
|
+
"heading-order": {"criterion": "1.3.1", "level": "A", "title": "Info and Relationships"},
|
|
1317
|
+
"aria-hidden-focusable": {"criterion": "4.1.2", "level": "A", "title": "Name, Role, Value"},
|
|
1318
|
+
"viewport-zoom-blocked": {"criterion": "1.4.4", "level": "AA", "title": "Resize Text"},
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
# Rules that exist in each level but we DON'T implement — surfaced as gaps
|
|
1322
|
+
# so consumers know coverage is partial.
|
|
1323
|
+
UNIMPLEMENTED_WCAG_RULES_AA = [
|
|
1324
|
+
{"criterion": "1.4.3", "level": "AA", "title": "Contrast (Minimum)"},
|
|
1325
|
+
{"criterion": "2.4.7", "level": "AA", "title": "Focus Visible"},
|
|
1326
|
+
{"criterion": "2.1.2", "level": "A", "title": "No Keyboard Trap"},
|
|
1327
|
+
{"criterion": "2.3.3", "level": "AAA", "title": "Animation from Interactions"},
|
|
1328
|
+
{"criterion": "3.1.1", "level": "A", "title": "Language of Page"},
|
|
1329
|
+
{"criterion": "1.4.1", "level": "A", "title": "Use of Color"},
|
|
1330
|
+
{"criterion": "1.3.5", "level": "AA", "title": "Identify Input Purpose"},
|
|
1331
|
+
{"criterion": "3.3.1", "level": "A", "title": "Error Identification"},
|
|
1332
|
+
{"criterion": "3.3.2", "level": "A", "title": "Labels or Instructions"},
|
|
1333
|
+
{"criterion": "2.5.3", "level": "A", "title": "Label in Name"},
|
|
1334
|
+
{"criterion": "2.5.2", "level": "A", "title": "Pointer Cancellation"},
|
|
1335
|
+
{"criterion": "4.1.3", "level": "AA", "title": "Status Messages"},
|
|
1336
|
+
]
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def _stamp_rule(rule: str) -> str:
|
|
1340
|
+
"""Return the actual WCAG level the rule enforces — not the caller's request."""
|
|
1341
|
+
info = IMPLEMENTED_WCAG_RULES.get(rule)
|
|
1342
|
+
if not info:
|
|
1343
|
+
return "WCAG2A"
|
|
1344
|
+
level = info["level"]
|
|
1345
|
+
return "WCAG2A" if level == "A" else ("WCAG2AA" if level == "AA" else "WCAG2AAA")
|
|
918
1346
|
|
|
919
1347
|
|
|
920
1348
|
def story_accessibility(
|
|
921
1349
|
project_path: str,
|
|
922
1350
|
standards: str = "WCAG2AA",
|
|
923
1351
|
) -> Dict[str, Any]:
|
|
924
|
-
"""Run accessibility checks by scanning HTML/JSX/TSX for common issues.
|
|
1352
|
+
"""Run accessibility checks by scanning HTML/JSX/TSX for common issues.
|
|
1353
|
+
|
|
1354
|
+
LED-1010: issues are now stamped with the ACTUAL WCAG level the rule
|
|
1355
|
+
enforces (not the caller's requested level). `standard_requested`,
|
|
1356
|
+
`implemented_rules`, `unimplemented_rules`, and `coverage_percent`
|
|
1357
|
+
surface exactly what ran vs what's still in the standard, so a caller
|
|
1358
|
+
asking for WCAG2AA does not see a false-confident pass.
|
|
1359
|
+
"""
|
|
925
1360
|
root = Path(project_path)
|
|
926
1361
|
if not root.is_dir():
|
|
927
1362
|
return {"tool": "story.accessibility", "error": f"Directory not found: {root}"}
|
|
@@ -936,21 +1371,21 @@ def story_accessibility(
|
|
|
936
1371
|
files_checked += 1
|
|
937
1372
|
rel = str(f.relative_to(root)) if f.is_relative_to(root) else str(f)
|
|
938
1373
|
|
|
939
|
-
# Missing alt on images
|
|
1374
|
+
# Missing alt on images (WCAG 1.1.1, Level A)
|
|
940
1375
|
for m in _IMG_NO_ALT_RE.finditer(text):
|
|
941
1376
|
issues.append({
|
|
942
1377
|
"rule": "img-alt",
|
|
943
1378
|
"severity": "error",
|
|
944
1379
|
"message": "Image missing alt attribute",
|
|
945
1380
|
"file": rel,
|
|
946
|
-
"standard": "
|
|
1381
|
+
"standard": _stamp_rule("img-alt"),
|
|
1382
|
+
"wcag": IMPLEMENTED_WCAG_RULES["img-alt"],
|
|
947
1383
|
"snippet": m.group()[:120],
|
|
948
1384
|
})
|
|
949
1385
|
|
|
950
|
-
# Inputs without labels
|
|
1386
|
+
# Inputs without labels (WCAG 1.3.1, Level A)
|
|
951
1387
|
for m in _INPUT_NO_LABEL_RE.finditer(text):
|
|
952
1388
|
snippet = m.group()
|
|
953
|
-
# Skip hidden inputs
|
|
954
1389
|
if 'type="hidden"' in snippet or "type='hidden'" in snippet:
|
|
955
1390
|
continue
|
|
956
1391
|
issues.append({
|
|
@@ -958,33 +1393,37 @@ def story_accessibility(
|
|
|
958
1393
|
"severity": "error",
|
|
959
1394
|
"message": "Input missing associated label or aria-label",
|
|
960
1395
|
"file": rel,
|
|
961
|
-
"standard": "
|
|
1396
|
+
"standard": _stamp_rule("input-label"),
|
|
1397
|
+
"wcag": IMPLEMENTED_WCAG_RULES["input-label"],
|
|
962
1398
|
"snippet": snippet[:120],
|
|
963
1399
|
})
|
|
964
1400
|
|
|
965
|
-
# Empty buttons
|
|
1401
|
+
# Empty buttons (WCAG 4.1.2, Level A)
|
|
966
1402
|
for m in _BUTTON_EMPTY_RE.finditer(text):
|
|
967
1403
|
issues.append({
|
|
968
1404
|
"rule": "button-content",
|
|
969
1405
|
"severity": "error",
|
|
970
1406
|
"message": "Button has no text content or aria-label",
|
|
971
1407
|
"file": rel,
|
|
972
|
-
"standard": "
|
|
1408
|
+
"standard": _stamp_rule("button-content"),
|
|
1409
|
+
"wcag": IMPLEMENTED_WCAG_RULES["button-content"],
|
|
973
1410
|
"snippet": m.group()[:120],
|
|
974
1411
|
})
|
|
975
1412
|
|
|
976
|
-
# Links without href
|
|
1413
|
+
# Links without href (WCAG 2.4.4, Level A) — regex fixed to not
|
|
1414
|
+
# false-match PascalCase JSX components (`<ArrowLeft />` etc).
|
|
977
1415
|
for m in _A_NO_HREF_RE.finditer(text):
|
|
978
1416
|
issues.append({
|
|
979
1417
|
"rule": "link-href",
|
|
980
1418
|
"severity": "warning",
|
|
981
1419
|
"message": "Anchor element missing href attribute",
|
|
982
1420
|
"file": rel,
|
|
983
|
-
"standard": "
|
|
1421
|
+
"standard": _stamp_rule("link-href"),
|
|
1422
|
+
"wcag": IMPLEMENTED_WCAG_RULES["link-href"],
|
|
984
1423
|
"snippet": m.group()[:120],
|
|
985
1424
|
})
|
|
986
1425
|
|
|
987
|
-
# Heading level skips (
|
|
1426
|
+
# Heading level skips (WCAG 1.3.1, Level A)
|
|
988
1427
|
headings = [int(h) for h in _HEADING_SKIP_RE.findall(text)]
|
|
989
1428
|
for i in range(1, len(headings)):
|
|
990
1429
|
if headings[i] > headings[i - 1] + 1:
|
|
@@ -993,21 +1432,23 @@ def story_accessibility(
|
|
|
993
1432
|
"severity": "warning",
|
|
994
1433
|
"message": f"Heading level skipped: h{headings[i-1]} to h{headings[i]}",
|
|
995
1434
|
"file": rel,
|
|
996
|
-
"standard": "
|
|
1435
|
+
"standard": _stamp_rule("heading-order"),
|
|
1436
|
+
"wcag": IMPLEMENTED_WCAG_RULES["heading-order"],
|
|
997
1437
|
})
|
|
998
1438
|
|
|
999
|
-
# aria-hidden on focusable elements
|
|
1439
|
+
# aria-hidden on focusable elements (WCAG 4.1.2, Level A)
|
|
1000
1440
|
for m in _ARIA_HIDDEN_FOCUSABLE_RE.finditer(text):
|
|
1001
1441
|
issues.append({
|
|
1002
1442
|
"rule": "aria-hidden-focusable",
|
|
1003
1443
|
"severity": "error",
|
|
1004
1444
|
"message": "Focusable element has aria-hidden='true'",
|
|
1005
1445
|
"file": rel,
|
|
1006
|
-
"standard": "
|
|
1446
|
+
"standard": _stamp_rule("aria-hidden-focusable"),
|
|
1447
|
+
"wcag": IMPLEMENTED_WCAG_RULES["aria-hidden-focusable"],
|
|
1007
1448
|
"snippet": m.group()[:120],
|
|
1008
1449
|
})
|
|
1009
1450
|
|
|
1010
|
-
# Filter by standard level
|
|
1451
|
+
# Filter by requested standard level
|
|
1011
1452
|
standard_levels = {"WCAG2A": 1, "WCAG2AA": 2, "WCAG2AAA": 3}
|
|
1012
1453
|
requested_level = standard_levels.get(standards, 2)
|
|
1013
1454
|
filtered = [i for i in issues if standard_levels.get(i.get("standard", "WCAG2A"), 1) <= requested_level]
|
|
@@ -1015,11 +1456,50 @@ def story_accessibility(
|
|
|
1015
1456
|
errors = [i for i in filtered if i["severity"] == "error"]
|
|
1016
1457
|
warnings = [i for i in filtered if i["severity"] == "warning"]
|
|
1017
1458
|
|
|
1459
|
+
# LED-1010: group by (rule, file) so 77 input-label errors across 30+
|
|
1460
|
+
# files can be triaged as ~30 groups with counts rather than 77 individual
|
|
1461
|
+
# call-sites in the caller's inbox.
|
|
1462
|
+
groups: Dict[tuple, Dict[str, Any]] = {}
|
|
1463
|
+
for issue in filtered:
|
|
1464
|
+
key = (issue["rule"], issue.get("file", ""))
|
|
1465
|
+
if key not in groups:
|
|
1466
|
+
groups[key] = {
|
|
1467
|
+
"rule": issue["rule"],
|
|
1468
|
+
"file": issue.get("file", ""),
|
|
1469
|
+
"count": 0,
|
|
1470
|
+
"severity": issue["severity"],
|
|
1471
|
+
"standard": issue.get("standard", "WCAG2A"),
|
|
1472
|
+
}
|
|
1473
|
+
groups[key]["count"] += 1
|
|
1474
|
+
|
|
1475
|
+
# LED-1010 coverage: we implement ~7 rules; WCAG2AA covers many more.
|
|
1476
|
+
# Count rules at/below the requested level to be honest about coverage.
|
|
1477
|
+
implemented_at_level = [
|
|
1478
|
+
r for r, info in IMPLEMENTED_WCAG_RULES.items()
|
|
1479
|
+
if standard_levels.get("WCAG2" + info["level"], 1) <= requested_level
|
|
1480
|
+
]
|
|
1481
|
+
unimplemented_at_level = [
|
|
1482
|
+
r for r in UNIMPLEMENTED_WCAG_RULES_AA
|
|
1483
|
+
if standard_levels.get("WCAG2" + r["level"], 1) <= requested_level
|
|
1484
|
+
]
|
|
1485
|
+
total_rules = max(1, len(implemented_at_level) + len(unimplemented_at_level))
|
|
1486
|
+
coverage_pct = len(implemented_at_level) * 100 // total_rules
|
|
1487
|
+
|
|
1488
|
+
# Status: partial_coverage when coverage < 100%. A `status: ok` on a scan
|
|
1489
|
+
# that ran 7 of ~50 WCAG2AA rules is exactly the silent-false-confidence
|
|
1490
|
+
# failure mode LED-1010 flagged.
|
|
1491
|
+
status = STATUS_PARTIAL_COVERAGE if coverage_pct < 100 else STATUS_OK
|
|
1492
|
+
|
|
1018
1493
|
return {
|
|
1019
1494
|
"tool": "story.accessibility",
|
|
1020
|
-
"status":
|
|
1021
|
-
"
|
|
1495
|
+
"status": status,
|
|
1496
|
+
"standard_requested": standards,
|
|
1497
|
+
"standard": standards, # retained for back-compat
|
|
1498
|
+
"implemented_rules": implemented_at_level,
|
|
1499
|
+
"unimplemented_rules": unimplemented_at_level,
|
|
1500
|
+
"coverage_percent": coverage_pct,
|
|
1022
1501
|
"issues": filtered,
|
|
1502
|
+
"groups": sorted(groups.values(), key=lambda g: (-g["count"], g["file"])),
|
|
1023
1503
|
"passed_count": files_checked - len(set(i["file"] for i in errors)),
|
|
1024
1504
|
"failed_count": len(set(i["file"] for i in errors)),
|
|
1025
1505
|
"error_count": len(errors),
|