devcouncil 0.1.1 → 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.
Files changed (159) hide show
  1. package/README.md +201 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +348 -12
  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 +32 -5
  8. package/src/devcouncil/assets/__init__.py +1 -0
  9. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  10. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  11. package/src/devcouncil/cli/commands/agents.py +292 -0
  12. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  13. package/src/devcouncil/cli/commands/check.py +220 -0
  14. package/src/devcouncil/cli/commands/config.py +43 -4
  15. package/src/devcouncil/cli/commands/cost.py +57 -0
  16. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  17. package/src/devcouncil/cli/commands/design.py +186 -0
  18. package/src/devcouncil/cli/commands/doctor.py +379 -22
  19. package/src/devcouncil/cli/commands/evidence.py +48 -0
  20. package/src/devcouncil/cli/commands/go.py +532 -33
  21. package/src/devcouncil/cli/commands/handoff.py +69 -0
  22. package/src/devcouncil/cli/commands/hook.py +296 -15
  23. package/src/devcouncil/cli/commands/init.py +161 -20
  24. package/src/devcouncil/cli/commands/integrate.py +1371 -124
  25. package/src/devcouncil/cli/commands/logs.py +106 -0
  26. package/src/devcouncil/cli/commands/map.py +80 -10
  27. package/src/devcouncil/cli/commands/okf.py +245 -0
  28. package/src/devcouncil/cli/commands/plan.py +256 -55
  29. package/src/devcouncil/cli/commands/prompt.py +18 -7
  30. package/src/devcouncil/cli/commands/repair.py +50 -24
  31. package/src/devcouncil/cli/commands/report.py +8 -0
  32. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  33. package/src/devcouncil/cli/commands/rollback.py +27 -28
  34. package/src/devcouncil/cli/commands/run.py +195 -54
  35. package/src/devcouncil/cli/commands/runs.py +223 -0
  36. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  37. package/src/devcouncil/cli/commands/semantic.py +47 -0
  38. package/src/devcouncil/cli/commands/setup.py +145 -6
  39. package/src/devcouncil/cli/commands/shell.py +73 -0
  40. package/src/devcouncil/cli/commands/skills.py +267 -0
  41. package/src/devcouncil/cli/commands/status.py +30 -15
  42. package/src/devcouncil/cli/commands/trace.py +47 -3
  43. package/src/devcouncil/cli/commands/verify.py +144 -3
  44. package/src/devcouncil/cli/commands/watch.py +32 -12
  45. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  46. package/src/devcouncil/cli/main.py +91 -7
  47. package/src/devcouncil/domain/evidence.py +29 -2
  48. package/src/devcouncil/domain/gap.py +27 -1
  49. package/src/devcouncil/domain/task.py +31 -2
  50. package/src/devcouncil/execution/checkpoints.py +256 -0
  51. package/src/devcouncil/execution/context_builder.py +1 -1
  52. package/src/devcouncil/execution/fs_watcher.py +205 -0
  53. package/src/devcouncil/execution/handoff.py +102 -0
  54. package/src/devcouncil/execution/hook_policy.py +162 -74
  55. package/src/devcouncil/execution/patch.py +65 -10
  56. package/src/devcouncil/execution/permissions.py +24 -24
  57. package/src/devcouncil/execution/policy_engine.py +350 -0
  58. package/src/devcouncil/execution/prompt_builder.py +751 -23
  59. package/src/devcouncil/execution/shell_session.py +231 -0
  60. package/src/devcouncil/execution/task_runner.py +24 -9
  61. package/src/devcouncil/executors/agent_registry.py +596 -0
  62. package/src/devcouncil/executors/coding_cli.py +791 -39
  63. package/src/devcouncil/executors/mini_swe.py +6 -0
  64. package/src/devcouncil/executors/native/agent.py +135 -19
  65. package/src/devcouncil/executors/openhands.py +6 -0
  66. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  67. package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
  68. package/src/devcouncil/gating/policy.py +190 -11
  69. package/src/devcouncil/hardware.py +184 -0
  70. package/src/devcouncil/indexing/ast_matcher.py +17 -7
  71. package/src/devcouncil/indexing/lsp.py +45 -4
  72. package/src/devcouncil/indexing/repo_mapper.py +1284 -15
  73. package/src/devcouncil/indexing/semantic_index.py +221 -0
  74. package/src/devcouncil/integrations/actions.py +166 -0
  75. package/src/devcouncil/integrations/check.py +426 -0
  76. package/src/devcouncil/integrations/claude_assets.py +444 -0
  77. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  78. package/src/devcouncil/integrations/github_intent.py +149 -0
  79. package/src/devcouncil/integrations/gitnexus.py +45 -2
  80. package/src/devcouncil/integrations/mcp/server.py +1944 -32
  81. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  82. package/src/devcouncil/integrations/pr_comments.py +9 -0
  83. package/src/devcouncil/knowledge/__init__.py +23 -0
  84. package/src/devcouncil/knowledge/design.py +374 -0
  85. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  86. package/src/devcouncil/knowledge/fetch.py +223 -0
  87. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  88. package/src/devcouncil/knowledge/okf.py +202 -0
  89. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  90. package/src/devcouncil/knowledge/sources.py +239 -0
  91. package/src/devcouncil/live/cards.py +181 -25
  92. package/src/devcouncil/live/repair_prompt.py +29 -6
  93. package/src/devcouncil/live/reviewer.py +72 -13
  94. package/src/devcouncil/live/signals.py +2 -2
  95. package/src/devcouncil/live/summary.py +18 -8
  96. package/src/devcouncil/live/transcripts.py +47 -11
  97. package/src/devcouncil/llm/cache.py +20 -8
  98. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  99. package/src/devcouncil/llm/provider.py +617 -49
  100. package/src/devcouncil/llm/router.py +337 -53
  101. package/src/devcouncil/optimization/__init__.py +1 -0
  102. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  103. package/src/devcouncil/optimization/skillopt.py +673 -0
  104. package/src/devcouncil/planning/arbiter_service.py +10 -2
  105. package/src/devcouncil/planning/correction_manifest.py +346 -0
  106. package/src/devcouncil/planning/critique_service.py +16 -4
  107. package/src/devcouncil/planning/plan_service.py +86 -6
  108. package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
  109. package/src/devcouncil/planning/repair_service.py +8 -2
  110. package/src/devcouncil/planning/spec_service.py +37 -3
  111. package/src/devcouncil/repo/ci_scaffold.py +165 -0
  112. package/src/devcouncil/repo/gitignore.py +123 -0
  113. package/src/devcouncil/repo/sca.py +384 -0
  114. package/src/devcouncil/reporting/json_report.py +22 -1
  115. package/src/devcouncil/reporting/markdown_report.py +29 -1
  116. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  117. package/src/devcouncil/reporting/okf_html.py +323 -0
  118. package/src/devcouncil/reporting/report_builder.py +18 -1
  119. package/src/devcouncil/skills/__init__.py +19 -0
  120. package/src/devcouncil/skills/library/README.md +46 -0
  121. package/src/devcouncil/skills/library/ai-training.md +50 -0
  122. package/src/devcouncil/skills/library/android.md +50 -0
  123. package/src/devcouncil/skills/library/backend.md +52 -0
  124. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  125. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  126. package/src/devcouncil/skills/library/desktop.md +46 -0
  127. package/src/devcouncil/skills/library/devops.md +48 -0
  128. package/src/devcouncil/skills/library/game-dev.md +46 -0
  129. package/src/devcouncil/skills/library/ios.md +48 -0
  130. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  131. package/src/devcouncil/skills/library/security.md +48 -0
  132. package/src/devcouncil/skills/library/systems.md +48 -0
  133. package/src/devcouncil/skills/library/web.md +47 -0
  134. package/src/devcouncil/skills/library/windows.md +47 -0
  135. package/src/devcouncil/skills/registry.py +408 -0
  136. package/src/devcouncil/storage/db.py +140 -3
  137. package/src/devcouncil/storage/models.py +125 -0
  138. package/src/devcouncil/storage/native.py +559 -0
  139. package/src/devcouncil/storage/repositories.py +157 -78
  140. package/src/devcouncil/telemetry/cost.py +123 -17
  141. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  142. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  143. package/src/devcouncil/telemetry/pricing.py +28 -0
  144. package/src/devcouncil/telemetry/stages.py +141 -0
  145. package/src/devcouncil/telemetry/traces.py +62 -7
  146. package/src/devcouncil/telemetry/tracker.py +24 -10
  147. package/src/devcouncil/ui/dashboard.py +393 -28
  148. package/src/devcouncil/utils/redaction.py +9 -3
  149. package/src/devcouncil/utils/subprocess_env.py +69 -0
  150. package/src/devcouncil/verification/acceptance_compiler.py +253 -0
  151. package/src/devcouncil/verification/ad_hoc_check.py +135 -0
  152. package/src/devcouncil/verification/diff_coverage.py +353 -0
  153. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  154. package/src/devcouncil/verification/next_actions.py +189 -0
  155. package/src/devcouncil/verification/sandbox.py +181 -0
  156. package/src/devcouncil/verification/test_resolver.py +91 -0
  157. package/src/devcouncil/verification/verifier.py +1549 -143
  158. package/uv.lock +205 -64
  159. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -0,0 +1,24 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ const projectRoot = process.env.DEVCOUNCIL_PROJECT_ROOT || process.cwd();
