delimit-cli 4.3.4 → 4.4.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +25 -18
  3. package/adapters/codex-security.js +64 -0
  4. package/adapters/codex-skill.js +78 -0
  5. package/adapters/cursor-rules.js +73 -0
  6. package/bin/delimit-setup.js +23 -0
  7. package/gateway/ai/backends/governance_bridge.py +168 -2
  8. package/gateway/ai/backends/tools_design.py +563 -83
  9. package/gateway/ai/backends/tools_infra.py +11 -4
  10. package/gateway/ai/backends/tools_real.py +3 -1
  11. package/gateway/ai/content_grounding/__init__.py +98 -0
  12. package/gateway/ai/content_grounding/build.py +350 -0
  13. package/gateway/ai/content_grounding/consume.py +280 -0
  14. package/gateway/ai/content_grounding/features.py +218 -0
  15. package/gateway/ai/content_grounding/fixtures/fail/01_missing_evidence.json +9 -0
  16. package/gateway/ai/content_grounding/fixtures/fail/02_unknown_evidence_prefix.json +9 -0
  17. package/gateway/ai/content_grounding/fixtures/fail/03_banned_comparative.json +17 -0
  18. package/gateway/ai/content_grounding/fixtures/fail/04_banned_adoption.json +17 -0
  19. package/gateway/ai/content_grounding/fixtures/fail/05_aggregate_no_numeric.json +17 -0
  20. package/gateway/ai/content_grounding/fixtures/fail/06_unversioned_inference_rule.json +18 -0
  21. package/gateway/ai/content_grounding/fixtures/pass/01_feature_shipped.json +18 -0
  22. package/gateway/ai/content_grounding/fixtures/pass/02_aggregate_claim.json +23 -0
  23. package/gateway/ai/content_grounding/fixtures/pass/03_attestation.json +16 -0
  24. package/gateway/ai/content_grounding/schemas/claim.schema.json +40 -0
  25. package/gateway/ai/content_grounding/schemas/event.schema.json +23 -0
  26. package/gateway/ai/content_grounding/schemas.py +276 -0
  27. package/gateway/ai/content_grounding/telemetry.py +221 -0
  28. package/gateway/ai/governance.py +89 -0
  29. package/gateway/ai/hot_reload.py +148 -7
  30. package/gateway/ai/ledger_manager.py +9 -2
  31. package/gateway/ai/license_core.py +3 -1
  32. package/gateway/ai/mcp_bridge.py +1 -1
  33. package/gateway/ai/reddit_proxy.py +8 -6
  34. package/gateway/ai/server.py +27 -0
  35. package/gateway/ai/supabase_sync.py +47 -7
  36. package/gateway/ai/swarm.py +1 -1
  37. package/gateway/ai/workers/executor.py +1 -1
  38. package/gateway/core/zero_spec/express_extractor.py +1 -1
  39. package/lib/delimit-template.js +5 -0
  40. 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 _extract_css_variables(text: str) -> Dict[str, List[Dict[str, str]]]:
75
- """Extract CSS custom properties grouped by category."""
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
- for name, value in _CSS_VAR_RE.findall(text):
82
- value = value.strip()
83
- entry = {"name": f"--{name}", "value": value}
84
- lower = name.lower()
85
- if any(k in lower for k in ("color", "bg", "text", "border", "fill", "stroke", "accent", "primary", "secondary")):
86
- colors.append(entry)
87
- elif any(k in lower for k in ("space", "gap", "margin", "padding", "size", "width", "height", "radius")):
88
- spacing.append(entry)
89
- elif any(k in lower for k in ("font", "line", "letter", "text", "heading")):
90
- typography.append(entry)
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 {"colors": colors, "spacing": spacing, "typography": typography, "other": other}
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] = {"colors": [], "spacing": [], "typography": [], "breakpoints": [], "other": []}
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
- # 1. Tailwind config
216
- for tw_name in ("tailwind.config.js", "tailwind.config.ts", "tailwind.config.mjs", "tailwind.config.cjs"):
217
- tw_path = root / tw_name
218
- if tw_path.exists():
219
- text = _read_text(tw_path)
220
- parsed = _parse_tailwind_config(text)
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
- # 2. CSS / SCSS files
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
- vars_found = _extract_css_variables(text)
236
- for cat in ("colors", "spacing", "typography", "other"):
237
- for entry in vars_found[cat]:
238
- entry["source"] = str(cf)
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({"value": bp_val.strip(), "source": str(cf)})
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
- # Deduplicate breakpoints
250
- seen_bp = set()
251
- unique_bp = []
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 not in seen_bp:
255
- seen_bp.add(key)
256
- unique_bp.append(bp)
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
- total = sum(len(v) for v in all_tokens.values())
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": "ok",
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 (Playwright optional)."""
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
- if _VIEWPORT_META_RE.search(text):
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 any(
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
- return {
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": "ok",
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": min_width_count >= max_width_count,
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": "puppeteer_error",
770
- "error": result.stderr.decode(errors="replace")[:500],
771
- "hint": "Puppeteer fallback failed. Install Playwright for better support: pip install playwright && python -m playwright install chromium",
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": "no_screenshot_tool",
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 tool available. Install one of the following:\n"
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
- _IMG_NO_ALT_RE = re.compile(r"<img(?![^>]*alt=)[^>]*>", re.IGNORECASE)
913
- _INPUT_NO_LABEL_RE = re.compile(r"<input(?![^>]*(?:aria-label|aria-labelledby|id=)[^>]*>)[^>]*>", re.IGNORECASE)
914
- _BUTTON_EMPTY_RE = re.compile(r"<button[^>]*>\s*</button>", re.IGNORECASE)
915
- _A_NO_HREF_RE = re.compile(r"<a(?![^>]*href=)[^>]*>", re.IGNORECASE)
916
- _HEADING_SKIP_RE = re.compile(r"<h([1-6])")
917
- _ARIA_HIDDEN_FOCUSABLE_RE = re.compile(r'aria-hidden=["\']true["\'][^>]*(?:tabindex=["\']0["\']|<button|<a\s)', re.IGNORECASE)
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": "WCAG2A",
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": "WCAG2A",
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": "WCAG2A",
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": "WCAG2A",
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 (e.g., h1 -> h3 without h2)
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": "WCAG2A",
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": "WCAG2AA",
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 if needed
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": "ok",
1021
- "standard": standards,
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),