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,75 @@
1
+ """A configurable regex-based log parser, for tools with no dedicated parser.
2
+
3
+ The pattern must define named groups ``file``, ``line`` and ``msg``; ``rule`` and
4
+ ``severity`` are optional. One `Issue` is produced per match, found with
5
+ `re.finditer` over the whole log text (so `pattern` may itself be multi-line).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from chipgraph.core.contracts import Issue
13
+ from chipgraph.core.contracts.types import Severity
14
+
15
+ _REQUIRED_GROUPS = ("file", "line", "msg")
16
+ _SEVERITY_PREFIXES: tuple[tuple[str, Severity], ...] = (
17
+ ("err", "error"),
18
+ ("warn", "warning"),
19
+ ("info", "info"),
20
+ )
21
+
22
+
23
+ class GenericRegexParser:
24
+ """A `LogParser` driven entirely by a user-supplied regex pattern."""
25
+
26
+ name = "generic-regex"
27
+
28
+ def __init__(
29
+ self,
30
+ pattern: str,
31
+ severity_group: str | None = None,
32
+ default_severity: Severity = "error",
33
+ ) -> None:
34
+ try:
35
+ self._regex = re.compile(pattern, re.MULTILINE)
36
+ except re.error as exc:
37
+ raise ValueError(f"invalid regex pattern {pattern!r}: {exc}") from exc
38
+
39
+ group_names = set(self._regex.groupindex)
40
+ missing = [name for name in _REQUIRED_GROUPS if name not in group_names]
41
+ if missing:
42
+ raise ValueError(f"pattern {pattern!r} is missing required named group(s): {missing}")
43
+ if severity_group is not None and severity_group not in group_names:
44
+ raise ValueError(
45
+ f"severity_group {severity_group!r} is not a named group in {pattern!r}"
46
+ )
47
+
48
+ self._severity_group = severity_group
49
+ self._default_severity = default_severity
50
+
51
+ def parse(self, log: str) -> tuple[Issue, ...]:
52
+ issues: list[Issue] = []
53
+ for match in self._regex.finditer(log):
54
+ groups = match.groupdict()
55
+ issues.append(
56
+ Issue(
57
+ file=groups.get("file") or None,
58
+ line=int(groups["line"]) if groups.get("line") else None,
59
+ rule=groups.get("rule") or "",
60
+ severity=self._severity(groups),
61
+ msg=(groups.get("msg") or "").strip(),
62
+ )
63
+ )
64
+ return tuple(issues)
65
+
66
+ def _severity(self, groups: dict[str, str | None]) -> Severity:
67
+ group_name = self._severity_group or "severity"
68
+ raw = groups.get(group_name)
69
+ if not raw:
70
+ return self._default_severity
71
+ lowered = raw.strip().lower()
72
+ for prefix, severity in _SEVERITY_PREFIXES:
73
+ if lowered.startswith(prefix):
74
+ return severity
75
+ return self._default_severity
@@ -0,0 +1,55 @@
1
+ """Log parser for `verible-verilog-lint` output.
2
+
3
+ Handles both the column-range shape (``file:line:col1-col2: msg [rule]``) and the
4
+ single-column shape (``file:line:col: msg [rule]``). A lint finding is always a style
5
+ warning; a `syntax error at token ...` line (verible reports these the same way, with
6
+ no trailing `[rule]` bracket) is treated as an error with rule ``syntax``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ from chipgraph.core.contracts import Issue
14
+ from chipgraph.core.contracts.types import Severity
15
+
16
+ _LINE_RE = re.compile(
17
+ r"^(?P<file>[^\s:]+):(?P<line>\d+):(?P<col1>\d+)(-(?P<col2>\d+))?:\s*(?P<msg>.*)$"
18
+ )
19
+ _TRAILING_RULE_RE = re.compile(r"\s*\[(?P<rule>[^\[\]]+)\]\s*$")
20
+
21
+
22
+ class VeribleParser:
23
+ """Parses `verible-verilog-lint` logs into `Issue`s."""
24
+
25
+ name = "verible"
26
+
27
+ def parse(self, log: str) -> tuple[Issue, ...]:
28
+ issues: list[Issue] = []
29
+ for raw_line in log.splitlines():
30
+ match = _LINE_RE.match(raw_line)
31
+ if match is None:
32
+ continue
33
+ msg = match.group("msg").strip()
34
+ severity: Severity
35
+ rule_match = _TRAILING_RULE_RE.search(msg)
36
+ if rule_match is not None:
37
+ rule = rule_match.group("rule")
38
+ msg = msg[: rule_match.start()].rstrip()
39
+ severity = "warning"
40
+ elif "syntax error" in msg:
41
+ rule = "syntax"
42
+ severity = "error"
43
+ else:
44
+ rule = ""
45
+ severity = "warning"
46
+ issues.append(
47
+ Issue(
48
+ file=match.group("file"),
49
+ line=int(match.group("line")),
50
+ rule=rule,
51
+ severity=severity,
52
+ msg=msg,
53
+ )
54
+ )
55
+ return tuple(issues)
@@ -0,0 +1,46 @@
1
+ """Log parser for Verilator (`--lint-only` and simulation build) output.
2
+
3
+ Handles both the 5.0xx line shape with a column (``%Error-CODE: file:line:col: msg``)
4
+ and the older shape without one (``%Warning-CODE: file:line: msg``), across the
5
+ Verilator versions this project has fixtures for (5.020 and 5.052). Continuation
6
+ lines (indented source context, ``... For warning description ...`` hints) and the
7
+ final summary line (``%Error: Exiting due to N error(s)``, which names no file) are
8
+ ignored.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ from chipgraph.core.contracts import Issue
16
+ from chipgraph.core.contracts.types import Severity
17
+
18
+ _LINE_RE = re.compile(
19
+ r"^\s*%(?P<kind>Error|Warning)(-(?P<rule>[A-Za-z0-9_]+))?:\s+"
20
+ r"(?P<file>[^\s:]+):(?P<line>\d+)(:(?P<col>\d+))?:\s*(?P<msg>.*)$"
21
+ )
22
+
23
+
24
+ class VerilatorParser:
25
+ """Parses Verilator lint/build logs into `Issue`s."""
26
+
27
+ name = "verilator"
28
+
29
+ def parse(self, log: str) -> tuple[Issue, ...]:
30
+ issues: list[Issue] = []
31
+ for raw_line in log.splitlines():
32
+ match = _LINE_RE.match(raw_line)
33
+ if match is None:
34
+ continue # continuation line, source context, or summary line
35
+ kind = match.group("kind")
36
+ severity: Severity = "error" if kind == "Error" else "warning"
37
+ issues.append(
38
+ Issue(
39
+ file=match.group("file"),
40
+ line=int(match.group("line")),
41
+ rule=match.group("rule") or "",
42
+ severity=severity,
43
+ msg=match.group("msg").strip(),
44
+ )
45
+ )
46
+ return tuple(issues)
@@ -0,0 +1,7 @@
1
+ """Review adapters: record and retrieve human approvals, behind `ReviewAdapter`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from chipgraph.adapters.review.file import FileReview, ReviewError
6
+
7
+ __all__ = ["FileReview", "ReviewError"]
@@ -0,0 +1,123 @@
1
+ """The `file` review adapter: one small YAML decision file per approval.
2
+
3
+ Implements `chipgraph.core.plugin_api.protocols.ReviewAdapter`. Every recorded
4
+ decision (approve, reject, baseline, waive) is written as its own file under a
5
+ `decisions_dir` (typically `.chipgraph/decisions/`), so two people approving at the
6
+ same time never conflict on the same file (DESIGN.md 6.1).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ import secrets
14
+ import tempfile
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+
18
+ import yaml
19
+ from pydantic import ValidationError
20
+
21
+ from chipgraph.core.contracts import Approval
22
+
23
+ _UNSAFE_ID_CHARS = re.compile(r"[^A-Za-z0-9._-]")
24
+ _MAX_NAME_ATTEMPTS = 8
25
+
26
+
27
+ class ReviewError(Exception):
28
+ """Raised when a decision file cannot be recorded or a decision file is malformed."""
29
+
30
+
31
+ def _safe_id(gate_id: str) -> str:
32
+ """Replace any character outside `[A-Za-z0-9._-]` in `gate_id` with `_`."""
33
+ return _UNSAFE_ID_CHARS.sub("_", gate_id)
34
+
35
+
36
+ class FileReview:
37
+ """Records approvals as one YAML file per decision under `decisions_dir`."""
38
+
39
+ name = "file"
40
+
41
+ def __init__(self, decisions_dir: Path | None = None) -> None:
42
+ # The registry builds adapters with no arguments; the default is the current
43
+ # project's `.chipgraph/decisions/`, and callers that know the root pass it.
44
+ self.decisions_dir = (
45
+ decisions_dir
46
+ if decisions_dir is not None
47
+ else (Path.cwd() / ".chipgraph" / "decisions")
48
+ )
49
+
50
+ def record(self, approval: Approval) -> None:
51
+ """Write `approval` to a new file in `decisions_dir`.
52
+
53
+ The file name is `<safe gate id>--<UTC YYYYmmddTHHMMSSZ>-<6 hex>.yml`. The
54
+ write is atomic (a temp file is written first, then moved into place with
55
+ `os.replace`), and an existing file is never overwritten: on the vanishingly
56
+ unlikely chance of a name collision, a fresh random suffix is tried again.
57
+ """
58
+ self.decisions_dir.mkdir(parents=True, exist_ok=True)
59
+ safe_id = _safe_id(approval.gate_id)
60
+ ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
61
+ content = yaml.safe_dump(approval.model_dump(mode="json"), sort_keys=True)
62
+
63
+ for _ in range(_MAX_NAME_ATTEMPTS):
64
+ suffix = secrets.token_hex(3)
65
+ target = self.decisions_dir / f"{safe_id}--{ts}-{suffix}.yml"
66
+ if target.exists():
67
+ continue
68
+
69
+ fd, tmp_name = tempfile.mkstemp(
70
+ dir=self.decisions_dir, prefix=".tmp-decision-", suffix=".yml"
71
+ )
72
+ tmp_path = Path(tmp_name)
73
+ try:
74
+ with os.fdopen(fd, "w") as handle:
75
+ handle.write(content)
76
+ if target.exists():
77
+ tmp_path.unlink(missing_ok=True)
78
+ continue
79
+ os.replace(tmp_path, target)
80
+ except BaseException:
81
+ tmp_path.unlink(missing_ok=True)
82
+ raise
83
+ return
84
+
85
+ raise ReviewError(
86
+ f"could not allocate a unique decision file name for gate {approval.gate_id!r} "
87
+ f"in {self.decisions_dir}"
88
+ )
89
+
90
+ def approvals(self, gate_id: str) -> tuple[Approval, ...]:
91
+ """Return every approval recorded for `gate_id`, sorted by `at` then file name.
92
+
93
+ Every `*.yml`/`*.yaml` file in `decisions_dir` (except temp files left behind
94
+ by an interrupted write, which start with `.`) is parsed. A file that is not
95
+ valid YAML, not a mapping, or does not validate as an `Approval` raises
96
+ `ReviewError` naming that file; it is never silently skipped.
97
+ """
98
+ if not self.decisions_dir.is_dir():
99
+ return ()
100
+
101
+ matches: list[tuple[Approval, str]] = []
102
+ paths = sorted({*self.decisions_dir.glob("*.yml"), *self.decisions_dir.glob("*.yaml")})
103
+ for path in paths:
104
+ if path.name.startswith("."):
105
+ continue
106
+ try:
107
+ raw = yaml.safe_load(path.read_text())
108
+ except yaml.YAMLError as exc:
109
+ raise ReviewError(f"malformed decision file {path}: invalid YAML: {exc}") from exc
110
+
111
+ if not isinstance(raw, dict):
112
+ raise ReviewError(f"malformed decision file {path}: not a YAML mapping")
113
+ if raw.get("gate_id") != gate_id:
114
+ continue
115
+
116
+ try:
117
+ approval = Approval.model_validate(raw)
118
+ except ValidationError as exc:
119
+ raise ReviewError(f"malformed decision file {path}: {exc}") from exc
120
+ matches.append((approval, path.name))
121
+
122
+ matches.sort(key=lambda pair: (pair[0].at, pair[1]))
123
+ return tuple(approval for approval, _ in matches)