4
+
5
+ function runHook(event, payload) {
6
+ const args = ["hook", event, "--client", "opencode", "--project-root", projectRoot];
7
+ const result = spawnSync("devcouncil", args, {
8
+ input: JSON.stringify(payload ?? {}),
9
+ encoding: "utf-8",
10
+ env: { ...process.env, DEVCOUNCIL_PROJECT_ROOT: projectRoot },
11
+ });
12
+ if (result.status === 2) {
13
+ throw new Error(result.stderr || result.stdout || "DevCouncil blocked the tool call.");
14
+ }
15
+ }
16
+
17
+ export const DevCouncilOpenCodeHook = async () => ({
18
+ "tool.execute.before": async (input, output) => {
19
+ runHook("pre-tool-use", { tool: input.tool, arguments: output.args });
20
+ },
21
+ "tool.execute.after": async (input, output) => {
22
+ runHook("post-tool-use", { tool: input.tool, arguments: output.args });
23
+ },
24
+ });
@@ -1,11 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import httpx
4
5
  from urllib.parse import quote
5
6
 
6
7
  from devcouncil.artifacts.graph import ArtifactGraph
7
8
  from devcouncil.reporting.report_builder import ReportBuilder
8
9
 
10
+ logger = logging.getLogger(__name__)
11
+
9
12
 
