devcouncil 0.2.0 → 0.3.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/README.md +12 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/devcouncil/app/config.py +181 -7
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +9 -2
- package/src/devcouncil/cli/commands/check.py +12 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +160 -3
- package/src/devcouncil/cli/commands/go.py +96 -16
- package/src/devcouncil/cli/commands/hook.py +172 -0
- package/src/devcouncil/cli/commands/init.py +7 -2
- package/src/devcouncil/cli/commands/integrate.py +492 -34
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +54 -14
- package/src/devcouncil/cli/commands/repair.py +12 -3
- package/src/devcouncil/cli/commands/run.py +128 -7
- package/src/devcouncil/cli/commands/skills.py +180 -1
- package/src/devcouncil/cli/commands/status.py +7 -16
- package/src/devcouncil/cli/commands/verify.py +16 -10
- package/src/devcouncil/cli/commands/watch.py +24 -4
- package/src/devcouncil/cli/main.py +36 -1
- package/src/devcouncil/domain/evidence.py +7 -0
- package/src/devcouncil/execution/checkpoints.py +12 -2
- package/src/devcouncil/execution/fs_watcher.py +27 -2
- package/src/devcouncil/execution/handoff.py +1 -1
- package/src/devcouncil/execution/patch.py +6 -0
- package/src/devcouncil/execution/permissions.py +7 -0
- package/src/devcouncil/execution/policy_engine.py +12 -5
- package/src/devcouncil/execution/prompt_builder.py +126 -10
- package/src/devcouncil/execution/shell_session.py +6 -0
- package/src/devcouncil/execution/task_runner.py +18 -7
- package/src/devcouncil/executors/agent_registry.py +22 -1
- package/src/devcouncil/executors/coding_cli.py +133 -5
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +15 -0
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
- package/src/devcouncil/gating/policy.py +38 -7
- package/src/devcouncil/indexing/ast_matcher.py +16 -6
- package/src/devcouncil/indexing/repo_mapper.py +30 -8
- package/src/devcouncil/indexing/semantic_index.py +42 -26
- package/src/devcouncil/integrations/actions.py +24 -4
- package/src/devcouncil/integrations/check.py +7 -4
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +8 -1
- package/src/devcouncil/integrations/gitnexus.py +10 -2
- package/src/devcouncil/integrations/mcp/server.py +404 -15
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +20 -6
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +38 -5
- package/src/devcouncil/llm/cache.py +14 -6
- package/src/devcouncil/llm/provider.py +179 -92
- package/src/devcouncil/llm/router.py +122 -23
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +47 -4
- package/src/devcouncil/planning/critique_service.py +9 -2
- package/src/devcouncil/planning/plan_service.py +69 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +10 -2
- package/src/devcouncil/repo/ci_scaffold.py +13 -5
- package/src/devcouncil/repo/sca.py +11 -1
- package/src/devcouncil/reporting/json_report.py +11 -0
- package/src/devcouncil/reporting/markdown_report.py +14 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/registry.py +111 -33
- package/src/devcouncil/storage/db.py +58 -2
- package/src/devcouncil/storage/models.py +4 -0
- package/src/devcouncil/storage/native.py +20 -18
- package/src/devcouncil/storage/repositories.py +35 -18
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/tracker.py +12 -1
- package/src/devcouncil/ui/dashboard.py +69 -5
- package/src/devcouncil/verification/acceptance_compiler.py +147 -19
- package/src/devcouncil/verification/ad_hoc_check.py +6 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/sandbox.py +7 -4
- package/src/devcouncil/verification/verifier.py +905 -517
- 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
|