chipgraph 0.1.0__py3-none-any.whl

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 (252) hide show
  1. chipgraph/__init__.py +7 -0
  2. chipgraph/adapters/__init__.py +1 -0
  3. chipgraph/adapters/format/__init__.py +6 -0
  4. chipgraph/adapters/format/__main__.py +16 -0
  5. chipgraph/adapters/format/_yaml_lines.py +54 -0
  6. chipgraph/adapters/format/chip_yaml.py +323 -0
  7. chipgraph/adapters/format/qsoc_contract.py +246 -0
  8. chipgraph/adapters/llm/__init__.py +58 -0
  9. chipgraph/adapters/llm/_budget.py +173 -0
  10. chipgraph/adapters/llm/_errors.py +50 -0
  11. chipgraph/adapters/llm/_nda.py +30 -0
  12. chipgraph/adapters/llm/_retry.py +85 -0
  13. chipgraph/adapters/llm/anthropic.py +247 -0
  14. chipgraph/adapters/llm/decide_backend.py +179 -0
  15. chipgraph/adapters/llm/fake.py +87 -0
  16. chipgraph/adapters/llm/guard.py +68 -0
  17. chipgraph/adapters/parser/__init__.py +22 -0
  18. chipgraph/adapters/parser/cocotb.py +304 -0
  19. chipgraph/adapters/parser/generic_regex.py +75 -0
  20. chipgraph/adapters/parser/verible.py +55 -0
  21. chipgraph/adapters/parser/verilator.py +46 -0
  22. chipgraph/adapters/review/__init__.py +7 -0
  23. chipgraph/adapters/review/file.py +123 -0
  24. chipgraph/adapters/review/github.py +886 -0
  25. chipgraph/adapters/runner/__init__.py +7 -0
  26. chipgraph/adapters/runner/local.py +122 -0
  27. chipgraph/adapters/runtime/__init__.py +5 -0
  28. chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
  29. chipgraph/adapters/runtime/claude_code/agents.py +158 -0
  30. chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
  31. chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
  32. chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
  33. chipgraph/adapters/runtime/claude_code/loop.py +342 -0
  34. chipgraph/adapters/runtime/claude_code/previous.py +419 -0
  35. chipgraph/adapters/runtime/claude_code/review.py +492 -0
  36. chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
  37. chipgraph/adapters/runtime/claude_code/service.py +2285 -0
  38. chipgraph/adapters/tool/__init__.py +7 -0
  39. chipgraph/adapters/tool/cmd.py +229 -0
  40. chipgraph/adapters/tool/edalize.py +773 -0
  41. chipgraph/adapters/tool/filelist.py +259 -0
  42. chipgraph/adapters/tool/pyslang.py +485 -0
  43. chipgraph/adapters/vcs/__init__.py +1 -0
  44. chipgraph/adapters/vcs/git.py +521 -0
  45. chipgraph/app/__init__.py +10 -0
  46. chipgraph/app/baseline.py +339 -0
  47. chipgraph/app/build.py +332 -0
  48. chipgraph/app/checks.py +202 -0
  49. chipgraph/app/context.py +191 -0
  50. chipgraph/app/errors.py +11 -0
  51. chipgraph/app/executors.py +79 -0
  52. chipgraph/app/findings.py +203 -0
  53. chipgraph/app/ingest.py +523 -0
  54. chipgraph/checks/__init__.py +52 -0
  55. chipgraph/checks/_cdc_analyze.py +272 -0
  56. chipgraph/checks/_cdc_elaborate.py +455 -0
  57. chipgraph/checks/_common.py +268 -0
  58. chipgraph/checks/_cross.py +96 -0
  59. chipgraph/checks/_model.py +64 -0
  60. chipgraph/checks/_naming_pyslang.py +235 -0
  61. chipgraph/checks/_naming_rules.py +127 -0
  62. chipgraph/checks/_xref_syntax.py +260 -0
  63. chipgraph/checks/cdc_struct.py +235 -0
  64. chipgraph/checks/connect.py +353 -0
  65. chipgraph/checks/cross_chip.py +454 -0
  66. chipgraph/checks/duplicate.py +153 -0
  67. chipgraph/checks/filelist.py +323 -0
  68. chipgraph/checks/format.py +268 -0
  69. chipgraph/checks/generated.py +151 -0
  70. chipgraph/checks/hardcode.py +233 -0
  71. chipgraph/checks/layout.py +155 -0
  72. chipgraph/checks/naming.py +311 -0
  73. chipgraph/checks/plan_check.py +901 -0
  74. chipgraph/checks/ports_diff.py +203 -0
  75. chipgraph/checks/spec_schema.py +277 -0
  76. chipgraph/checks/style.py +910 -0
  77. chipgraph/checks/tb_static.py +599 -0
  78. chipgraph/checks/trace.py +224 -0
  79. chipgraph/checks/vendor_guard.py +369 -0
  80. chipgraph/checks/wrap_check.py +486 -0
  81. chipgraph/cli/__init__.py +1688 -0
  82. chipgraph/cli/next_steps.py +137 -0
  83. chipgraph/core/__init__.py +1 -0
  84. chipgraph/core/config/__init__.py +93 -0
  85. chipgraph/core/config/defaults.py +14 -0
  86. chipgraph/core/config/errors.py +32 -0
  87. chipgraph/core/config/loader.py +766 -0
  88. chipgraph/core/config/models.py +845 -0
  89. chipgraph/core/config/templates.py +340 -0
  90. chipgraph/core/contracts/__init__.py +75 -0
  91. chipgraph/core/contracts/_paths.py +26 -0
  92. chipgraph/core/contracts/agent.py +49 -0
  93. chipgraph/core/contracts/artifact.py +68 -0
  94. chipgraph/core/contracts/check.py +76 -0
  95. chipgraph/core/contracts/event.py +70 -0
  96. chipgraph/core/contracts/export.py +128 -0
  97. chipgraph/core/contracts/finding.py +171 -0
  98. chipgraph/core/contracts/gate.py +50 -0
  99. chipgraph/core/contracts/rule.py +177 -0
  100. chipgraph/core/contracts/types.py +45 -0
  101. chipgraph/core/engine/__init__.py +70 -0
  102. chipgraph/core/engine/agent_rule.py +1362 -0
  103. chipgraph/core/engine/baseline.py +283 -0
  104. chipgraph/core/engine/decide.py +462 -0
  105. chipgraph/core/engine/fanout.py +1358 -0
  106. chipgraph/core/engine/gate.py +170 -0
  107. chipgraph/core/engine/graph.py +647 -0
  108. chipgraph/core/engine/records.py +60 -0
  109. chipgraph/core/engine/rules.py +92 -0
  110. chipgraph/core/engine/scheduler.py +637 -0
  111. chipgraph/core/engine/select.py +1239 -0
  112. chipgraph/core/engine/targets.py +342 -0
  113. chipgraph/core/model/__init__.py +98 -0
  114. chipgraph/core/model/entities.py +244 -0
  115. chipgraph/core/model/export.py +16 -0
  116. chipgraph/core/model/ingest.py +693 -0
  117. chipgraph/core/model/json_value.py +4 -0
  118. chipgraph/core/model/keys.py +95 -0
  119. chipgraph/core/model/model.py +153 -0
  120. chipgraph/core/model/provenance.py +27 -0
  121. chipgraph/core/model/query.py +583 -0
  122. chipgraph/core/model/registry.py +80 -0
  123. chipgraph/core/model/relations.py +61 -0
  124. chipgraph/core/model/store.py +279 -0
  125. chipgraph/core/plugin_api/__init__.py +81 -0
  126. chipgraph/core/plugin_api/local.py +167 -0
  127. chipgraph/core/plugin_api/pack.py +159 -0
  128. chipgraph/core/plugin_api/protocols.py +177 -0
  129. chipgraph/core/plugin_api/registry.py +111 -0
  130. chipgraph/core/plugin_api/types.py +94 -0
  131. chipgraph/core/runtime/__init__.py +41 -0
  132. chipgraph/core/runtime/executor.py +79 -0
  133. chipgraph/core/runtime/queue.py +565 -0
  134. chipgraph/core/runtime/roles/__init__.py +91 -0
  135. chipgraph/core/runtime/roles/data/author.md +41 -0
  136. chipgraph/core/runtime/roles/data/critic.md +56 -0
  137. chipgraph/core/runtime/roles/data/planner.md +38 -0
  138. chipgraph/core/runtime/roles/data/researcher.md +37 -0
  139. chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
  140. chipgraph/core/runtime/roles/data/tb-author.md +48 -0
  141. chipgraph/core/runtime/roles/data/triage.md +34 -0
  142. chipgraph/core/runtime/roles/policy.py +299 -0
  143. chipgraph/core/runtime/roles/registry.py +52 -0
  144. chipgraph/core/runtime/roles/review.py +275 -0
  145. chipgraph/core/runtime/roles/schema.py +82 -0
  146. chipgraph/core/runtime/roles/skills.py +134 -0
  147. chipgraph/core/runtime/roles/spec.py +232 -0
  148. chipgraph/core/state/__init__.py +1 -0
  149. chipgraph/core/state/artifacts.py +156 -0
  150. chipgraph/core/state/backend.py +117 -0
  151. chipgraph/core/state/findings.py +151 -0
  152. chipgraph/core/state/handoff.py +411 -0
  153. chipgraph/core/state/idempotency.py +59 -0
  154. chipgraph/core/state/journal.py +238 -0
  155. chipgraph/core/state/layout.py +82 -0
  156. chipgraph/core/state/lock.py +195 -0
  157. chipgraph/core/state/trace.py +225 -0
  158. chipgraph/learn/__init__.py +96 -0
  159. chipgraph/learn/draft.py +187 -0
  160. chipgraph/learn/infer.py +580 -0
  161. chipgraph/learn/models.py +145 -0
  162. chipgraph/learn/try_run.py +228 -0
  163. chipgraph/mcp/__init__.py +12 -0
  164. chipgraph/mcp/model_tools.py +167 -0
  165. chipgraph/mcp/server.py +787 -0
  166. chipgraph/orgs/qnsc/naming-v1.yml +113 -0
  167. chipgraph/orgs/qnsc/profile.yml +5 -0
  168. chipgraph/packs/__init__.py +8 -0
  169. chipgraph/packs/assist/__init__.py +16 -0
  170. chipgraph/packs/assist/ask/__init__.py +58 -0
  171. chipgraph/packs/assist/ask/_db.py +57 -0
  172. chipgraph/packs/assist/ask/_project.py +230 -0
  173. chipgraph/packs/assist/ask/answer.py +266 -0
  174. chipgraph/packs/assist/ask/check.py +182 -0
  175. chipgraph/packs/assist/ask/contract.py +115 -0
  176. chipgraph/packs/assist/ask/documents.py +157 -0
  177. chipgraph/packs/assist/ask/fts.py +80 -0
  178. chipgraph/packs/assist/ask/retrieve.py +331 -0
  179. chipgraph/packs/assist/audit/__init__.py +383 -0
  180. chipgraph/packs/assist/pack.yml +13 -0
  181. chipgraph/packs/assist/triage/__init__.py +64 -0
  182. chipgraph/packs/assist/triage/contract.py +94 -0
  183. chipgraph/packs/assist/triage/parse.py +371 -0
  184. chipgraph/packs/assist/triage/question.py +384 -0
  185. chipgraph/packs/assist/triage/report.py +160 -0
  186. chipgraph/packs/assist/triage/rules.py +447 -0
  187. chipgraph/packs/assist/triage/run.py +224 -0
  188. chipgraph/packs/digital_rtl/__init__.py +19 -0
  189. chipgraph/packs/digital_rtl/layout.py +144 -0
  190. chipgraph/packs/digital_rtl/pack.yml +25 -0
  191. chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
  192. chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
  193. chipgraph/packs/digital_rtl/plan/context.py +82 -0
  194. chipgraph/packs/digital_rtl/plan/expand.py +94 -0
  195. chipgraph/packs/digital_rtl/plan/model.py +150 -0
  196. chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
  197. chipgraph/packs/digital_rtl/plan/show.py +176 -0
  198. chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
  199. chipgraph/packs/digital_rtl/pr/body.py +165 -0
  200. chipgraph/packs/digital_rtl/pr/gate.py +110 -0
  201. chipgraph/packs/digital_rtl/pr/open.py +397 -0
  202. chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
  203. chipgraph/packs/digital_rtl/rtl/context.py +272 -0
  204. chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
  205. chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
  206. chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
  207. chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
  208. chipgraph/packs/digital_rtl/rules/review.yml +31 -0
  209. chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
  210. chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
  211. chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
  212. chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
  213. chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
  214. chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
  215. chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
  216. chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
  217. chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
  218. chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
  219. chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
  220. chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
  221. chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
  222. chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
  223. chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
  224. chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
  225. chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
  226. chipgraph/packs/dv/__init__.py +15 -0
  227. chipgraph/packs/dv/declaration.py +392 -0
  228. chipgraph/packs/dv/interface.py +596 -0
  229. chipgraph/packs/dv/pack.yml +14 -0
  230. chipgraph/packs/dv/rules/tb_module.yml +42 -0
  231. chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
  232. chipgraph/packs/spec_core/__init__.py +9 -0
  233. chipgraph/packs/spec_core/extract/__init__.py +12 -0
  234. chipgraph/packs/spec_core/extract/_markdown.py +371 -0
  235. chipgraph/packs/spec_core/extract/_text.py +116 -0
  236. chipgraph/packs/spec_core/extract/mas.py +975 -0
  237. chipgraph/packs/spec_core/gen/__init__.py +11 -0
  238. chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
  239. chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
  240. chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
  241. chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
  242. chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
  243. chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
  244. chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
  245. chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
  246. chipgraph/packs/spec_core/pack.yml +6 -0
  247. chipgraph/py.typed +0 -0
  248. chipgraph-0.1.0.dist-info/METADATA +53 -0
  249. chipgraph-0.1.0.dist-info/RECORD +252 -0
  250. chipgraph-0.1.0.dist-info/WHEEL +4 -0
  251. chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
  252. chipgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,766 @@