10
13
  class PullRequestCommentError(RuntimeError):
11
14
  pass
@@ -34,10 +37,13 @@ class GitHubPRCommenter:
34
37
  "Accept": "application/vnd.github+json",
35
38
  "Content-Type": "application/json",
36
39
  }
40
+ logger.info("Posting GitHub PR comment: repo=%s pr=%s", self.repository, self.pull_number)
37
41
  async with httpx.AsyncClient() as client:
38
42
  response = await client.post(url, headers=headers, json={"body": body})
39
43
  if response.status_code >= 400:
44
+ logger.error("GitHub comment failed: HTTP %s for %s#%s", response.status_code, self.repository, self.pull_number)
40
45
  raise PullRequestCommentError(f"GitHub comment failed with HTTP {response.status_code}: {response.text}")
46
+ logger.info("GitHub PR comment posted to %s#%s", self.repository, self.pull_number)
41
47
  return response.json() if response.content else {}
42
48
 
43
49
 
@@ -55,8 +61,11 @@ class GitLabMRCommenter:
55
61
  "PRIVATE-TOKEN": self.token,
56
62
  "Content-Type": "application/json",
57
63
  }
64
+ logger.info("Posting GitLab MR note: project=%s mr=%s", self.project_id, self.merge_request_iid)
58
65
  async with httpx.AsyncClient() as client:
59
66
  response = await client.post(url, headers=headers, json={"body": body})
60
67
  if response.status_code >= 400:
68
+ logger.error("GitLab comment failed: HTTP %s for project=%s mr=%s", response.status_code, self.project_id, self.merge_request_iid)
61
69
  raise PullRequestCommentError(f"GitLab comment failed with HTTP {response.status_code}: {response.text}")
70
+ logger.info("GitLab MR note posted to project=%s mr=%s", self.project_id, self.merge_request_iid)
62
71
  return response.json() if response.content else {}
@@ -0,0 +1,23 @@
1
+ """Knowledge formats: Open Knowledge Format (OKF) and design.md support.
2
+
3
+ DevCouncil treats durable, file-based knowledge the same way it treats its own
4
+ artifacts. This package adds two interoperable, vendor-neutral markdown formats:
5
+
6
+ * :mod:`devcouncil.knowledge.okf` — the Open Knowledge Format (Google Cloud, v0.1):
7
+ markdown + YAML frontmatter arranged into a cross-linked knowledge graph. Used both
8
+ to *export* DevCouncil's artifact graph as a portable bundle and to *ingest* external
9
+ org knowledge as planning context.
10
+ * :mod:`devcouncil.knowledge.design` — the design.md spec (google-labs-code, alpha):
11
+ machine-readable design tokens plus human-readable rationale, with lint/export tooling.
12
+
13
+ Both ride on :mod:`devcouncil.knowledge.frontmatter` (a single markdown+YAML frontmatter
14
+ implementation shared with the skills library) and are surfaced as selectable
15
+ :class:`devcouncil.knowledge.sources.KnowledgeSource` objects injected into prompts.
16
+ """
17
+
18
+ from devcouncil.knowledge.frontmatter import (
19
+ build_frontmatter_markdown,
20
+ split_frontmatter,
21
+ )
22
+
23
+ __all__ = ["build_frontmatter_markdown", "split_frontmatter"]
@@ -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"