devcouncil 0.2.0 → 0.3.1

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 (98) hide show
  1. package/README.md +12 -1
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/devcouncil/app/config.py +181 -7
  5. package/src/devcouncil/app/orchestrator.py +10 -6
  6. package/src/devcouncil/app/state_machine.py +4 -0
  7. package/src/devcouncil/artifacts/graph.py +9 -2
  8. package/src/devcouncil/cli/commands/check.py +12 -1
  9. package/src/devcouncil/cli/commands/design.py +186 -0
  10. package/src/devcouncil/cli/commands/doctor.py +160 -3
  11. package/src/devcouncil/cli/commands/go.py +96 -16
  12. package/src/devcouncil/cli/commands/hook.py +172 -0
  13. package/src/devcouncil/cli/commands/init.py +7 -2
  14. package/src/devcouncil/cli/commands/integrate.py +492 -34
  15. package/src/devcouncil/cli/commands/logs.py +106 -0
  16. package/src/devcouncil/cli/commands/okf.py +245 -0
  17. package/src/devcouncil/cli/commands/plan.py +54 -14
  18. package/src/devcouncil/cli/commands/repair.py +12 -3
  19. package/src/devcouncil/cli/commands/run.py +128 -7
  20. package/src/devcouncil/cli/commands/skills.py +180 -1
  21. package/src/devcouncil/cli/commands/status.py +7 -16
  22. package/src/devcouncil/cli/commands/verify.py +16 -10
  23. package/src/devcouncil/cli/commands/watch.py +24 -4
  24. package/src/devcouncil/cli/main.py +36 -1
  25. package/src/devcouncil/domain/evidence.py +7 -0
  26. package/src/devcouncil/execution/checkpoints.py +12 -2
  27. package/src/devcouncil/execution/fs_watcher.py +27 -2
  28. package/src/devcouncil/execution/handoff.py +1 -1
  29. package/src/devcouncil/execution/patch.py +6 -0
  30. package/src/devcouncil/execution/permissions.py +7 -0
  31. package/src/devcouncil/execution/policy_engine.py +12 -5
  32. package/src/devcouncil/execution/prompt_builder.py +126 -10
  33. package/src/devcouncil/execution/shell_session.py +6 -0
  34. package/src/devcouncil/execution/task_runner.py +18 -7
  35. package/src/devcouncil/executors/agent_registry.py +22 -1
  36. package/src/devcouncil/executors/coding_cli.py +133 -5
  37. package/src/devcouncil/executors/mini_swe.py +6 -0
  38. package/src/devcouncil/executors/native/agent.py +15 -0
  39. package/src/devcouncil/executors/openhands.py +6 -0
  40. package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
  41. package/src/devcouncil/gating/policy.py +38 -7
  42. package/src/devcouncil/indexing/ast_matcher.py +16 -6
  43. package/src/devcouncil/indexing/repo_mapper.py +30 -8
  44. package/src/devcouncil/indexing/semantic_index.py +42 -26
  45. package/src/devcouncil/integrations/actions.py +24 -4
  46. package/src/devcouncil/integrations/check.py +7 -4
  47. package/src/devcouncil/integrations/claude_assets.py +444 -0
  48. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  49. package/src/devcouncil/integrations/github_intent.py +8 -1
  50. package/src/devcouncil/integrations/gitnexus.py +10 -2
  51. package/src/devcouncil/integrations/mcp/server.py +404 -15
  52. package/src/devcouncil/integrations/pr_comments.py +9 -0
  53. package/src/devcouncil/knowledge/__init__.py +23 -0
  54. package/src/devcouncil/knowledge/design.py +374 -0
  55. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  56. package/src/devcouncil/knowledge/fetch.py +223 -0
  57. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  58. package/src/devcouncil/knowledge/okf.py +202 -0
  59. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  60. package/src/devcouncil/knowledge/sources.py +239 -0
  61. package/src/devcouncil/live/cards.py +20 -6
  62. package/src/devcouncil/live/repair_prompt.py +29 -6
  63. package/src/devcouncil/live/reviewer.py +72 -13
  64. package/src/devcouncil/live/summary.py +18 -8
  65. package/src/devcouncil/live/transcripts.py +38 -5
  66. package/src/devcouncil/llm/cache.py +14 -6
  67. package/src/devcouncil/llm/provider.py +179 -92
  68. package/src/devcouncil/llm/router.py +122 -23
  69. package/src/devcouncil/optimization/skillopt.py +673 -0
  70. package/src/devcouncil/planning/arbiter_service.py +10 -2
  71. package/src/devcouncil/planning/correction_manifest.py +47 -4
  72. package/src/devcouncil/planning/critique_service.py +9 -2
  73. package/src/devcouncil/planning/plan_service.py +69 -3
  74. package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
  75. package/src/devcouncil/planning/repair_service.py +8 -2
  76. package/src/devcouncil/planning/spec_service.py +10 -2
  77. package/src/devcouncil/repo/ci_scaffold.py +13 -5
  78. package/src/devcouncil/repo/sca.py +11 -1
  79. package/src/devcouncil/reporting/json_report.py +11 -0
  80. package/src/devcouncil/reporting/markdown_report.py +14 -1
  81. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  82. package/src/devcouncil/reporting/okf_html.py +323 -0
  83. package/src/devcouncil/reporting/report_builder.py +18 -1
  84. package/src/devcouncil/skills/registry.py +111 -33
  85. package/src/devcouncil/storage/db.py +58 -2
  86. package/src/devcouncil/storage/models.py +4 -0
  87. package/src/devcouncil/storage/native.py +20 -18
  88. package/src/devcouncil/storage/repositories.py +35 -18
  89. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  90. package/src/devcouncil/telemetry/stages.py +141 -0
  91. package/src/devcouncil/telemetry/tracker.py +12 -1
  92. package/src/devcouncil/ui/dashboard.py +69 -5
  93. package/src/devcouncil/verification/acceptance_compiler.py +147 -19
  94. package/src/devcouncil/verification/ad_hoc_check.py +6 -0
  95. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  96. package/src/devcouncil/verification/sandbox.py +7 -4
  97. package/src/devcouncil/verification/verifier.py +905 -517
  98. package/uv.lock +1 -1