1
+ """Find, read, merge and resolve the layered configuration (DESIGN 8.3, 8.4, 8.6, 12.6).
2
+
3
+ Layer order, later overrides earlier::
4
+
5
+ tool defaults -> each `extends` entry, in order (recursively, ancestors first)
6
+ -> project `.chipgraph.yml`
7
+
8
+ The personal user layer (`~/.config/chipgraph/user.yml`) is resolved separately and
9
+ applied only to its own keys (autonomy, notify, answer_language, prefer_models, editor);
10
+ it never merges into the project's artifact conventions.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import fnmatch
16
+ import hashlib
17
+ import json
18
+ import os
19
+ from collections.abc import Mapping
20
+ from dataclasses import dataclass, field
21
+ from importlib import resources
22
+ from pathlib import Path
23
+ from typing import Any, Literal, Protocol, cast
24
+
25
+ import yaml
26
+ from pydantic import ValidationError
27
+
28
+ from chipgraph.core.config.defaults import DEFAULT_PROFILE
29
+ from chipgraph.core.config.errors import ConfigError
30
+ from chipgraph.core.config.models import Level, PathRule, Profile, UserConfig
31
+
32
+ SourceKind = Literal["tool", "org", "preset", "path", "git", "project", "user"]
33
+
34
+ _LEVEL_ORDER: dict[Level, int] = {"L1": 1, "L2": 2, "L3": 3, "L4": 4, "L5": 5}
35
+
36
+ _UNSET_LOCATION = "(unset, model default)"
37
+
38
+
39
+ class SourceFetcher(Protocol):
40
+ """Fetches a git-hosted rule repository, pinned to a ref, to a local directory."""
41
+
42
+ def fetch(self, url: str, ref: str) -> tuple[Path, str]:
43
+ """Return (local directory containing the fetched profile, resolved commit)."""
44
+ ...
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class SourceRef:
49
+ """Where one merged value came from: which layer, which file/URL, which version."""
50
+
51
+ kind: SourceKind
52
+ location: str
53
+ version: str
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class ConfigIssue:
58
+ """One consistency problem found by `ResolvedProfile.check()`."""
59
+
60
+ severity: Literal["error", "warning", "info"]
61
+ key: str
62
+ message: str
63
+
64
+
65
+ @dataclass
66
+ class _Layer:
67
+ source: SourceRef
68
+ data: dict[str, Any]
69
+
70
+
71
+ # --------------------------------------------------------------------------------------
72
+ # YAML reading
73
+ # --------------------------------------------------------------------------------------
74
+
75
+
76
+ def _read_yaml_with_hash(path: Path) -> tuple[Any, str]:
77
+ try:
78
+ text = path.read_text(encoding="utf-8")
79
+ except OSError as exc:
80
+ raise ConfigError(f"cannot read file: {exc}", file=path) from exc
81
+ digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
82
+ try:
83
+ return yaml.safe_load(text), digest
84
+ except yaml.YAMLError as exc:
85
+ mark = getattr(exc, "problem_mark", None)
86
+ line = (mark.line + 1) if mark is not None else None
87
+ raise ConfigError(f"invalid YAML: {exc}", file=path, line=line) from exc
88
+
89
+
90
+ def _sha256_json(data: Any) -> str:
91
+ canonical = json.dumps(data, sort_keys=True, separators=(",", ":"), default=str)
92
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
93
+
94
+
95
+ # --------------------------------------------------------------------------------------
96
+ # find_profile
97
+ # --------------------------------------------------------------------------------------
98
+
99
+
100
+ def find_profile(start: Path) -> Path | None:
101
+ """Walk up from `start` to the nearest directory with `.chipgraph.yml`.
102
+
103
+ Stops (inclusive) at the first directory containing `.git`, or at the filesystem root.
104
+ """
105
+ current = start.resolve()
106
+ if current.is_file():
107
+ current = current.parent
108
+ while True:
109
+ candidate = current / ".chipgraph.yml"
110
+ if candidate.is_file():
111
+ return candidate
112
+ if (current / ".git").exists():
113
+ return None
114
+ parent = current.parent
115
+ if parent == current:
116
+ return None
117
+ current = parent
118
+
119
+
120
+ # --------------------------------------------------------------------------------------
121
+ # extends resolution
122
+ # --------------------------------------------------------------------------------------
123
+
124
+
125
+ def _split_extends_entry(entry: str) -> tuple[Literal["org", "preset", "path", "git"], str]:
126
+ if entry.startswith("git+"):
127
+ return "git", entry[len("git+") :]
128
+ if ":" not in entry:
129
+ raise ConfigError(
130
+ f"invalid extends entry {entry!r}: expected 'org:'/'preset:'/'path:'/'git+'"
131
+ )
132
+ kind, rest = entry.split(":", 1)
133
+ if kind in ("org", "preset", "path"):
134
+ return cast(Literal["org", "preset", "path"], kind), rest
135
+ raise ConfigError(f"invalid extends entry {entry!r}: unknown kind {kind!r}")
136
+
137
+
138
+ def _load_layer_chain(
139
+ file_path: Path,
140
+ kind: SourceKind,
141
+ location: str,
142
+ data_dir: Path | None,
143
+ fetcher: SourceFetcher | None,
144
+ visited: frozenset[str],
145
+ *,
146
+ version: str | None = None,
147
+ ) -> list[_Layer]:
148
+ """Load one profile file, recursing into its own `extends` first (ancestors first)."""
149
+ key = str(file_path)
150
+ if key in visited:
151
+ raise ConfigError(f"cycle in 'extends': {key} is already being loaded", file=file_path)
152
+ visited = visited | {key}
153
+
154
+ raw, file_hash = _read_yaml_with_hash(file_path)
155
+ if raw is None:
156
+ raw = {}
157
+ if not isinstance(raw, dict):
158
+ raise ConfigError("profile must be a YAML mapping", file=file_path)
159
+
160
+ source = SourceRef(
161
+ kind=kind, location=location, version=version if version is not None else file_hash
162
+ )
163
+
164
+ layers: list[_Layer] = []
165
+ for entry in raw.get("extends", []) or []:
166
+ layers.extend(_resolve_extends(entry, file_path.parent, data_dir, fetcher, visited))
167
+ layers.append(_Layer(source=source, data=raw))
168
+ return layers
169
+
170
+
171
+ def _resolve_extends(
172
+ entry: str,
173
+ referencing_dir: Path,
174
+ data_dir: Path | None,
175
+ fetcher: SourceFetcher | None,
176
+ visited: frozenset[str],
177
+ ) -> list[_Layer]:
178
+ kind, rest = _split_extends_entry(entry)
179
+
180
+ if kind == "path":
181
+ target = (referencing_dir / rest).resolve()
182
+ if not target.is_file():
183
+ raise ConfigError(f"extends entry {entry!r}: no file at {target}")
184
+ return _load_layer_chain(target, "path", str(target), data_dir, fetcher, visited)
185
+
186
+ if kind == "org" or kind == "preset":
187
+ if data_dir is None:
188
+ raise ConfigError(
189
+ f"extends entry {entry!r} needs a data_dir with an '{kind}s/' directory; "
190
+ "none was found or given"
191
+ )
192
+ target = data_dir / f"{kind}s" / rest / "profile.yml"
193
+ if not target.is_file():
194
+ raise ConfigError(f"extends entry {entry!r}: no profile.yml at {target}")
195
+ return _load_layer_chain(target, kind, entry, data_dir, fetcher, visited)
196
+
197
+ if kind == "git":
198
+ if "@" not in rest:
199
+ raise ConfigError(f"extends entry {entry!r}: pin a tag or commit ('@<ref>')")
200
+ url, ref = rest.rsplit("@", 1)
201
+ if fetcher is None:
202
+ raise ConfigError(
203
+ f"extends entry {entry!r} needs a git fetcher for {url!r}, none given"
204
+ )
205
+ local_dir, commit = fetcher.fetch(url, ref)
206
+ target = local_dir / "profile.yml"
207
+ if not target.is_file():
208
+ raise ConfigError(f"extends entry {entry!r}: no profile.yml in {local_dir}")
209
+ return _load_layer_chain(target, "git", url, data_dir, fetcher, visited, version=commit)
210
+
211
+ raise ConfigError(f"invalid extends entry {entry!r}") # pragma: no cover - unreachable
212
+
213
+
214
+ # --------------------------------------------------------------------------------------
215
+ # merge with provenance
216
+ # --------------------------------------------------------------------------------------
217
+
218
+
219
+ def _merge(
220
+ base: Mapping[str, Any],
221
+ overlay: Mapping[str, Any],
222
+ source: SourceRef,
223
+ provenance: dict[str, SourceRef],
224
+ prefix: str = "",
225
+ ) -> dict[str, Any]:
226
+ """Recursively merge `overlay` over `base`; mappings merge, everything else replaces."""
227
+ result = dict(base)
228
+ for raw_key, value in overlay.items():
229
+ key = str(raw_key)
230
+ path = f"{prefix}.{key}" if prefix else key
231
+ if isinstance(value, Mapping):
232
+ base_value = base.get(key)
233
+ base_sub = base_value if isinstance(base_value, Mapping) else {}
234
+ result[key] = _merge(base_sub, value, source, provenance, path)
235
+ else:
236
+ result[key] = value
237
+ provenance[path] = source
238
+ return result
239
+
240
+
241
+ def _record_leaves(
242
+ data: Any, source: SourceRef, provenance: dict[str, SourceRef], prefix: str
243
+ ) -> None:
244
+ if isinstance(data, Mapping):
245
+ for raw_key, value in data.items():
246
+ path = f"{prefix}.{raw_key}" if prefix else str(raw_key)
247
+ _record_leaves(value, source, provenance, path)
248
+ else:
249
+ provenance[prefix] = source
250
+
251
+
252
+ def _collect_leaves(
253
+ data: Any,
254
+ prefix: str,
255
+ provenance: Mapping[str, SourceRef],
256
+ out: list[tuple[str, object, SourceRef]],
257
+ fallback_kind: SourceKind,
258
+ ) -> None:
259
+ if isinstance(data, Mapping):
260
+ for raw_key, value in data.items():
261
+ path = f"{prefix}.{raw_key}" if prefix else str(raw_key)
262
+ _collect_leaves(value, path, provenance, out, fallback_kind)
263
+ return
264
+ source = provenance.get(prefix)
265
+ if source is None:
266
+ source = SourceRef(kind=fallback_kind, location=_UNSET_LOCATION, version="")
267
+ if isinstance(data, list):
268
+ data = tuple(data)
269
+ out.append((prefix, data, source))
270
+
271
+
272
+ def _loc_to_key(loc: tuple[Any, ...]) -> str:
273
+ return ".".join(str(part) for part in loc)
274
+
275
+
276
+ def _find_provenance(provenance: Mapping[str, SourceRef], key: str) -> SourceRef | None:
277
+ if key in provenance:
278
+ return provenance[key]
279
+ # fall back to the closest ancestor or descendant prefix we do have provenance for
280
+ for candidate in sorted(provenance, key=len, reverse=True):
281
+ if key.startswith(candidate + ".") or candidate.startswith(key + "."):
282
+ return provenance[candidate]
283
+ return None
284
+
285
+
286
+ def _translate_validation_error(
287
+ exc: ValidationError, provenance: Mapping[str, SourceRef], fallback_file: str
288
+ ) -> ConfigError:
289
+ first = exc.errors()[0]
290
+ key = _loc_to_key(first["loc"])
291
+ source = _find_provenance(provenance, key) if key else None
292
+ file = source.location if source is not None else fallback_file
293
+ return ConfigError(str(first["msg"]), file=file, key=key or None)
294
+
295
+
296
+ # --------------------------------------------------------------------------------------
297
+ # defaults for data_dir / user config path
298
+ # --------------------------------------------------------------------------------------
299
+
300
+
301
+ def builtin_data_dir() -> Path | None:
302
+ """The directory holding the `orgs/` and `presets/` shipped with chipgraph (D36).
303
+
304
+ It is the `chipgraph` package directory, found through `importlib.resources` so it is
305
+ the same from a checkout and from a wheel. `None` if the package is not on a real
306
+ filesystem or ships neither `orgs/` nor `presets/`.
307
+ """
308
+ root = resources.files("chipgraph")
309
+ if not isinstance(root, Path):
310
+ return None
311
+ if (root / "orgs").is_dir() or (root / "presets").is_dir():
312
+ return root
313
+ return None
314
+
315
+
316
+ def _default_data_dir() -> Path | None:
317
+ return builtin_data_dir()
318
+
319
+
320
+ def resolve_data_ref(ref: str, base_dir: Path, data_dir: Path | None = None) -> Path:
321
+ """Resolve a data file reference from a profile to an existing file.
322
+
323
+ - `org:<org>/<file>` and `preset:<preset>/<file>` resolve under `data_dir/orgs/` and
324
+ `data_dir/presets/` (default: `builtin_data_dir()`), e.g. `org:qnsc/naming-v1.yml`.
325
+ - `path:<rel>` or a plain relative path resolves against `base_dir`; an absolute path
326
+ is used as is.
327
+
328
+ Raises `ConfigError` if the reference is malformed or the file does not exist.
329
+ """
330
+ kind, sep, rest = ref.partition(":")
331
+ if sep and kind in ("org", "preset"):
332
+ root = data_dir if data_dir is not None else builtin_data_dir()
333
+ if root is None:
334
+ raise ConfigError(f"reference {ref!r} needs a data_dir with '{kind}s/'; none found")
335
+ if "/" not in rest or rest.startswith("/") or ".." in Path(rest).parts:
336
+ raise ConfigError(f"invalid reference {ref!r}: expected '{kind}:<name>/<file>'")
337
+ target = root / f"{kind}s" / rest
338
+ else:
339
+ rel = rest if sep and kind == "path" else ref
340
+ target = base_dir / rel
341
+ if not target.is_file():
342
+ raise ConfigError(f"reference {ref!r}: no file at {target}")
343
+ return target
344
+
345
+
346
+ def _default_user_config_path() -> Path:
347
+ xdg = os.environ.get("XDG_CONFIG_HOME")
348
+ base = Path(xdg) if xdg else Path.home() / ".config"
349
+ return base / "chipgraph" / "user.yml"
350
+
351
+
352
+ # --------------------------------------------------------------------------------------
353
+ # ResolvedProfile
354
+ # --------------------------------------------------------------------------------------
355
+
356
+
357
+ @dataclass
358
+ class ResolvedProfile:
359
+ """The fully merged project profile, plus the personal layer and full provenance."""
360
+
361
+ profile: Profile
362
+ user: UserConfig
363
+ sources: tuple[SourceRef, ...]
364
+ profile_hash: str
365
+ provenance: dict[str, SourceRef] = field(default_factory=dict)
366
+
367
+ def explain(self) -> list[tuple[str, object, SourceRef]]:
368
+ """Every leaf value (dotted key, value, source), sorted by key.
369
+
370
+ Backs `chipgraph config show --explain`. User-layer leaves are prefixed `user.`.
371
+ """
372
+ leaves: list[tuple[str, object, SourceRef]] = []
373
+ _collect_leaves(self.profile.model_dump(mode="python"), "", self.provenance, leaves, "tool")
374
+ _collect_leaves(
375
+ self.user.model_dump(mode="python"), "user", self.provenance, leaves, "user"
376
+ )
377
+ leaves.sort(key=lambda item: item[0])
378
+ return leaves
379
+
380
+ def check(self) -> list[ConfigIssue]:
381
+ """Consistency problems: user overreach, duplicate layout templates, wrappers, plan
382
+ limits, the style section, local plugins."""
383
+ issues: list[ConfigIssue] = []
384
+ for area, user_level in self.user.autonomy.items():
385
+ project_level = self.profile.autonomy.get(area)
386
+ if project_level is not None and _LEVEL_ORDER[user_level] > _LEVEL_ORDER[project_level]:
387
+ issues.append(
388
+ ConfigIssue(
389
+ severity="error",
390
+ key=f"autonomy.{area}",
391
+ message="the user layer may lower autonomy, not raise it",
392
+ )
393
+ )
394
+
395
+ allowed_models = set(self.profile.models.tiers.values()) | set(
396
+ self.profile.models.alt.values()
397
+ )
398
+ for tier, model in self.user.prefer_models.items():
399
+ if model not in allowed_models:
400
+ issues.append(
401
+ ConfigIssue(
402
+ severity="error",
403
+ key=f"user.prefer_models.{tier}",
404
+ message=f"model {model!r} is not in this project's models.tiers/alt",
405
+ )
406
+ )
407
+
408
+ seen: dict[str, str] = {}
409
+ for name, template in self.profile.layout.items():
410
+ if not isinstance(template, str):
411
+ continue
412
+ other = next((k for k, v in seen.items() if v == template), None)
413
+ if other is not None:
414
+ issues.append(
415
+ ConfigIssue(
416
+ severity="error",
417
+ key=f"layout.{name}",
418
+ message=f"layout template is identical to 'layout.{other}'",
419
+ )
420
+ )
421
+ seen[name] = template
422
+
423
+ instance_owner: dict[str, str] = {}
424
+ for block_name in sorted(self.profile.blocks):
425
+ override = self.profile.blocks[block_name]
426
+ for instance in override.instances:
427
+ other = instance_owner.get(instance)
428
+ if other is not None:
429
+ issues.append(
430
+ ConfigIssue(
431
+ severity="error",
432
+ key=f"blocks.{block_name}.instances",
433
+ message=(
434
+ f"instance {instance!r} is already an instance of block {other!r}"
435
+ ),
436
+ )
437
+ )
438
+ else:
439
+ instance_owner[instance] = block_name
440
+
441
+ issues.extend(_wrapper_issues(self.profile))
442
+
443
+ plan = self.profile.plan
444
+ if plan.max_total_tries < plan.max_modules:
445
+ issues.append(
446
+ ConfigIssue(
447
+ severity="warning",
448
+ key="plan.max_total_tries",
449
+ message=(
450
+ f"{plan.max_total_tries} is below plan.max_modules "
451
+ f"({plan.max_modules}): every module needs at least one try, so a "
452
+ f"plan of {plan.max_modules} modules can never pass"
453
+ ),
454
+ )
455
+ )
456
+ if plan.max_depth > plan.max_modules:
457
+ issues.append(
458
+ ConfigIssue(
459
+ severity="info",
460
+ key="plan.max_depth",
461
+ message=(
462
+ f"{plan.max_depth} is above plan.max_modules ({plan.max_modules}): "
463
+ "a dependency chain cannot be longer than the number of modules"
464
+ ),
465
+ )
466
+ )
467
+ max_parallel = self.profile.fanout.max_parallel
468
+ cpus = os.cpu_count()
469
+ if max_parallel is not None and cpus is not None and max_parallel > cpus:
470
+ issues.append(
471
+ ConfigIssue(
472
+ severity="warning",
473
+ key="fanout.max_parallel",
474
+ message=(
475
+ f"{max_parallel} branches at once is more than this machine's {cpus} CPUs"
476
+ ),
477
+ )
478
+ )
479
+
480
+ style = self.profile.style
481
+ if style.enforce and not style.exemplars:
482
+ issues.append(
483
+ ConfigIssue(
484
+ severity="warning",
485
+ key="style.enforce",
486
+ message=(
487
+ "style.enforce is set but style.exemplars is empty: the style check "
488
+ "has nothing to measure a file against, so nothing is enforced"
489
+ ),
490
+ )
491
+ )
492
+ repeated = sorted({p for p in style.exemplars if style.exemplars.count(p) > 1})
493
+ if repeated:
494
+ issues.append(
495
+ ConfigIssue(
496
+ severity="warning",
497
+ key="style.exemplars",
498
+ message=f"listed more than once: {', '.join(repeated)}",
499
+ )
500
+ )
501
+ if len(set(style.enforce)) != len(style.enforce):
502
+ issues.append(
503
+ ConfigIssue(
504
+ severity="info", key="style.enforce", message="a style key is listed twice"
505
+ )
506
+ )
507
+ issues.extend(_select_issues(self.profile))
508
+
509
+ for index, plugin in enumerate(self.profile.plugins):
510
+ issues.append(
511
+ ConfigIssue(
512
+ severity="info",
513
+ key=f"plugins[{index}]",
514
+ message=f"local plugin {plugin!r} runs code: review it like code",
515
+ )
516
+ )
517
+
518
+ return issues
519
+
520
+ def effective_autonomy(self, area: str) -> Level:
521
+ """The effective autonomy for `area`: min(project, user), user only ever lowers it."""
522
+ project_level: Level = self.profile.autonomy.get(area, "L3")
523
+ user_level = self.user.autonomy.get(area)
524
+ if user_level is None:
525
+ return project_level
526
+ return (
527
+ user_level if _LEVEL_ORDER[user_level] < _LEVEL_ORDER[project_level] else project_level
528
+ )
529
+
530
+ def for_block(self, name: str) -> Profile:
531
+ """The profile with `blocks[name]` merged over it, if any.
532
+
533
+ Only the fields the block sets are merged. `instances` (D38) and `wrappers` are
534
+ block-level facts, not `Profile` fields, so they are dropped from the overlay here;
535
+ they are read directly from `profile.blocks` (ingest, the wrapper rules).
536
+ """
537
+ override = self.profile.blocks.get(name)
538
+ if override is None:
539
+ return self.profile
540
+ base = self.profile.model_dump(mode="python")
541
+ # Only what the block sets: an unset nested field (say `spec.requirements.infer`)
542
+ # must not reset the project's value to the model default.
543
+ overlay = override.model_dump(mode="python", exclude_unset=True)
544
+ overlay.pop("instances", None)
545
+ overlay.pop("wrappers", None)
546
+ dummy_source = SourceRef(kind="project", location="", version="")
547
+ merged = _merge(base, overlay, dummy_source, {})
548
+ return Profile.model_validate(merged)
549
+
550
+ def for_path(self, rel_path: str) -> PathRule:
551
+ """Combine every `paths` glob matching `rel_path`; more specific (longer) wins."""
552
+ matches = [
553
+ (pattern, rule)
554
+ for pattern, rule in self.profile.paths.items()
555
+ if fnmatch.fnmatchcase(rel_path, pattern)
556
+ ]
557
+ matches.sort(key=lambda pair: len(pair[0]))
558
+
559
+ checks: dict[str, Literal["on", "off"]] = {}
560
+ write: Literal["allow", "deny"] = "allow"
561
+ reason: str | None = None
562
+ for _, rule in matches:
563
+ checks.update(rule.checks)
564
+ write = rule.write
565
+ if rule.reason is not None:
566
+ reason = rule.reason
567
+ return PathRule(checks=checks, write=write, reason=reason)
568
+
569
+
570
+ def _wrapper_issues(profile: Profile) -> list[ConfigIssue]:
571
+ """`config check` notes on `blocks.<b>.wrappers`: a wrapper name is unique in the
572
+ project and is no IP's module name; an exemplar is a wrapper source, not an IP file."""
573
+ issues: list[ConfigIssue] = []
574
+ owner: dict[str, str] = {}
575
+ vendor_modules: dict[str, str] = {}
576
+ vendor_files: set[str] = set()
577
+ for block in sorted(profile.blocks):
578
+ for wrapper in profile.blocks[block].wrappers:
579
+ vendor_modules.setdefault(wrapper.vendor_module, block)
580
+ vendor_files.update(wrapper.vendor_files)
581
+ for block in sorted(profile.blocks):
582
+ for index, wrapper in enumerate(profile.blocks[block].wrappers):
583
+ key = f"blocks.{block}.wrappers[{index}]"
584
+ other = owner.setdefault(wrapper.name, block)
585
+ if other != block:
586
+ issues.append(
587
+ ConfigIssue(
588
+ severity="error",
589
+ key=key,
590
+ message=f"wrapper {wrapper.name!r} is already a wrapper of block {other!r}",
591
+ )
592
+ )
593
+ if wrapper.name in vendor_modules:
594
+ issues.append(
595
+ ConfigIssue(
596
+ severity="error",
597
+ key=key,
598
+ message=(
599
+ f"wrapper {wrapper.name!r} has the name of an IP module that a "
600
+ f"wrapper of block {vendor_modules[wrapper.name]!r} instantiates"
601
+ ),
602
+ )
603
+ )
604
+ if wrapper.exemplar is not None and wrapper.exemplar in vendor_files:
605
+ issues.append(
606
+ ConfigIssue(
607
+ severity="warning",
608
+ key=f"{key}.exemplar",
609
+ message=(
610
+ f"{wrapper.exemplar} is an IP file; an exemplar is another "
611
+ "wrapper's source, whose structure the new wrapper imitates"
612
+ ),
613
+ )
614
+ )
615
+ return issues
616
+
617
+
618
+ def _select_issues(profile: Profile) -> list[ConfigIssue]:
619
+ """`config check` notes on the `select:` section (best-of-N, F6)."""
620
+ select = profile.select
621
+ issues: list[ConfigIssue] = []
622
+ if select.n > select.max_n:
623
+ issues.append(
624
+ ConfigIssue(
625
+ severity="warning",
626
+ key="select.n",
627
+ message=f"{select.n} candidates is above select.max_n ({select.max_n}): "
628
+ f"capped at {select.max_n}",
629
+ )
630
+ )
631
+ for rule_id in sorted(select.rules):
632
+ n = select.rules[rule_id]
633
+ if n > select.max_n:
634
+ issues.append(
635
+ ConfigIssue(
636
+ severity="warning",
637
+ key=f"select.rules.{rule_id}",
638
+ message=f"{n} candidates is above select.max_n ({select.max_n}): "
639
+ f"capped at {select.max_n}",
640
+ )
641
+ )
642
+ widest = max([select.effective_n(), *(select.effective_n(n) for n in select.rules.values())])
643
+ if widest >= 2 and select.budget_factor >= widest:
644
+ issues.append(
645
+ ConfigIssue(
646
+ severity="warning",
647
+ key="select.budget_factor",
648
+ message=(
649
+ f"{select.budget_factor} x one run's budget gives each of {widest} "
650
+ "candidates a full run: best-of-N then costs N runs; the default is 1.5"
651
+ ),
652
+ )
653
+ )
654
+ if select.budget_factor < 1:
655
+ issues.append(
656
+ ConfigIssue(
657
+ severity="info",
658
+ key="select.budget_factor",
659
+ message=(
660
+ f"{select.budget_factor} is below 1: the candidates together get less "
661
+ "than one plain run's budget"
662
+ ),
663
+ )
664
+ )
665
+ return issues
666
+
667
+
668
+ # --------------------------------------------------------------------------------------
669
+ # load()
670
+ # --------------------------------------------------------------------------------------
671
+
672
+
673
+ def _enforce_tighten_only_policy(layers: list[_Layer]) -> None:
674
+ """`policy.local_plugins` may only tighten across layers.
675
+
676
+ Any layer may set it to ``deny``; once a layer has denied, no later layer may set it
677
+ back to ``allow``. Raises `ConfigError` naming the offending file and the layer that
678
+ denied. Layers that do not mention the key are ignored.
679
+ """
680
+ denied_by: SourceRef | None = None
681
+ for layer in layers:
682
+ policy = layer.data.get("policy")
683
+ if not isinstance(policy, Mapping) or "local_plugins" not in policy:
684
+ continue
685
+ value = policy["local_plugins"]
686
+ if value == "deny":
687
+ denied_by = layer.source
688
+ elif value == "allow" and denied_by is not None:
689
+ raise ConfigError(
690
+ "policy.local_plugins is tighten-only: cannot set 'allow' after "
691
+ f"{denied_by.location!r} ({denied_by.kind}) set 'deny'",
692
+ file=layer.source.location,
693
+ key="policy.local_plugins",
694
+ )
695
+
696
+
697
+ def load(
698
+ root: Path,
699
+ *,
700
+ profile_path: Path | None = None,
701
+ user_config: Path | None = None,
702
+ data_dir: Path | None = None,
703
+ fetcher: SourceFetcher | None = None,
704
+ ) -> ResolvedProfile | None:
705
+ """Find, read and merge every configuration layer under `root`.
706
+
707
+ Returns `None` when no project profile exists (callers then allow only read-only
708
+ commands, DESIGN 8.4/6.1).
709
+ """
710
+ project_file = profile_path.resolve() if profile_path is not None else find_profile(root)
711
+ if project_file is None:
712
+ return None
713
+
714
+ if data_dir is None:
715
+ data_dir = _default_data_dir()
716
+
717
+ tool_source = SourceRef(
718
+ kind="tool",
719
+ location="chipgraph.core.config.defaults",
720
+ version=_sha256_json(DEFAULT_PROFILE),
721
+ )
722
+ tool_layer = _Layer(source=tool_source, data=DEFAULT_PROFILE)
723
+
724
+ project_layers = _load_layer_chain(
725
+ project_file, "project", str(project_file), data_dir, fetcher, frozenset()
726
+ )
727
+ layers = [tool_layer, *project_layers]
728
+
729
+ _enforce_tighten_only_policy(layers)
730
+
731
+ merged: dict[str, Any] = {}
732
+ provenance: dict[str, SourceRef] = {}
733
+ for layer in layers:
734
+ merged = _merge(merged, layer.data, layer.source, provenance)
735
+
736
+ try:
737
+ profile = Profile.model_validate(merged)
738
+ except ValidationError as exc:
739
+ raise _translate_validation_error(exc, provenance, str(project_file)) from exc
740
+
741
+ user_path = user_config if user_config is not None else _default_user_config_path()
742
+ user_raw: dict[str, Any] = {}
743
+ user_source = SourceRef(kind="user", location=str(user_path), version="")
744
+ if user_path.is_file():
745
+ loaded, user_hash = _read_yaml_with_hash(user_path)
746
+ user_raw = loaded or {}
747
+ if not isinstance(user_raw, dict):
748
+ raise ConfigError("profile must be a YAML mapping", file=user_path)
749
+ user_source = SourceRef(kind="user", location=str(user_path), version=user_hash)
750
+
751
+ try:
752
+ user = UserConfig.model_validate(user_raw)
753
+ except ValidationError as exc:
754
+ first = exc.errors()[0]
755
+ key = _loc_to_key(first["loc"])
756
+ raise ConfigError(str(first["msg"]), file=str(user_path), key=key or None) from exc
757
+
758
+ _record_leaves(user_raw, user_source, provenance, "user")
759
+
760
+ return ResolvedProfile(
761
+ profile=profile,
762
+ user=user,
763
+ sources=(*(layer.source for layer in layers), user_source),
764
+ profile_hash=_sha256_json(profile.model_dump(mode="json")),
765
+ provenance=provenance,
766
+ )