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,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
@@ -0,0 +1,223 @@
1
+ """Materialize an OKF bundle *source* into a local directory for ingest.
2
+
3
+ ``dev okf ingest`` historically accepted only a local bundle directory. Bundles travel,
4
+ though — as ``.tar.gz``/``.zip`` archives or behind a git URL — so this module resolves
5
+ any of those forms to a concrete on-disk directory the existing read/validate/copy logic
6
+ can consume unchanged:
7
+
8
+ * an existing local directory → returned as-is (no temp dir, nothing to clean up);
9
+ * a local archive (``.tar.gz``/``.tgz``/``.zip``) → extracted into a temp dir, with a
10
+ **path-traversal guard** that rejects entries (or link targets) escaping the target;
11
+ * a git URL (``http(s)://``, ``git@``, ``ssh://``, or ``*.git``) → ``git clone --depth 1``
12
+ into a temp dir (best-effort; a clear error is raised if git is missing or the clone
13
+ fails).
14
+
15
+ Callers are responsible for invoking :meth:`FetchedBundle.cleanup` (in a ``finally``) to
16
+ remove any temp dir once the bundle has been read/copied.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import shutil
22
+ import subprocess
23
+ import tarfile
24
+ import tempfile
25
+ import zipfile
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ _ARCHIVE_SUFFIXES = (".tar.gz", ".tgz", ".zip")
30
+
31
+
32
+ class UnsafeArchiveError(ValueError):
33
+ """An archive entry (or link target) resolves outside the extraction directory.
34
+
35
+ Subclasses :class:`ValueError` so callers can catch either; raised by the extraction
36
+ guard before any unsafe member is written to disk (a path-traversal / Zip-Slip block).
37
+ """
38
+
39
+
40
+ @dataclass
41
+ class FetchedBundle:
42
+ """The resolved local bundle directory plus any temp dir that must be cleaned up.
43
+
44
+ ``directory`` is the bundle root to read. ``cleanup_dir`` is the temp directory created
45
+ for archives/git (``None`` for a pre-existing local directory, which is returned as-is
46
+ and must NOT be deleted). ``suggested_name`` is a sensible default ingest subfolder name
47
+ derived from the original source (its temp dir name would otherwise be random).
48
+ """
49
+
50
+ directory: Path
51
+ cleanup_dir: Path | None
52
+ suggested_name: str = ""
53
+
54
+ def cleanup(self) -> None:
55
+ """Remove the temp dir if one was created; safe to call when there is none."""
56
+ if self.cleanup_dir is not None:
57
+ shutil.rmtree(self.cleanup_dir, ignore_errors=True)
58
+
59
+
60
+ def is_git_url(source: str) -> bool:
61
+ """Whether ``source`` looks like a git remote we should ``git clone``."""
62
+ s = source.strip()
63
+ return (
64
+ s.startswith("http://")
65
+ or s.startswith("https://")
66
+ or s.startswith("git@")
67
+ or s.startswith("ssh://")
68
+ or s.endswith(".git")
69
+ )
70
+
71
+
72
+ def _archive_stem(name: str) -> str:
73
+ """The base name of an archive file with its (possibly two-part) suffix removed."""
74
+ lower = name.lower()
75
+ for suffix in _ARCHIVE_SUFFIXES:
76
+ if lower.endswith(suffix):
77
+ return name[: -len(suffix)] or "bundle"
78
+ return Path(name).stem or "bundle"
79
+
80
+
81
+ def _git_repo_name(url: str) -> str:
82
+ tail = url.rstrip("/").split("/")[-1].split(":")[-1]
83
+ if tail.endswith(".git"):
84
+ tail = tail[:-4]
85
+ return tail or "bundle"
86
+
87
+
88
+ def _within(base: Path, target: Path) -> bool:
89
+ """Whether resolved ``target`` is ``base`` itself or lives beneath it."""
90
+ try:
91
+ target.relative_to(base)
92
+ return True
93
+ except ValueError:
94
+ # relative_to only raises when target is neither base nor beneath it, so an
95
+ # escaping path is unambiguously outside the extraction root.
96
+ return False
97
+
98
+
99
+ def _safe_extract_tar(archive: Path, dest: Path) -> None:
100
+ """Extract a tar archive into ``dest``, rejecting any path-escaping member/link."""
101
+ dest_resolved = dest.resolve()
102
+ with tarfile.open(archive, "r:*") as tf:
103
+ members = tf.getmembers()
104
+ for member in members:
105
+ target = (dest / member.name).resolve()
106
+ if not _within(dest_resolved, target):
107
+ raise UnsafeArchiveError(
108
+ f"unsafe archive entry {member.name!r} escapes the extraction directory"
109
+ )
110
+ # A symlink/hardlink could still point outside even if its own path is safe.
111
+ # Symlink ``linkname`` is relative to the link's own directory; hardlink
112
+ # ``linkname`` is relative to the archive root — resolve each against the
113
+ # correct base, else a hardlink escaping via the root is mis-validated.
114
+ if member.issym() or member.islnk():
115
+ base = target.parent if member.issym() else dest
116
+ link_target = (base / member.linkname).resolve()
117
+ if not _within(dest_resolved, link_target):
118
+ raise UnsafeArchiveError(
119
+ f"unsafe link target {member.linkname!r} in entry {member.name!r}"
120
+ )
121
+ # ``filter="data"`` is the safe extraction default on Python 3.12+ (defense in depth
122
+ # alongside the explicit guard above); fall back gracefully on older interpreters.
123
+ try:
124
+ tf.extractall(dest, members=members, filter="data")
125
+ except TypeError: # pragma: no cover - Python < 3.12 has no filter kwarg
126
+ tf.extractall(dest, members=members)
127
+
128
+
129
+ def _safe_extract_zip(archive: Path, dest: Path) -> None:
130
+ """Extract a zip archive into ``dest``, rejecting any path-escaping member (Zip-Slip)."""
131
+ dest_resolved = dest.resolve()
132
+ with zipfile.ZipFile(archive) as zf:
133
+ for name in zf.namelist():
134
+ target = (dest / name).resolve()
135
+ if not _within(dest_resolved, target):
136
+ raise UnsafeArchiveError(
137
+ f"unsafe archive entry {name!r} escapes the extraction directory"
138
+ )
139
+ zf.extractall(dest)
140
+
141
+
142
+ def _resolve_bundle_root(extracted: Path) -> Path:
143
+ """Descend into a lone top-level directory.
144
+
145
+ Archives produced with ``tar czf x.tgz somedir`` (or a git repo whose bundle lives in
146
+ a subdir) nest everything under one directory; collapsing it makes the returned path the
147
+ actual bundle root. If the markdown already sits at the top level, the dir is used as-is.
148
+ Purely best-effort: :func:`read_bundle` recurses anyway, so a wrong guess is harmless.
149
+ """
150
+ entries = [p for p in extracted.iterdir() if not p.name.startswith(".")]
151
+ if any(p.is_file() and p.suffix == ".md" for p in entries):
152
+ return extracted
153
+ if len(entries) == 1 and entries[0].is_dir():
154
+ return entries[0]
155
+ return extracted
156
+
157
+
158
+ def fetch_bundle(source: str) -> FetchedBundle:
159
+ """Resolve ``source`` (local dir, local archive, or git URL) to a local bundle dir.
160
+
161
+ Raises :class:`UnsafeArchiveError` for a path-escaping archive entry, :class:`FileNotFoundError`
162
+ for a non-existent local path that isn't a git URL, and :class:`RuntimeError` if a git
163
+ clone is required but git is missing or the clone fails.
164
+ """
165
+ raw = source.strip()
166
+ local = Path(raw).expanduser()
167
+
168
+ # (a) existing local directory — use it directly; nothing to clean up.
169
+ if local.is_dir():
170
+ resolved = local.resolve()
171
+ return FetchedBundle(directory=resolved, cleanup_dir=None, suggested_name=resolved.name)
172
+
173
+ # (b) local archive — extract into a temp dir behind the traversal guard.
174
+ if local.is_file() and local.name.lower().endswith(_ARCHIVE_SUFFIXES):
175
+ tmp = Path(tempfile.mkdtemp(prefix="okf-archive-"))
176
+ try:
177
+ if local.name.lower().endswith(".zip"):
178
+ _safe_extract_zip(local, tmp)
179
+ else:
180
+ _safe_extract_tar(local, tmp)
181
+ except BaseException:
182
+ shutil.rmtree(tmp, ignore_errors=True)
183
+ raise
184
+ return FetchedBundle(
185
+ directory=_resolve_bundle_root(tmp),
186
+ cleanup_dir=tmp,
187
+ suggested_name=_archive_stem(local.name),
188
+ )
189
+
190
+ # (c) git URL — shallow clone into a temp dir.
191
+ if is_git_url(raw):
192
+ return _clone_git(raw)
193
+
194
+ raise FileNotFoundError(
195
+ f"bundle source not found: {source!r} (expected a directory, a .tar.gz/.tgz/.zip "
196
+ "archive, or a git URL)"
197
+ )
198
+
199
+
200
+ def _clone_git(url: str) -> FetchedBundle:
201
+ """``git clone --depth 1`` ``url`` into a fresh temp dir (best-effort)."""
202
+ if shutil.which("git") is None:
203
+ raise RuntimeError("git is not installed; cannot clone bundle from a git URL")
204
+ parent = Path(tempfile.mkdtemp(prefix="okf-git-"))
205
+ target = parent / "clone"
206
+ try:
207
+ result = subprocess.run(
208
+ ["git", "clone", "--depth", "1", url, str(target)],
209
+ capture_output=True,
210
+ text=True,
211
+ )
212
+ except OSError as exc: # pragma: no cover - git present but unexecutable
213
+ shutil.rmtree(parent, ignore_errors=True)
214
+ raise RuntimeError(f"git clone failed to start: {exc}") from exc
215
+ if result.returncode != 0:
216
+ shutil.rmtree(parent, ignore_errors=True)
217
+ detail = (result.stderr or result.stdout or "").strip()
218
+ raise RuntimeError(f"git clone of {url!r} failed: {detail}")
219
+ return FetchedBundle(
220
+ directory=_resolve_bundle_root(target),
221
+ cleanup_dir=parent,
222
+ suggested_name=_git_repo_name(url),
223
+ )
@@ -0,0 +1,51 @@
1
+ """Markdown + YAML frontmatter: the single split/build implementation.
2
+
3
+ Both the skills library (:mod:`devcouncil.skills.registry`) and the knowledge formats
4
+ (OKF, design.md) store structured metadata in a leading ``---`` YAML block followed by a
5
+ markdown body. Keeping one parser/serializer here means a fix to frontmatter handling
6
+ applies everywhere rather than drifting between copies.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ import yaml
14
+
15
+
16
+ def split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
17
+ """Split ``text`` into (frontmatter dict, body).
18
+
19
+ Returns ``({}, text)`` when there is no leading ``---`` block or the block does not
20
+ parse to a mapping. Mirrors the historical behavior of
21
+ ``skills.registry._split_frontmatter`` so existing skill files keep parsing.
22
+ """
23
+ if text.startswith("---"):
24
+ parts = text.split("---", 2)
25
+ if len(parts) == 3:
26
+ try:
27
+ meta = yaml.safe_load(parts[1]) or {}
28
+ except yaml.YAMLError:
29
+ meta = {}
30
+ return (meta if isinstance(meta, dict) else {}), parts[2].lstrip("\r\n")
31
+ return {}, text
32
+
33
+
34
+ def build_frontmatter_markdown(meta: dict[str, Any], body: str) -> str:
35
+ """Render a ``---`` YAML frontmatter block above ``body``.
36
+
37
+ Empty/``None`` values are dropped so the frontmatter stays minimal (OKF and design.md
38
+ both favor only-what-you-have metadata). Key order is preserved as given by the caller
39
+ (``sort_keys=False``); Unicode is kept literal rather than escaped.
40
+ """
41
+ clean = {k: v for k, v in meta.items() if v not in (None, "", [], {})}
42
+ front = yaml.safe_dump(
43
+ clean,
44
+ sort_keys=False,
45
+ default_flow_style=False,
46
+ allow_unicode=True,
47
+ ).strip()
48
+ body = body.strip()
49
+ if not front:
50
+ return f"{body}\n" if body else ""
51
+ return f"---\n{front}\n---\n\n{body}\n" if body else f"---\n{front}\n---\n"