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.
- package/README.md +201 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +348 -12
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +32 -5
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +6 -3
- package/src/devcouncil/cli/commands/check.py +220 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +379 -22
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +532 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +296 -15
- package/src/devcouncil/cli/commands/init.py +161 -20
- package/src/devcouncil/cli/commands/integrate.py +1371 -124
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +256 -55
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +50 -24
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +195 -54
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +267 -0
- package/src/devcouncil/cli/commands/status.py +30 -15
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +144 -3
- package/src/devcouncil/cli/commands/watch.py +32 -12
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +91 -7
- package/src/devcouncil/domain/evidence.py +29 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +256 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +205 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +65 -10
- package/src/devcouncil/execution/permissions.py +24 -24
- package/src/devcouncil/execution/policy_engine.py +350 -0
- package/src/devcouncil/execution/prompt_builder.py +751 -23
- package/src/devcouncil/execution/shell_session.py +231 -0
- package/src/devcouncil/execution/task_runner.py +24 -9
- package/src/devcouncil/executors/agent_registry.py +596 -0
- package/src/devcouncil/executors/coding_cli.py +791 -39
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +135 -19
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
- package/src/devcouncil/gating/policy.py +190 -11
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +17 -7
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1284 -15
- package/src/devcouncil/indexing/semantic_index.py +221 -0
- package/src/devcouncil/integrations/actions.py +166 -0
- package/src/devcouncil/integrations/check.py +426 -0
- 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 +149 -0
- package/src/devcouncil/integrations/gitnexus.py +45 -2
- package/src/devcouncil/integrations/mcp/server.py +1944 -32
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- 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 +181 -25
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +47 -11
- package/src/devcouncil/llm/cache.py +20 -8
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +617 -49
- package/src/devcouncil/llm/router.py +337 -53
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +346 -0
- package/src/devcouncil/planning/critique_service.py +16 -4
- package/src/devcouncil/planning/plan_service.py +86 -6
- package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +37 -3
- package/src/devcouncil/repo/ci_scaffold.py +165 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +384 -0
- package/src/devcouncil/reporting/json_report.py +22 -1
- package/src/devcouncil/reporting/markdown_report.py +29 -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/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +408 -0
- package/src/devcouncil/storage/db.py +140 -3
- package/src/devcouncil/storage/models.py +125 -0
- package/src/devcouncil/storage/native.py +559 -0
- package/src/devcouncil/storage/repositories.py +157 -78
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +24 -10
- package/src/devcouncil/ui/dashboard.py +393 -28
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +253 -0
- package/src/devcouncil/verification/ad_hoc_check.py +135 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +181 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1549 -143
- package/uv.lock +205 -64
- 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"
|