@@ -0,0 +1,374 @@
1
+ """design.md (google-labs-code, alpha) — model, lint, and export.
2
+
3
+ A design.md file pairs machine-readable design *tokens* (YAML frontmatter: colors,
4
+ typography, rounded, spacing, components) with human-readable rationale (a markdown body
5
+ of canonical sections). DevCouncil parses it so the design system can be (a) injected as
6
+ agent context and (b) validated/converted, mirroring the upstream ``@google/design.md``
7
+ CLI's ``lint`` and ``export`` subcommands.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from pathlib import Path
15
+ from typing import Any, Literal
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+ from devcouncil.knowledge.frontmatter import split_frontmatter
20
+ # Cycle-safe: knowledge.okf does not import this module (or the skills package).
21
+ from devcouncil.knowledge.okf import OKFDocument
22
+
23
+ # Canonical section order from the design.md spec; sections that ARE present must appear
24
+ # in this relative order. Lowercased for comparison.
25
+ CANONICAL_SECTIONS = [
26
+ "overview",
27
+ "colors",
28
+ "typography",
29
+ "layout",
30
+ "elevation & depth",
31
+ "shapes",
32
+ "components",
33
+ "do's and don'ts",
34
+ ]
35
+ # O(1) membership companion to the ordered list above.
36
+ _CANONICAL_SET = frozenset(CANONICAL_SECTIONS)
37
+
38
+ # Token categories a component property may reference (e.g. "colors.primary").
39
+ _TOKEN_CATEGORIES = ("colors", "typography", "rounded", "spacing")
40
+
41
+ # A token reference is either dotted (colors.primary) or brace-wrapped ({colors.primary}).
42
+ _REF_RE = re.compile(r"^\{?\s*(?P<cat>colors|typography|rounded|spacing)\.(?P<name>[\w-]+)\s*\}?$")
43
+ _HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
44
+ _HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
45
+
46
+ Severity = Literal["error", "warning", "info"]
47
+
48
+
49
+ class Finding(BaseModel):
50
+ """A single lint finding."""
51
+
52
+ rule: str
53
+ severity: Severity
54
+ message: str
55
+
56
+ def format(self) -> str:
57
+ return f"[{self.severity}] {self.rule}: {self.message}"
58
+
59
+
60
+ class DesignSystem(BaseModel):
61
+ """Parsed design.md: tokens (frontmatter) plus ordered markdown sections."""
62
+
63
+ name: str = ""
64
+ colors: dict[str, Any] = Field(default_factory=dict)
65
+ typography: dict[str, Any] = Field(default_factory=dict)
66
+ rounded: dict[str, Any] = Field(default_factory=dict)
67
+ spacing: dict[str, Any] = Field(default_factory=dict)
68
+ components: dict[str, Any] = Field(default_factory=dict)
69
+ # (heading text, body) pairs in document order.
70
+ sections: list[tuple[str, str]] = Field(default_factory=list)
71
+ body: str = ""
72
+
73
+ def category(self, name: str) -> dict[str, Any]:
74
+ return {
75
+ "colors": self.colors,
76
+ "typography": self.typography,
77
+ "rounded": self.rounded,
78
+ "spacing": self.spacing,
79
+ }.get(name, {})
80
+
81
+
82
+ def _parse_sections(body: str) -> list[tuple[str, str]]:
83
+ """Split a markdown body into (heading, section-body) pairs at ATX headings."""
84
+ sections: list[tuple[str, str]] = []
85
+ current_heading: str | None = None
86
+ buf: list[str] = []
87
+ for line in body.splitlines():
88
+ m = _HEADING_RE.match(line)
89
+ if m:
90
+ if current_heading is not None:
91
+ sections.append((current_heading, "\n".join(buf).strip()))
92
+ current_heading = m.group(2).strip()
93
+ buf = []
94
+ else:
95
+ buf.append(line)
96
+ if current_heading is not None:
97
+ sections.append((current_heading, "\n".join(buf).strip()))
98
+ return sections
99
+
100
+
101
+ def parse_design_md(source: str | Path) -> DesignSystem:
102
+ """Parse a design.md document from a path or raw text."""
103
+ if isinstance(source, Path):
104
+ text = source.read_text(encoding="utf-8")
105
+ else:
106
+ text = source
107
+ meta, body = split_frontmatter(text)
108
+
109
+ def _as_dict(value: Any) -> dict[str, Any]:
110
+ return value if isinstance(value, dict) else {}
111
+
112
+ return DesignSystem(
113
+ name=str(meta.get("name") or ""),
114
+ colors=_as_dict(meta.get("colors")),
115
+ typography=_as_dict(meta.get("typography")),
116
+ rounded=_as_dict(meta.get("rounded")),
117
+ spacing=_as_dict(meta.get("spacing")),
118
+ components=_as_dict(meta.get("components")),
119
+ sections=_parse_sections(body),
120
+ body=body.strip(),
121
+ )
122
+
123
+
124
+ def design_system_to_okf_document(
125
+ ds: DesignSystem, rel_path: str = "design/design.md"
126
+ ) -> OKFDocument:
127
+ """Render a :class:`DesignSystem` as an OKF document for inclusion in a bundle.
128
+
129
+ Mirrors :func:`skill_bridge.skill_to_okf_document` so design knowledge travels in an OKF
130
+ bundle alongside skills and the artifact graph. The body is a deterministic, readable
131
+ rendering of the design tokens (in fixed category order, preserving each category's own
132
+ key order) followed by the human-readable rationale (``ds.body``). ``tags`` are left empty
133
+ and ``timestamp`` is left to the caller (a design system is library content, not a
134
+ timestamped artifact).
135
+ """
136
+ title = ds.name or "Design System"
137
+ lines: list[str] = []
138
+
139
+ def _emit(label: str, mapping: dict[str, Any]) -> None:
140
+ if not mapping:
141
+ return
142
+ lines.append(f"### {label}")
143
+ for name, value in mapping.items():
144
+ if isinstance(value, dict):
145
+ inner = ", ".join(f"{k}: {v}" for k, v in value.items())
146
+ lines.append(f"- **{name}**: {inner}")
147
+ else:
148
+ lines.append(f"- **{name}**: {value}")
149
+ lines.append("")
150
+
151
+ _emit("Colors", ds.colors)
152
+ _emit("Typography", ds.typography)
153
+ _emit("Rounded", ds.rounded)
154
+ _emit("Spacing", ds.spacing)
155
+ _emit("Components", ds.components)
156
+
157
+ if ds.body:
158
+ lines.append("## Rationale")
159
+ lines.append("")
160
+ lines.append(ds.body)
161
+
162
+ return OKFDocument(
163
+ type="Design System",
164
+ title=title,
165
+ description=f"Design system tokens and guidance for {title}."[:280],
166
+ tags=[],
167
+ timestamp="",
168
+ body="\n".join(lines).strip(),
169
+ rel_path=rel_path,
170
+ )
171
+
172
+
173
+ def _iter_component_refs(ds: DesignSystem):
174
+ """Yield (component, prop, value) for every component property that is a string."""
175
+ for comp_name, props in ds.components.items():
176
+ if not isinstance(props, dict):
177
+ continue
178
+ for prop, value in props.items():
179
+ if isinstance(value, str):
180
+ yield comp_name, prop, value
181
+
182
+
183
+ def _hex_to_rgb(value: str) -> tuple[int, int, int] | None:
184
+ if not _HEX_RE.match(value):
185
+ return None
186
+ h = value.lstrip("#")
187
+ if len(h) == 3:
188
+ h = "".join(ch * 2 for ch in h)
189
+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
190
+
191
+
192
+ def _relative_luminance(rgb: tuple[int, int, int]) -> float:
193
+ def chan(c: int) -> float:
194
+ s = c / 255.0
195
+ return s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4
196
+
197
+ r, g, b = (chan(c) for c in rgb)
198
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
199
+
200
+
201
+ def contrast_ratio(fg: str, bg: str) -> float | None:
202
+ """WCAG contrast ratio between two hex colors, or ``None`` if either isn't hex."""
203
+ frgb, brgb = _hex_to_rgb(fg), _hex_to_rgb(bg)
204
+ if frgb is None or brgb is None:
205
+ return None
206
+ lf, lb = _relative_luminance(frgb), _relative_luminance(brgb)
207
+ lighter, darker = max(lf, lb), min(lf, lb)
208
+ return (lighter + 0.05) / (darker + 0.05)
209
+
210
+
211
+ def _resolve_color(ds: DesignSystem, value: str) -> str | None:
212
+ """Resolve a component color value to a hex string (follows one token reference)."""
213
+ m = _REF_RE.match(value.strip())
214
+ if m and m.group("cat") == "colors":
215
+ resolved = ds.colors.get(m.group("name"))
216
+ return resolved if isinstance(resolved, str) else None
217
+ return value if _HEX_RE.match(value.strip()) else None
218
+
219
+
220
+ def lint(ds: DesignSystem) -> list[Finding]:
221
+ """Validate a design system. Mirrors a high-value subset of the upstream rules:
222
+ broken token references, missing primary color, low text/background contrast,
223
+ orphaned tokens, and canonical section ordering.
224
+ """
225
+ findings: list[Finding] = []
226
+ referenced: set[str] = set()
227
+
228
+ # broken-token-reference
229
+ for comp, prop, value in _iter_component_refs(ds):
230
+ m = _REF_RE.match(value.strip())
231
+ if not m:
232
+ continue
233
+ cat, name = m.group("cat"), m.group("name")
234
+ referenced.add(f"{cat}.{name}")
235
+ if name not in ds.category(cat):
236
+ findings.append(Finding(
237
+ rule="broken-token-reference",
238
+ severity="error",
239
+ message=f"components.{comp}.{prop} references '{cat}.{name}' which is not defined",
240
+ ))
241
+
242
+ # missing-primary-color
243
+ if ds.colors and "primary" not in ds.colors:
244
+ findings.append(Finding(
245
+ rule="missing-primary-color",
246
+ severity="warning",
247
+ message="no 'primary' color token is defined",
248
+ ))
249
+
250
+ # contrast: any component declaring both a text and background color
251
+ color_cache: dict[str, str | None] = {}
252
+ for comp, props in ds.components.items():
253
+ if not isinstance(props, dict):
254
+ continue
255
+ fg_raw = props.get("textColor") or props.get("color")
256
+ bg_raw = props.get("backgroundColor")
257
+ if not (isinstance(fg_raw, str) and isinstance(bg_raw, str)):
258
+ continue
259
+ fg = color_cache.setdefault(fg_raw, _resolve_color(ds, fg_raw))
260
+ bg = color_cache.setdefault(bg_raw, _resolve_color(ds, bg_raw))
261
+ if fg and bg:
262
+ ratio = contrast_ratio(fg, bg)
263
+ if ratio is not None and ratio < 4.5:
264
+ findings.append(Finding(
265
+ rule="contrast-ratio",
266
+ severity="warning",
267
+ message=f"components.{comp} text/background contrast is {ratio:.2f}:1 (WCAG AA needs 4.5:1)",
268
+ ))
269
+
270
+ # orphaned-token: color tokens defined but never referenced by a component
271
+ for name in ds.colors:
272
+ if ds.components and f"colors.{name}" not in referenced:
273
+ findings.append(Finding(
274
+ rule="orphaned-token",
275
+ severity="info",
276
+ message=f"color token 'colors.{name}' is never referenced by a component",
277
+ ))
278
+
279
+ # section-ordering (+ duplicate-section)
280
+ present = [low for h, _ in ds.sections if (low := h.lower()) in _CANONICAL_SET]
281
+ # A duplicated canonical section is its own problem; report it and de-duplicate before
282
+ # the ordering check, so a duplicated-but-correctly-ordered doc isn't mislabeled as
283
+ # "out of canonical order" (the duplicate alone made actual != expected).
284
+ seen: set[str] = set()
285
+ actual: list[str] = []
286
+ duplicates: list[str] = []
287
+ for name in present:
288
+ if name in seen:
289
+ if name not in duplicates:
290
+ duplicates.append(name)
291
+ else:
292
+ seen.add(name)
293
+ actual.append(name)
294
+ for name in duplicates:
295
+ findings.append(Finding(
296
+ rule="duplicate-section",
297
+ severity="warning",
298
+ message=f"section '{name}' appears more than once",
299
+ ))
300
+ expected = [name for name in CANONICAL_SECTIONS if name in seen]
301
+ if actual != expected:
302
+ findings.append(Finding(
303
+ rule="section-ordering",
304
+ severity="warning",
305
+ message=f"sections are out of canonical order: {actual} (expected {expected})",
306
+ ))
307
+
308
+ return findings
309
+
310
+
311
+ def _flatten_typography(value: Any) -> str:
312
+ """Render a typography token (dict of fontFamily/fontSize/...) as a CSS-ish summary."""
313
+ if isinstance(value, dict):
314
+ return "; ".join(f"{k}: {v}" for k, v in value.items())
315
+ return str(value)
316
+
317
+
318
+ def export(ds: DesignSystem, fmt: Literal["css", "tailwind", "w3c"]) -> str:
319
+ """Convert a design system to ``css`` custom properties, a ``tailwind`` theme-extend
320
+ config, or a ``w3c`` Design Tokens JSON document."""
321
+ if fmt == "css":
322
+ return _export_css(ds)
323
+ if fmt == "tailwind":
324
+ return _export_tailwind(ds)
325
+ if fmt == "w3c":
326
+ return _export_w3c(ds)
327
+ raise ValueError(f"unknown export format: {fmt!r}")
328
+
329
+
330
+ def _export_css(ds: DesignSystem) -> str:
331
+ lines = [":root {"]
332
+ for name, value in ds.colors.items():
333
+ lines.append(f" --color-{name}: {value};")
334
+ for name, value in ds.rounded.items():
335
+ lines.append(f" --rounded-{name}: {value};")
336
+ for name, value in ds.spacing.items():
337
+ lines.append(f" --spacing-{name}: {value};")
338
+ for name, value in ds.typography.items():
339
+ if isinstance(value, dict):
340
+ for prop, pval in value.items():
341
+ lines.append(f" --typography-{name}-{prop}: {pval};")
342
+ else:
343
+ lines.append(f" --typography-{name}: {value};")
344
+ lines.append("}")
345
+ return "\n".join(lines) + "\n"
346
+
347
+
348
+ def _export_tailwind(ds: DesignSystem) -> str:
349
+ theme: dict[str, Any] = {}
350
+ if ds.colors:
351
+ theme["colors"] = dict(ds.colors)
352
+ if ds.rounded:
353
+ theme["borderRadius"] = dict(ds.rounded)
354
+ if ds.spacing:
355
+ theme["spacing"] = dict(ds.spacing)
356
+ config = {"theme": {"extend": theme}}
357
+ return "/** @type {import('tailwindcss').Config} */\nmodule.exports = " + json.dumps(config, indent=2) + ";\n"
358
+
359
+
360
+ def _export_w3c(ds: DesignSystem) -> str:
361
+ """W3C Design Tokens Community Group format (``$value``/``$type`` groups)."""
362
+ out: dict[str, Any] = {}
363
+ if ds.colors:
364
+ out["color"] = {name: {"$value": value, "$type": "color"} for name, value in ds.colors.items()}
365
+ if ds.spacing:
366
+ out["spacing"] = {name: {"$value": value, "$type": "dimension"} for name, value in ds.spacing.items()}
367
+ if ds.rounded:
368
+ out["rounded"] = {name: {"$value": value, "$type": "dimension"} for name, value in ds.rounded.items()}
369
+ if ds.typography:
370
+ out["typography"] = {
371
+ name: {"$value": value if isinstance(value, dict) else {"value": value}, "$type": "typography"}
372
+ for name, value in ds.typography.items()
373
+ }
374
+ return json.dumps(out, indent=2) + "\n"
@@ -0,0 +1,317 @@
1
+ """Design-system conformance: prove code honored the design tokens.
2
+
3
+ The lint/export side of :mod:`devcouncil.knowledge.design` validates the *tokens*; this
4
+ module checks the *consumers*. It scans source / stylesheet text for hardcoded style
5
+ literals (hex colors, ``px`` font-size / spacing values) that bypass the design system's
6
+ tokens, so a project can fail CI / a pre-commit hook when an agent (or human) hand-rolls a
7
+ color instead of referencing ``colors.primary``.
8
+
9
+ Heuristics are deliberately conservative — the goal is high-signal, low-noise, because a
10
+ false positive that blocks CI is worse than a missed literal:
11
+
12
+ * We only inspect *declarations* whose property name looks like styling (``color:``,
13
+ ``background:``, ``font-size:``, ``margin:``, ``padding:``, …, plus the camelCase JS/TS
14
+ style-object spellings like ``backgroundColor``). Arbitrary hex/px elsewhere is ignored.
15
+ * A literal that exactly matches a defined token value is allowed (that's the token's
16
+ value, just written out).
17
+ * We only flag a *kind* when the design system actually defines tokens of that kind — you
18
+ cannot "bypass" a scale that doesn't exist, and judging it would only add noise.
19
+ * Comments (``/* … */`` and ``//``) are stripped before scanning.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from pathlib import Path
26
+ from typing import Any, Iterable
27
+
28
+ from pydantic import BaseModel
29
+
30
+ from devcouncil.knowledge.design import DesignSystem
31
+
32
+ # File extensions worth scanning for style literals.
33
+ STYLE_EXTENSIONS = frozenset(
34
+ {".css", ".scss", ".sass", ".less", ".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte"}
35
+ )
36
+
37
+ # Property names (normalized to lowercase letters-only, so "background-color" and
38
+ # "backgroundColor" both collapse to "backgroundcolor") that carry a *color* value.
39
+ _COLOR_PROPS = frozenset({
40
+ "color", "background", "backgroundcolor", "border", "bordercolor",
41
+ "bordertopcolor", "borderrightcolor", "borderbottomcolor", "borderleftcolor",
42
+ "outline", "outlinecolor", "fill", "stroke", "boxshadow", "textshadow",
43
+ "caretcolor", "accentcolor", "columnrulecolor", "textdecorationcolor",
44
+ })
45
+ # Property names that carry a font-size value.
46
+ _FONT_SIZE_PROPS = frozenset({"fontsize"})
47
+ # Property names that carry a spacing (length) value.
48
+ _SPACING_PROPS = frozenset({
49
+ "margin", "margintop", "marginright", "marginbottom", "marginleft",
50
+ "padding", "paddingtop", "paddingright", "paddingbottom", "paddingleft",
51
+ "gap", "rowgap", "columngap", "gridgap",
52
+ })
53
+
54
+ # A single property:value declaration. The value stops at a comma so JS style objects
55
+ # ({ fontSize: '20px', color: '#fff' }) and CSS rgba()/gradients don't swallow the next
56
+ # declaration; this can under-report multi-literal CSS values, which is the safe direction.
57
+ _DECL_RE = re.compile(r"(?P<prop>[A-Za-z][A-Za-z-]*)\s*:\s*(?P<value>[^;{}\n,]*)")
58
+ # Hex colors: #rgb / #rgba / #rrggbb / #rrggbbaa.
59
+ _HEX_RE = re.compile(r"#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b")
60
+ # A px length literal (not preceded by a word char / dot, so "12.5px" is one token).
61
+ _PX_RE = re.compile(r"(?<![\w.])(\d+(?:\.\d+)?)px\b")
62
+ # A token value that is a bare or px length.
63
+ _PX_TOKEN_RE = re.compile(r"^(\d+(?:\.\d+)?)px$")
64
+ _NUM_TOKEN_RE = re.compile(r"^\d+(?:\.\d+)?$")
65
+ # Strips everything but lowercase letters for property-name normalization (hot scan loop).
66
+ _NORM_PROP_RE = re.compile(r"[^a-z]")
67
+
68
+
69
+ def _quoted_spans(line: str) -> list[tuple[int, int]]:
70
+ """Index ranges of ``line`` that sit inside a ``'`` or ``"`` string literal.
71
+
72
+ Used to drop declarations whose *property name* lives inside a plain string — e.g. a
73
+ ``color: #ff0000`` substring in ``console.log("color: #ff0000")`` is a log message, not
74
+ a real style declaration, and flagging it is exactly the false positive the module's
75
+ contract warns against. Backtick template literals are intentionally NOT treated as
76
+ strings, so CSS-in-JS (styled-components) hardcoded values stay scannable. Escape-aware
77
+ and per-line (matching the existing per-line scan; multi-line strings aren't tracked)."""
78
+ spans: list[tuple[int, int]] = []
79
+ quote = ""
80
+ start = 0
81
+ i = 0
82
+ n = len(line)
83
+ while i < n:
84
+ ch = line[i]
85
+ if quote:
86
+ if ch == "\\":
87
+ i += 2
88
+ continue
89
+ if ch == quote:
90
+ spans.append((start, i))
91
+ quote = ""
92
+ elif ch in "\"'":
93
+ quote = ch
94
+ start = i + 1
95
+ i += 1
96
+ if quote: # unterminated quote: treat the rest of the line as string
97
+ spans.append((start, n))
98
+ return spans
99
+
100
+
101
+ class Violation(BaseModel):
102
+ """A hardcoded style literal that bypasses a design token."""
103
+
104
+ file: str
105
+ line: int
106
+ kind: str # 'color' | 'font-size' | 'spacing'
107
+ snippet: str
108
+ message: str
109
+
110
+ def format(self) -> str:
111
+ loc = f"{self.file}:{self.line}" if self.file else f"line {self.line}"
112
+ return f"{loc} [{self.kind}] {self.message}"
113
+
114
+
115
+ def _normalize_prop(prop: str) -> str:
116
+ """Collapse a CSS/JS property name to lowercase letters only for set membership."""
117
+ return _NORM_PROP_RE.sub("", prop.lower())
118
+
119
+
120
+ def _normalize_hex(value: str) -> str:
121
+ """Lowercase a hex color and expand 3/4-digit shorthand to 6/8 digits."""
122
+ h = value[1:].lower()
123
+ if len(h) in (3, 4):
124
+ h = "".join(ch * 2 for ch in h)
125
+ return "#" + h
126
+
127
+
128
+ def _color_token_values(ds: DesignSystem) -> set[str]:
129
+ """Normalized hex values declared in the design system's color tokens."""
130
+ out: set[str] = set()
131
+ for value in ds.colors.values():
132
+ if isinstance(value, str) and _HEX_RE.fullmatch(value.strip()):
133
+ out.add(_normalize_hex(value.strip()))
134
+ return out
135
+
136
+
137
+ def _px_values(values: Iterable[Any]) -> set[float]:
138
+ """Numeric px-equivalents from token values (``"8px"`` or bare ``8``)."""
139
+ out: set[float] = set()
140
+ for value in values:
141
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
142
+ out.add(float(value))
143
+ continue
144
+ if not isinstance(value, str):
145
+ continue
146
+ s = value.strip()
147
+ m = _PX_TOKEN_RE.match(s) or _NUM_TOKEN_RE.match(s)
148
+ if m:
149
+ out.add(float(m.group(1) if m.re is _PX_TOKEN_RE else s))
150
+ return out
151
+
152
+
153
+ def _font_size_scale(ds: DesignSystem) -> set[float]:
154
+ """px font sizes declared across the typography tokens."""
155
+ candidates: list[Any] = []
156
+ for value in ds.typography.values():
157
+ if isinstance(value, dict):
158
+ for key, inner in value.items():
159
+ if "size" in key.lower():
160
+ candidates.append(inner)
161
+ else:
162
+ candidates.append(value)
163
+ return _px_values(candidates)
164
+
165
+
166
+ def _spacing_scale(ds: DesignSystem) -> set[float]:
167
+ """px lengths declared in the spacing token scale."""
168
+ return _px_values(ds.spacing.values())
169
+
170
+
171
+ def _strip_comments(text: str) -> list[str]:
172
+ """Return per-line text with ``/* … */`` and ``//`` comments blanked out.
173
+
174
+ Line count is preserved so reported line numbers stay accurate. String literals are
175
+ tracked so a ``//`` *inside* a string (e.g. ``url('http://x')`` or ``"http://x"``) is
176
+ NOT mistaken for a line comment — otherwise a stray ``color: #f00`` after a URL on the
177
+ same line would be silently dropped. A bare ``scheme://`` (``//`` preceded by ``:``) is
178
+ likewise treated as a URL, not a comment. ``/* … */`` blocks still span lines.
179
+ """
180
+ out: list[str] = []
181
+ in_block = False
182
+ for line in text.splitlines():
183
+ res: list[str] = []
184
+ i, n = 0, len(line)
185
+ quote: str | None = None # active string delimiter within this line
186
+ while i < n:
187
+ ch = line[i]
188
+ two = line[i:i + 2]
189
+ if in_block:
190
+ if two == "*/":
191
+ in_block = False
192
+ i += 2
193
+ else:
194
+ i += 1
195
+ continue
196
+ if quote is not None:
197
+ res.append(ch)
198
+ if ch == "\\" and i + 1 < n: # keep an escaped char verbatim
199
+ res.append(line[i + 1])
200
+ i += 2
201
+ continue
202
+ if ch == quote:
203
+ quote = None
204
+ i += 1
205
+ continue
206
+ if ch in ("'", '"', "`"):
207
+ quote = ch
208
+ res.append(ch)
209
+ i += 1
210
+ elif two == "/*":
211
+ in_block = True
212
+ i += 2
213
+ elif two == "//" and (not res or res[-1] != ":"):
214
+ break # a real line comment (not a scheme:// URL)
215
+ else:
216
+ res.append(ch)
217
+ i += 1
218
+ out.append("".join(res))
219
+ return out
220
+
221
+
222
+ def scan_text(text: str, ds: DesignSystem, filename: str = "") -> list[Violation]:
223
+ """Scan source/style ``text`` for hardcoded literals that bypass ``ds``'s tokens.
224
+
225
+ Returns one :class:`Violation` per offending literal, with 1-based line numbers. Only
226
+ declarations whose property name looks like styling are considered, literals matching a
227
+ token value are allowed, and a kind is only judged when the design system defines tokens
228
+ of that kind (see module docstring).
229
+ """
230
+ return _scan_text(
231
+ text, _color_token_values(ds), _font_size_scale(ds), _spacing_scale(ds), filename
232
+ )
233
+
234
+
235
+ def _scan_text(
236
+ text: str,
237
+ color_tokens: set[str],
238
+ font_scale: set[float],
239
+ spacing_scale: set[float],
240
+ filename: str = "",
241
+ ) -> list[Violation]:
242
+ """Scan one text against pre-computed token scales. The scales depend only on the
243
+ design system, so :func:`scan_files` computes them once and reuses them across files."""
244
+ violations: list[Violation] = []
245
+ for lineno, line in enumerate(_strip_comments(text), start=1):
246
+ quoted = _quoted_spans(line)
247
+ for m in _DECL_RE.finditer(line):
248
+ # Skip a "declaration" whose property name is inside a quoted string — it's a
249
+ # log/error/message string, not real styling (a CSS-in-JS backtick literal is
250
+ # not treated as a string, so styled-components values are still caught).
251
+ if any(s <= m.start("prop") < e for s, e in quoted):
252
+ continue
253
+ prop = _normalize_prop(m.group("prop"))
254
+ value = m.group("value")
255
+ snippet = m.group(0).strip()
256
+
257
+ if color_tokens and prop in _COLOR_PROPS:
258
+ for hm in _HEX_RE.finditer(value):
259
+ norm = _normalize_hex(hm.group(0))
260
+ if norm not in color_tokens:
261
+ violations.append(Violation(
262
+ file=filename, line=lineno, kind="color", snippet=snippet,
263
+ message=(
264
+ f"hardcoded color '{hm.group(0)}' bypasses design tokens; "
265
+ "use a colors.* token"
266
+ ),
267
+ ))
268
+
269
+ if font_scale and prop in _FONT_SIZE_PROPS:
270
+ for pm in _PX_RE.finditer(value):
271
+ num = float(pm.group(1))
272
+ if num != 0 and num not in font_scale:
273
+ violations.append(Violation(
274
+ file=filename, line=lineno, kind="font-size", snippet=snippet,
275
+ message=(
276
+ f"hardcoded font-size '{pm.group(0)}' is not in the typography "
277
+ "scale; use a typography token"
278
+ ),
279
+ ))
280
+
281
+ if spacing_scale and prop in _SPACING_PROPS:
282
+ for pm in _PX_RE.finditer(value):
283
+ num = float(pm.group(1))
284
+ if num != 0 and num not in spacing_scale:
285
+ violations.append(Violation(
286
+ file=filename, line=lineno, kind="spacing", snippet=snippet,
287
+ message=(
288
+ f"hardcoded spacing '{pm.group(0)}' is not in the spacing "
289
+ "scale; use a spacing token"
290
+ ),
291
+ ))
292
+
293
+ return violations
294
+
295
+
296
+ def scan_files(paths: list[Path], ds: DesignSystem) -> list[Violation]:
297
+ """Scan style-ish files for token-bypassing literals (best-effort, never raises).
298
+
299
+ Non-style extensions are skipped, and unreadable / binary files are silently ignored so
300
+ a single bad file never aborts a conformance check.
301
+ """
302
+ # Token scales depend only on the design system — compute once, not per file.
303
+ color_tokens = _color_token_values(ds)
304
+ font_scale = _font_size_scale(ds)
305
+ spacing_scale = _spacing_scale(ds)
306
+ violations: list[Violation] = []
307
+ for path in paths:
308
+ if path.suffix.lower() not in STYLE_EXTENSIONS:
309
+ continue
310
+ try:
311
+ text = path.read_text(encoding="utf-8")
312
+ except (OSError, UnicodeDecodeError, ValueError):
313
+ continue
314
+ violations.extend(
315
+ _scan_text(text, color_tokens, font_scale, spacing_scale, filename=str(path))
316
+ )
317
+ return violations