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,165 @@
1
+ """The body of a block's pull request: what a reviewer needs, factual and short.
2
+
3
+ Four parts, each from data the build already has:
4
+
5
+ - **Plan**: the approved plan's path and hash, its top and modules in build order (what
6
+ each does, its requirements, its files);
7
+ - **Requirement coverage**: each requirement of the block and the modules covering it,
8
+ or why it is unassigned (`chipgraph plan show`'s table);
9
+ - **Checks**: the last result of each check of the block's `rtl_module` and `tb_module`
10
+ instances, from the run journals;
11
+ - **Critic review**: the verdict and summary of `reports/review/<block>.json`, with its
12
+ blocker and major comments (`file:line`, the claim); minor ones are counted.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from collections.abc import Iterable, Mapping, Sequence
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from chipgraph.app.context import AppContext
23
+ from chipgraph.core.runtime.roles import ReviewReport
24
+ from chipgraph.core.state import journal as journal_mod
25
+
26
+ MAX_COMMENTS = 20
27
+ """Most blocker/major comments listed; the rest are counted."""
28
+ MAX_CHARS = 60_000
29
+ """Longest body (GitHub's limit is 65 536 characters)."""
30
+
31
+ CHECKED_RULES = ("digital-rtl/rtl_module", "dv/tb_module")
32
+ """The rules whose instances' check results the body lists."""
33
+
34
+
35
+ def last_checks(ctx: AppContext, block: str) -> dict[str, dict[str, str]]:
36
+ """Instance id -> check id -> status: the last result of each, over every run."""
37
+ runs = ctx.layout.runs_dir
38
+ found: dict[str, dict[str, str]] = {}
39
+ if not runs.is_dir():
40
+ return found
41
+ for run in sorted(p.name for p in runs.iterdir() if p.is_dir()):
42
+ try:
43
+ events = journal_mod.read(ctx.layout.journal(run)).events
44
+ except (OSError, ValueError):
45
+ continue
46
+ for event in events:
47
+ iid = event.rule_instance
48
+ if event.type != "check_result" or iid is None or not _of_block(iid, block):
49
+ continue
50
+ check = event.payload.get("check_id")
51
+ status = event.payload.get("status")
52
+ if isinstance(check, str) and isinstance(status, str):
53
+ found.setdefault(iid, {})[check] = status
54
+ return found
55
+
56
+
57
+ def _of_block(instance_id: str, block: str) -> bool:
58
+ rule, _, rest = instance_id.partition("[")
59
+ params = dict(p.partition("=")[::2] for p in rest.removesuffix("]").split(",") if "=" in p)
60
+ return rule in CHECKED_RULES and params.get("block") == block
61
+
62
+
63
+ def read_review(path: Path) -> ReviewReport | None:
64
+ """The Critic's review report at `path`, or None if it is missing or not one."""
65
+ try:
66
+ return ReviewReport.model_validate(json.loads(path.read_text(encoding="utf-8")))
67
+ except (OSError, ValueError):
68
+ return None
69
+
70
+
71
+ def _plan_lines(summary: Mapping[str, Any]) -> list[str]:
72
+ gate = summary.get("gate", {})
73
+ sha = str(summary.get("sha256") or "")[:12]
74
+ lines = [
75
+ "### Plan",
76
+ f"`{summary.get('path')}` (sha256 `{sha}`), gate `{gate.get('id')}`: "
77
+ f"{gate.get('status')}. Top: `{summary.get('top')}`.",
78
+ "",
79
+ ]
80
+ for module in summary.get("modules", []):
81
+ reqs = ", ".join(module.get("reqs", [])) or "-"
82
+ files = ", ".join(f"`{w['path']}`" for w in module.get("writes", []))
83
+ lines.append(f"- `{module['name']}`: {module['summary']} Requirements: {reqs}. {files}")
84
+ questions = summary.get("open_questions") or []
85
+ if questions:
86
+ lines.append("")
87
+ lines.append("Open questions in the plan: " + "; ".join(questions))
88
+ return lines
89
+
90
+
91
+ def _coverage_lines(summary: Mapping[str, Any]) -> list[str]:
92
+ rows = summary.get("coverage") or []
93
+ lines = ["### Requirement coverage"]
94
+ if not rows:
95
+ return [*lines, "The block has no requirements in the Design Model."]
96
+ covered = sum(1 for r in rows if r.get("modules"))
97
+ lines.append(f"{covered} of {len(rows)} requirements assigned to a module.")
98
+ lines.append("")
99
+ for row in rows:
100
+ if row.get("modules"):
101
+ lines.append(f"- {row['req']}: {', '.join(row['modules'])}")
102
+ elif row.get("unassigned"):
103
+ lines.append(f"- {row['req']}: unassigned ({row['unassigned']})")
104
+ else:
105
+ lines.append(f"- {row['req']}: not covered")
106
+ return lines
107
+
108
+
109
+ def _check_lines(checks: Mapping[str, Mapping[str, str]]) -> list[str]:
110
+ lines = ["### Checks"]
111
+ if not checks:
112
+ return [*lines, "No check results recorded for this block."]
113
+ for iid in sorted(checks):
114
+ results = ", ".join(f"{cid} {status}" for cid, status in checks[iid].items())
115
+ lines.append(f"- `{iid}`: {results}")
116
+ return lines
117
+
118
+
119
+ def _review_lines(review: ReviewReport | None, path: str) -> list[str]:
120
+ lines = ["### Critic review"]
121
+ if review is None:
122
+ return [*lines, f"No valid review report at `{path}`."]
123
+ lines.append(f"Verdict: **{review.verdict}**. {review.summary}")
124
+ serious = [c for c in review.comments if c.severity in ("blocker", "major")]
125
+ others = len(review.comments) - len(serious)
126
+ for comment in serious[:MAX_COMMENTS]:
127
+ req = f" ({comment.req_id})" if comment.req_id else ""
128
+ lines.append(f"- {comment.severity} `{comment.file}:{comment.line}`{req}: {comment.claim}")
129
+ if len(serious) > MAX_COMMENTS:
130
+ lines.append(f"- and {len(serious) - MAX_COMMENTS} more blocker/major comments")
131
+ if others:
132
+ lines.append(f"{others} minor or nit comment(s) are in `{path}`.")
133
+ if not review.comments:
134
+ lines.append("No findings.")
135
+ return lines
136
+
137
+
138
+ def render_body(
139
+ *,
140
+ block: str,
141
+ summary: Mapping[str, Any],
142
+ checks: Mapping[str, Mapping[str, str]],
143
+ review: ReviewReport | None,
144
+ review_path: str,
145
+ files: Sequence[str],
146
+ ) -> str:
147
+ """The PR body (Markdown), at most `MAX_CHARS` characters."""
148
+ parts: Iterable[list[str]] = (
149
+ [
150
+ f"Block `{block}`: RTL and testbench built by chipgraph from the approved plan.",
151
+ "Merging is a person's decision: chipgraph never merges.",
152
+ ],
153
+ _plan_lines(summary),
154
+ _coverage_lines(summary),
155
+ _check_lines(checks),
156
+ _review_lines(review, review_path),
157
+ ["### Files", *(f"- `{f}`" for f in files)],
158
+ )
159
+ text = "\n\n".join("\n".join(part) for part in parts) + "\n"
160
+ if len(text) > MAX_CHARS:
161
+ text = text[: MAX_CHARS - 40] + "\n\n(cut: the body is too long)\n"
162
+ return text
163
+
164
+
165
+ __all__ = ["CHECKED_RULES", "last_checks", "read_review", "render_body"]
@@ -0,0 +1,110 @@
1
+ """`PrGateTool`: the `pr_gate` tool of rule `digital-rtl/pr_gate` (M2-11a).
2
+
3
+ The rule's input is `pr/<block>.json` (what `open_pr` wrote) and its gate `pr:<block>`,
4
+ so the scheduler runs this only once the gate is approved:
5
+
6
+ - with `review: {use: github}`, by a reviewer's approval on the pull request at its
7
+ current head commit (`GitHubGateChecker`; a later push makes it stale);
8
+ - with the `file` adapter, by `chipgraph approve pr:<block>`, which pins the hashes of
9
+ the rule's inputs (the PR record, the block's files): offline.
10
+
11
+ With the GitHub adapter it first compares the head commit of `pr/<block>.json` with the
12
+ pull request's **live** head: if someone pushed to the branch since `open_pr` ran, the
13
+ record is stale and the gate does not pass (it says to run the target again, which fast-forwards
14
+ the record) instead of recording a commit nobody reviewed.
15
+
16
+ It writes `pr/<block>.approved.json` (default; arg `out`): the branch and head commit
17
+ that were approved. It never merges: merging stays a person's act on the platform.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import time
24
+
25
+ from chipgraph.adapters.review.github import ReviewError
26
+ from chipgraph.app.context import AppContext
27
+ from chipgraph.app.errors import AppError
28
+ from chipgraph.checks._common import compute_idempotency_key
29
+ from chipgraph.core.contracts import CheckResult, CheckSpec, Issue
30
+ from chipgraph.core.plugin_api.types import ToolContext
31
+ from chipgraph.packs.digital_rtl.pr.open import DEFAULT_OUT, OpenPrTool, read_record
32
+
33
+ DEFAULT_APPROVED = "pr/{block}.approved.json"
34
+
35
+
36
+ class PrGateTool:
37
+ """Records the approved head of a block's pull request, once its gate is approved."""
38
+
39
+ name = "pr_gate"
40
+ capability = "gen"
41
+
42
+ async def run(self, spec: CheckSpec, ctx: ToolContext) -> CheckResult:
43
+ start = time.monotonic()
44
+ block = ctx.params.get("block", "")
45
+ record_t = spec.args.get("pr", DEFAULT_OUT)
46
+ out_t = spec.args.get("out", DEFAULT_APPROVED)
47
+ if not block or not isinstance(record_t, str) or not isinstance(out_t, str):
48
+ return self._result(spec, "error", "pr_gate needs a block and string templates", start)
49
+ rel = record_t.replace("{block}", block)
50
+ record = read_record(ctx.repo_root / rel)
51
+ if record is None:
52
+ return self._result(spec, "fail", f"no valid PR record at {rel}", start)
53
+ if record.review == "github":
54
+ stale = self._stale(ctx, record.number, record.head_sha, block)
55
+ if stale is not None:
56
+ return self._result(spec, "fail", stale, start)
57
+ out = ctx.repo_root / out_t.replace("{block}", block)
58
+ out.parent.mkdir(parents=True, exist_ok=True)
59
+ payload = {
60
+ "schema_version": 1,
61
+ "block": block,
62
+ "gate": record.gate,
63
+ "branch": record.branch,
64
+ "head_sha": record.head_sha,
65
+ "number": record.number,
66
+ "url": record.url,
67
+ }
68
+ out.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
69
+ return self._result(spec, "pass", f"{record.gate} approved at {record.head_sha}", start)
70
+
71
+ @staticmethod
72
+ def _stale(ctx: ToolContext, number: int | None, recorded: str, block: str) -> str | None:
73
+ """Why the record cannot be trusted (None when the PR's live head is the recorded one)."""
74
+ if number is None:
75
+ return f"the PR record of {block!r} has no pull request number"
76
+ try:
77
+ review = OpenPrTool.review_for(AppContext.load(ctx.repo_root))
78
+ if review is None:
79
+ return "the PR record says github, but the profile has no github review adapter"
80
+ live = review.pull_request(number)
81
+ except (AppError, ReviewError, OSError) as exc:
82
+ return f"cannot read pull request #{number}: {exc}"
83
+ if live.state != "open":
84
+ return f"pull request #{number} is {live.state}, not open"
85
+ if live.head_sha != recorded:
86
+ return (
87
+ f"pull request #{number} head is {live.head_sha[:12]} but the record has "
88
+ f"{recorded[:12]}: the branch moved after open_pr ran; run the target again "
89
+ "so the record, and the review, follow the live head"
90
+ )
91
+ return None
92
+
93
+ @staticmethod
94
+ def _result(spec: CheckSpec, status: str, message: str, start: float) -> CheckResult:
95
+ return CheckResult(
96
+ check_id=spec.id,
97
+ status=status, # type: ignore[arg-type]
98
+ issues=(
99
+ Issue(
100
+ rule="pr_gate",
101
+ severity="info" if status == "pass" else "error",
102
+ msg=message,
103
+ ),
104
+ ),
105
+ duration_s=time.monotonic() - start,
106
+ idempotency_key=compute_idempotency_key(spec.id, spec.args, ()),
107
+ )
108
+
109
+
110
+ __all__ = ["DEFAULT_APPROVED", "PrGateTool"]
@@ -0,0 +1,397 @@
1
+ """`OpenPrTool`: the `open_pr` tool of rule `digital-rtl/open_pr` (M2-11a).
2
+
3
+ For one block with an approved plan, once its modules, its testbench and its review are
4
+ built (the rule's inputs), it
5
+
6
+ 1. builds the branch `chipgraph/<run id>/<block>` **without touching the user's working
7
+ tree, index or HEAD**: a workspace (a git worktree) at HEAD, or at the branch's tip
8
+ when it exists already; exactly the block's RTL files (every module's write set) and
9
+ testbench copied into it from the user's tree; one commit with hooks off; the branch
10
+ created, or moved forward (a fast-forward, compare and swap), never rewritten. The
11
+ workspace is removed on every exit;
12
+ 2. with `review: {use: github}`: pushes the branch to the profile's `review.remote`
13
+ (default `origin`), a fast-forward push, never a force. An `https://` remote needs the
14
+ token in `CHIPGRAPH_GITHUB_TOKEN`, which git gets through a `GIT_ASKPASS` helper only
15
+ (`GitVcs.push_branch`); a missing token is a clear error; a `file://` or local-path
16
+ remote needs none. Then opens the pull request with `GitHubReview.open_pr`
17
+ (idempotent: an open PR from the branch is reused, never re-opened) into
18
+ `review.base`, with the body of `body.render_body`, and binds gate `pr:<block>` to
19
+ it. With the `file` review adapter nothing is pushed or opened: the gate is approved
20
+ with `chipgraph approve pr:<block>`, offline;
21
+ 3. writes `pr/<block>.json` (`PrRecord`): the branch, its head commit, the PR number and
22
+ URL (none with the `file` adapter). A push after review changes the head, so the
23
+ record changes, `digital-rtl/pr_gate` goes stale and the old approval with it.
24
+
25
+ Rule args (`{block}` templates): `review` (default `reports/review/{block}.json`) and
26
+ `out` (default `pr/{block}.json`). Nothing here merges.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import os
33
+ import re
34
+ import shutil
35
+ import stat
36
+ import tempfile
37
+ import time
38
+ import urllib.parse
39
+ from collections.abc import Callable, Sequence
40
+ from pathlib import Path
41
+ from typing import ClassVar, Literal
42
+
43
+ from pydantic import BaseModel, ConfigDict, Field
44
+
45
+ from chipgraph.adapters.review.github import TOKEN_ENV, GitHubReview, ReviewError
46
+ from chipgraph.adapters.vcs.git import GitVcs, VcsError, redact_secret, url_host
47
+ from chipgraph.app.build import load_rules
48
+ from chipgraph.app.context import AppContext
49
+ from chipgraph.app.errors import AppError
50
+ from chipgraph.checks._common import compute_idempotency_key
51
+ from chipgraph.checks.plan_check import PlanEnvError
52
+ from chipgraph.core.contracts import CheckResult, CheckSpec, Issue, RuleSpec
53
+ from chipgraph.core.engine.gate import gate_id_for
54
+ from chipgraph.core.plugin_api.types import ToolContext
55
+ from chipgraph.core.state.layout import StateLayout, new_run_id
56
+ from chipgraph.packs.digital_rtl.plan.resolver import (
57
+ ApprovedPlan,
58
+ approved_plan,
59
+ block_files,
60
+ plan_gates,
61
+ )
62
+ from chipgraph.packs.digital_rtl.plan.show import plan_summary
63
+ from chipgraph.packs.digital_rtl.pr.body import last_checks, read_review, render_body
64
+
65
+ DEFAULT_REVIEW = "reports/review/{block}.json"
66
+ DEFAULT_OUT = "pr/{block}.json"
67
+ PR_GATE_RULE = "digital-rtl/pr_gate"
68
+ DEFAULT_GATE = "pr:{block}"
69
+ BRANCH_PREFIX = "chipgraph"
70
+
71
+
72
+ class OpenPrError(Exception):
73
+ """The block's pull request cannot be built (a message for a person)."""
74
+
75
+
76
+ class PrRecord(BaseModel):
77
+ """`pr/<block>.json`: the branch and pull request of a block's planned RTL."""
78
+
79
+ model_config = ConfigDict(extra="forbid", frozen=True)
80
+
81
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this file.")
82
+ block: str = Field(description="The block.")
83
+ branch: str = Field(description="The branch, `chipgraph/<run id>/<block>`.")
84
+ head_sha: str = Field(description="The branch's head commit.")
85
+ files: tuple[str, ...] = Field(description="The files the branch adds or changes.")
86
+ review: Literal["github", "file"] = Field(description="Where the gate is decided.")
87
+ gate: str = Field(description="The gate the PR is bound to, `pr:<block>`.")
88
+ remote: str | None = Field(default=None, description="The remote it was pushed to.")
89
+ base: str | None = Field(default=None, description="The PR's base branch.")
90
+ number: int | None = Field(default=None, description="The PR number (GitHub).")
91
+ url: str | None = Field(default=None, description="The PR's web URL (GitHub).")
92
+
93
+
94
+ def _fill(template: str, block: str) -> str:
95
+ return template.replace("{block}", block)
96
+
97
+
98
+ def needs_token(url: str) -> bool:
99
+ """Whether pushing to `url` needs the HTTPS token (not for file://, a path, or SSH)."""
100
+ return urllib.parse.urlsplit(url).scheme in ("http", "https")
101
+
102
+
103
+ def check_token_host(url: str, api_url: str) -> None:
104
+ """Refuse to send the token to a git host that is not the API's host.
105
+
106
+ The remote comes from the repo's git config; a token meant for `api_url`'s GitHub must
107
+ not be handed to whatever host `origin` points at.
108
+ """
109
+ git_host = url_host(url)
110
+ api_host = url_host(api_url)
111
+ allowed = {api_host, api_host.removeprefix("api.") if api_host else None} - {None}
112
+ if git_host is None or git_host not in allowed:
113
+ raise OpenPrError(
114
+ f"refusing to send the GitHub token to {git_host or url!r}: the API is at "
115
+ f"{api_host!r}, so the remote must be on {sorted(str(h) for h in allowed)}"
116
+ )
117
+
118
+
119
+ def _latest_run(layout: StateLayout) -> str:
120
+ runs = layout.runs_dir
121
+ if runs.is_dir():
122
+ ids = sorted(p.name for p in runs.iterdir() if p.is_dir())
123
+ if ids:
124
+ return ids[-1]
125
+ return new_run_id()
126
+
127
+
128
+ def _gate_id(rules: Sequence[RuleSpec], block: str) -> str:
129
+ rule = next((r for r in rules if r.id == PR_GATE_RULE), None)
130
+ template = rule.gate if rule is not None and rule.gate else DEFAULT_GATE
131
+ return gate_id_for(template, {"block": block})
132
+
133
+
134
+ def read_record(path: Path) -> PrRecord | None:
135
+ """The `PrRecord` at `path`, or None if it is missing or invalid."""
136
+ try:
137
+ return PrRecord.model_validate_json(path.read_text(encoding="utf-8"))
138
+ except (OSError, ValueError):
139
+ return None
140
+
141
+
142
+ def check_branch(branch: str, block: str) -> None:
143
+ """Refuse a branch name that is not `chipgraph/<run id>/<block>`.
144
+
145
+ The name can come from `pr/<block>.json`, a file in the working tree that anyone may
146
+ edit; moving or pushing `main` because that file says so would publish without a PR.
147
+ """
148
+ pattern = rf"{re.escape(BRANCH_PREFIX)}/[A-Za-z0-9][A-Za-z0-9._-]*/{re.escape(block)}"
149
+ if re.fullmatch(pattern, branch) is None:
150
+ raise OpenPrError(
151
+ f"refusing branch {branch!r} for block {block!r}: a block's pull request branch "
152
+ f"is {BRANCH_PREFIX}/<run id>/{block}"
153
+ )
154
+
155
+
156
+ def _plain_path(base: Path, rel: str, *, what: str) -> Path:
157
+ """`base / rel` with no symlink on the way and inside `base` (else `OpenPrError`).
158
+
159
+ Every component is checked, not only the last: a symlinked directory in the user's tree
160
+ would read from outside it, and one in the committed tree of the branch would write
161
+ outside the workspace.
162
+ """
163
+ parts = Path(rel).parts
164
+ if not parts or Path(rel).is_absolute() or any(part in ("", ".", "..") for part in parts):
165
+ raise OpenPrError(f"{what} path {rel!r} is not a plain relative path")
166
+ path = base
167
+ for part in parts:
168
+ path = path / part
169
+ if path.is_symlink():
170
+ raise OpenPrError(f"{what} path {rel!r}: {part!r} is a symlink")
171
+ try:
172
+ path.resolve().relative_to(base.resolve())
173
+ except ValueError:
174
+ raise OpenPrError(f"{what} path {rel!r} resolves outside {base}") from None
175
+ return path
176
+
177
+
178
+ def _copy_file(src: Path, dest: Path) -> None:
179
+ """Copy the regular file `src` to `dest` without following a link at either end."""
180
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
181
+ try:
182
+ fd = os.open(src, flags)
183
+ except OSError as exc:
184
+ raise OpenPrError(f"cannot read {src}: {exc.strerror or exc}") from None
185
+ with os.fdopen(fd, "rb") as source:
186
+ mode = os.fstat(source.fileno()).st_mode
187
+ if not stat.S_ISREG(mode):
188
+ raise OpenPrError(f"{src} is not a regular file")
189
+ tmp = dest.with_name(f".{dest.name}.chipgraph-tmp")
190
+ try:
191
+ with open(tmp, "xb") as target:
192
+ shutil.copyfileobj(source, target)
193
+ os.chmod(tmp, stat.S_IMODE(mode))
194
+ os.replace(tmp, dest) # replaces a link itself, never what it points at
195
+ finally:
196
+ if tmp.exists() or tmp.is_symlink():
197
+ tmp.unlink()
198
+
199
+
200
+ def build_branch(
201
+ vcs: GitVcs, root: Path, branch: str, files: Sequence[str], message: str
202
+ ) -> tuple[str, str | None]:
203
+ """Commit exactly `files` (copied from `root`) onto `branch`; `(new head, old head)`.
204
+
205
+ The commit is made in a workspace at the branch's tip (or HEAD for a new branch),
206
+ outside the user's tree; the branch is then created or fast-forwarded. HEAD, the
207
+ index and the working tree of `root` are never touched.
208
+ """
209
+ if branch in vcs.checked_out_branches(root):
210
+ raise OpenPrError(
211
+ f"refusing branch {branch!r}: it is checked out in a worktree; switch that "
212
+ "worktree to another branch first (the PR branch is moved without a checkout)"
213
+ )
214
+ sources = {rel: _plain_path(root, rel, what="output") for rel in files}
215
+ old = vcs.branch_tip(root, branch)
216
+ base = old or vcs.head(root)
217
+ holder = Path(tempfile.mkdtemp(prefix="chipgraph-pr-"))
218
+ workspace: Path | None = None
219
+ try:
220
+ workspace = vcs.create_branch_workspace(root, base, "pr", holder)
221
+ for rel in files:
222
+ _plain_path(workspace, rel, what="branch")
223
+ dest = workspace / rel
224
+ dest.parent.mkdir(parents=True, exist_ok=True)
225
+ dest = _plain_path(workspace, rel, what="branch") # again: mkdir made the parents
226
+ if dest.is_dir():
227
+ raise OpenPrError(f"{rel} is a directory at {base[:12]}")
228
+ _copy_file(sources[rel], dest)
229
+ commit = vcs.commit_all(workspace, message, paths=list(files))
230
+ finally:
231
+ if workspace is not None:
232
+ vcs.remove(workspace)
233
+ vcs.prune(root)
234
+ shutil.rmtree(holder, ignore_errors=True)
235
+ if old is None:
236
+ vcs.create_branch(root, branch, commit)
237
+ else:
238
+ vcs.advance_branch(root, branch, commit, old)
239
+ return commit, old
240
+
241
+
242
+ def open_block_pr(
243
+ app: AppContext,
244
+ block: str,
245
+ *,
246
+ review_path: str = DEFAULT_REVIEW,
247
+ out_path: str = DEFAULT_OUT,
248
+ review: GitHubReview | None = None,
249
+ vcs: GitVcs | None = None,
250
+ token: str | None = None,
251
+ ) -> PrRecord:
252
+ """Build, push and open the PR of `block`; write and return its `PrRecord`.
253
+
254
+ `review` is the GitHub adapter (None: the `file` adapter, nothing pushed or opened);
255
+ `token` the HTTPS token, used only for an `http(s)://` remote. Raises `OpenPrError`.
256
+ """
257
+ vcs = vcs or GitVcs()
258
+ root = app.root
259
+ rules = load_rules(app)
260
+ resolved = app.require_profile()
261
+ if block not in resolved.profile.blocks:
262
+ raise OpenPrError(f"unknown block {block!r}")
263
+ gate = plan_gates(rules, resolved.profile.blocks, repo=app.store.repo)[block]
264
+ try:
265
+ approved: ApprovedPlan | None = approved_plan(app, rules, gate)
266
+ except PlanEnvError as exc:
267
+ raise OpenPrError(str(exc)) from exc
268
+ if approved is None:
269
+ raise OpenPrError(
270
+ f"block {block!r} has no approved, valid plan: approve gate {gate.gate_id!r} first"
271
+ )
272
+ rtl, tb = block_files(rules, approved)
273
+ files = [*rtl, *tb]
274
+ missing = [rel for rel in files if not (root / rel).is_file()]
275
+ if missing:
276
+ raise OpenPrError(f"block {block!r}: not built yet: {', '.join(missing)}")
277
+
278
+ out = root / _fill(out_path, block)
279
+ previous = read_record(out)
280
+ branch = (
281
+ previous.branch
282
+ if previous is not None and previous.block == block
283
+ else f"{BRANCH_PREFIX}/{_latest_run(app.layout)}/{block}"
284
+ )
285
+ check_branch(branch, block)
286
+ message = f"chipgraph: RTL and testbench of block {block} from its approved plan"
287
+ head, _old = build_branch(vcs, root, branch, files, message)
288
+
289
+ gate_id = _gate_id(rules, block)
290
+ record = PrRecord(
291
+ block=block, branch=branch, head_sha=head, files=tuple(files), review="file", gate=gate_id
292
+ )
293
+ if review is not None:
294
+ remote = review.remote
295
+ url = vcs.remote_url(root, remote)
296
+ secret: str | None = None
297
+ if needs_token(url):
298
+ check_token_host(url, review.api_url)
299
+ secret = token
300
+ if not secret:
301
+ raise OpenPrError(
302
+ f"no GitHub token: set {TOKEN_ENV} to push {branch!r} to remote "
303
+ f"{remote!r} (a fine-grained token with Contents and Pull requests "
304
+ "read/write on the repo)"
305
+ )
306
+ vcs.push_branch(root, remote, head, branch, token=secret)
307
+ rel_review = _fill(review_path, block)
308
+ body = render_body(
309
+ block=block,
310
+ summary=plan_summary(app, block),
311
+ checks=last_checks(app, block),
312
+ review=read_review(root / rel_review),
313
+ review_path=rel_review,
314
+ files=files,
315
+ )
316
+ pr = review.open_pr(branch, None, f"chipgraph: RTL of block {block}", body, gate_id=gate_id)
317
+ record = record.model_copy(
318
+ update={
319
+ "review": "github",
320
+ "remote": remote,
321
+ "base": pr.base,
322
+ "number": pr.number,
323
+ "url": pr.url,
324
+ }
325
+ )
326
+ out.parent.mkdir(parents=True, exist_ok=True)
327
+ out.write_text(
328
+ json.dumps(record.model_dump(mode="json"), indent=2, sort_keys=True) + "\n",
329
+ encoding="utf-8",
330
+ )
331
+ return record
332
+
333
+
334
+ def _scrub(text: str, secret: str | None) -> str:
335
+ return redact_secret(text, secret)
336
+
337
+
338
+ class OpenPrTool:
339
+ """Builds, pushes and opens a block's pull request (see the module docstring)."""
340
+
341
+ name = "open_pr"
342
+ capability = "gen"
343
+
344
+ review_for: ClassVar[Callable[[AppContext], GitHubReview | None]] = staticmethod(
345
+ lambda app: app.pr_review
346
+ )
347
+ """How the tool gets the GitHub adapter of a project (tests swap in a fake API)."""
348
+
349
+ async def run(self, spec: CheckSpec, ctx: ToolContext) -> CheckResult:
350
+ start = time.monotonic()
351
+ block = ctx.params.get("block")
352
+ review_t = spec.args.get("review", DEFAULT_REVIEW)
353
+ out_t = spec.args.get("out", DEFAULT_OUT)
354
+ token = os.environ.get(TOKEN_ENV, "").strip() or None
355
+ if not block or not isinstance(review_t, str) or not isinstance(out_t, str):
356
+ return self._result(spec, "error", "open_pr needs a block and string templates", start)
357
+ try:
358
+ app = AppContext.load(ctx.repo_root)
359
+ record = open_block_pr(
360
+ app,
361
+ block,
362
+ review_path=review_t,
363
+ out_path=out_t,
364
+ review=type(self).review_for(app),
365
+ token=token,
366
+ )
367
+ except (OpenPrError, AppError, VcsError, ReviewError, OSError) as exc:
368
+ return self._result(spec, "error", _scrub(str(exc), token), start)
369
+ where = record.url or f"branch {record.branch} (gate {record.gate}: chipgraph approve)"
370
+ return self._result(spec, "pass", f"{record.head_sha[:12]} -> {where}", start)
371
+
372
+ @staticmethod
373
+ def _result(spec: CheckSpec, status: str, message: str, start: float) -> CheckResult:
374
+ severity = "info" if status == "pass" else "error"
375
+ return CheckResult(
376
+ check_id=spec.id,
377
+ status=status, # type: ignore[arg-type]
378
+ issues=(Issue(rule="open_pr", severity=severity, msg=message),), # type: ignore[arg-type]
379
+ duration_s=time.monotonic() - start,
380
+ idempotency_key=compute_idempotency_key(spec.id, spec.args, ()),
381
+ )
382
+
383
+
384
+ __all__ = [
385
+ "BRANCH_PREFIX",
386
+ "DEFAULT_OUT",
387
+ "DEFAULT_REVIEW",
388
+ "OpenPrError",
389
+ "OpenPrTool",
390
+ "PrRecord",
391
+ "build_branch",
392
+ "check_branch",
393
+ "check_token_host",
394
+ "needs_token",
395
+ "open_block_pr",
396
+ "read_record",
397
+ ]
@@ -0,0 +1,9 @@
1
+ """The RTL of one planned module (M2-04): what an `rtl-author` task is given.
2
+
3
+ - `context`: the `rtl` section of an `rtl-author` task's context: the module's plan entry,
4
+ its requirements, interface and dependencies, the naming rules as text and the style
5
+ exemplars.
6
+
7
+ The rule is `rules/rtl_module.yml`; its checks `format` and `style` are
8
+ `chipgraph.checks.format` and `chipgraph.checks.style`.
9
+ """