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,901 @@
1
+ """`PlanCheck`: a block's plan is valid before a person approves it (DESIGN.md 5.1, 5.3).
2
+
3
+ A built-in check, deterministic and fast: a pure function (`validate_plan`) over the
4
+ plan file (`chipgraph.packs.digital_rtl.plan.Plan`), the block's slice of the Design
5
+ Model, the profile and the build graph's other rules (`PlanEnv`). The Planner gets its
6
+ issues as the redo text of the normal agent loop. It rejects:
7
+
8
+ - write sets: two modules writing the same file (F2), a write outside the block's
9
+ `rtl` and `tb` layout paths (`layout.rtl`, `layout.tb`, per block under
10
+ `blocks.<b>.layout`; a kind with no layout key allows its default directory, `rtl/`
11
+ or `dv/<block>/`), a write to a path another rule produces (a file
12
+ a `gen`/`import`/`human` rule writes is the engine's or a person's, never a plan's),
13
+ an existing file the plan does not say it replaces (`replaces: true`), and a module
14
+ whose write set misses a file a `foreach: plan.modules` rule will write for it;
15
+ - dependencies: an unknown module, a module depending on itself, a cycle;
16
+ - requirements: an id that is not one of the block's requirements in the model, and a
17
+ block requirement neither assigned to a module nor listed in `unassigned_reqs`;
18
+ - interfaces (F1): a port that is not on the block's interface in the model, or whose
19
+ direction or width differs from it; `internal: true` helper ports only off the top;
20
+ - limits (profile `plan:`): modules, dependency depth, total tries;
21
+ - module names against the project's naming rule, when one is configured.
22
+
23
+ `open_questions` are a **warning**: they do not block, but the approver must read
24
+ them (`chipgraph plan show`).
25
+
26
+ An existing file counts as the plan's own once a chipgraph build of this block's plan
27
+ nodes wrote it (its hash is a production record's): re-planning does not have to mark
28
+ every file it wrote last time `replaces: true`.
29
+
30
+ Example profile usage (`.chipgraph.yml`):
31
+
32
+ ```yaml
33
+ adapters:
34
+ plan_check: { use: plan_check } # optional arg: plan: "plan/{block}.plan.yml"
35
+ plan: { max_modules: 12, max_depth: 4, max_total_tries: 36 }
36
+ ```
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import re
42
+ import time
43
+ from collections.abc import Iterable, Mapping, Sequence
44
+ from dataclasses import dataclass, field
45
+ from pathlib import Path
46
+ from typing import Any
47
+
48
+ import yaml
49
+ from pydantic import ValidationError
50
+
51
+ from chipgraph.checks._common import (
52
+ compile_template,
53
+ compute_idempotency_key,
54
+ error_result,
55
+ sha256_bytes,
56
+ sha256_file,
57
+ )
58
+ from chipgraph.checks._naming_pyslang import Decl
59
+ from chipgraph.checks.naming import (
60
+ _check_decl,
61
+ _CompiledRules,
62
+ _load_rules,
63
+ _resolve_rules_path,
64
+ _RulesError,
65
+ )
66
+ from chipgraph.core.config.loader import ResolvedProfile
67
+ from chipgraph.core.config.loader import load as load_profile
68
+ from chipgraph.core.config.models import PlanCfg, Profile
69
+ from chipgraph.core.contracts import CheckResult, CheckSpec, Issue, RuleSpec
70
+ from chipgraph.core.contracts.types import CheckStatus
71
+ from chipgraph.core.engine.graph import ForeachEntry, GraphError, build_graph
72
+ from chipgraph.core.engine.records import RecordStore
73
+ from chipgraph.core.model.model import DesignModel
74
+ from chipgraph.core.model.store import ModelStore, default_model_db_path
75
+ from chipgraph.core.plugin_api.types import ToolContext
76
+ from chipgraph.core.state.layout import StateLayout
77
+ from chipgraph.packs.digital_rtl.layout import LayoutError, fill_vars, layout_vars, write_globs
78
+ from chipgraph.packs.digital_rtl.plan.model import Plan, PlanModule
79
+
80
+ DEFAULT_PLAN_PATH = "plan/{block}.plan.yml"
81
+ """Where a block's plan lives (the output of rule `digital-rtl/plan`)."""
82
+
83
+ PLAN_MODULES = "plan.modules"
84
+ """The `foreach` selector that expands to one instance per module of an approved plan."""
85
+
86
+ _PLACEHOLDER_RE = re.compile(r"\{([^{}]+)\}")
87
+
88
+
89
+ class PlanEnvError(Exception):
90
+ """There is nothing to check a plan against: no profile, or no Design Model."""
91
+
92
+
93
+ # --- the environment a plan is checked against ----------------------------------------
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class ReqInfo:
98
+ """One requirement of the Design Model."""
99
+
100
+ key: str
101
+ name: str
102
+ text: str
103
+ block: str | None
104
+ """The owning block's name, if the model says."""
105
+ declared: bool = True
106
+ """The spec declares its id; an inferred one (D37) has only its model key."""
107
+
108
+ @property
109
+ def ref(self) -> str:
110
+ """How a plan names it: the declared REQ id, else the inferred key (`timer.h1a2b`)."""
111
+ return self.name if self.declared else self.key.removeprefix("requirement:")
112
+
113
+ @property
114
+ def refs(self) -> frozenset[str]:
115
+ """The strings a plan may use for it: its id, its key, the key without the kind."""
116
+ refs = {self.key, self.key.removeprefix("requirement:")}
117
+ if self.declared:
118
+ refs.add(self.name)
119
+ return frozenset(refs)
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class PortInfo:
124
+ """One port: name, direction and width as the Design Model knows them."""
125
+
126
+ name: str
127
+ direction: str | None
128
+ width: int | str | None
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class ModuleInfo:
133
+ """An existing module of the block in the Design Model."""
134
+
135
+ name: str
136
+ file: str | None
137
+ ports: tuple[PortInfo, ...]
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class PlanEnv:
142
+ """Everything `validate_plan` checks a plan against, for one block."""
143
+
144
+ block: str
145
+ requirements: tuple[ReqInfo, ...] = ()
146
+ """The block's requirements."""
147
+ other_requirements: tuple[ReqInfo, ...] = ()
148
+ """Every other requirement of the model (so a wrong block is named as such)."""
149
+ interface: tuple[PortInfo, ...] = ()
150
+ """The block's interface ports, from the spec (else the top RTL module)."""
151
+ interface_source: str = "none"
152
+ """Where `interface` came from: `spec`, `rtl:<module>` or `none`."""
153
+ existing_modules: tuple[ModuleInfo, ...] = ()
154
+ layout: tuple[str, ...] = ()
155
+ """Where the block's modules may write: its `rtl` and `tb` layout templates,
156
+ `{block}`/`{BLOCK}` filled in (`chipgraph.packs.digital_rtl.layout.write_globs`)."""
157
+ engine_outputs: Mapping[str, str] = field(default_factory=dict)
158
+ """Path -> instance id, for every output of a non-agent rule."""
159
+ agent_outputs: Mapping[str, str] = field(default_factory=dict)
160
+ """Path -> instance id, for every output of an agent rule not expanded from a plan."""
161
+ module_outputs: tuple[tuple[str, str], ...] = ()
162
+ """(rule id, output template) of every rule with `foreach: plan.modules`, the
163
+ block's layout directories (`{layout.rtl}`, `{layout.tb}`) filled in."""
164
+ existing: Mapping[str, str] = field(default_factory=dict)
165
+ """Path -> content hash, for the plan's write paths that exist on disk."""
166
+ built: Mapping[str, frozenset[str]] = field(default_factory=dict)
167
+ """Path -> hashes a build of this block's plan nodes recorded for it."""
168
+ limits: PlanCfg = field(default_factory=PlanCfg)
169
+ naming: _CompiledRules | None = None
170
+ naming_ref: str | None = None
171
+
172
+
173
+ # --- validation (pure) ----------------------------------------------------------------
174
+
175
+
176
+ def _source_lines(source: str | None) -> tuple[dict[str, int], dict[int, int]]:
177
+ """Line numbers of the plan's top-level keys and of each `modules[i]` item."""
178
+ if not source:
179
+ return {}, {}
180
+ try:
181
+ node = yaml.compose(source)
182
+ except yaml.YAMLError:
183
+ return {}, {}
184
+ keys: dict[str, int] = {}
185
+ modules: dict[int, int] = {}
186
+ if not isinstance(node, yaml.MappingNode):
187
+ return keys, modules
188
+ for key_node, value_node in node.value:
189
+ name = str(key_node.value)
190
+ keys[name] = key_node.start_mark.line + 1
191
+ if name == "modules" and isinstance(value_node, yaml.SequenceNode):
192
+ for index, item in enumerate(value_node.value):
193
+ modules[index] = item.start_mark.line + 1
194
+ return keys, modules
195
+
196
+
197
+ def _fill(template: str, params: Mapping[str, str]) -> str | None:
198
+ """`template` with `{key}` from `params` (`{KEY}` upper-cased); `None` if one is unknown."""
199
+ missing = False
200
+
201
+ def _replace(match: re.Match[str]) -> str:
202
+ nonlocal missing
203
+ key = match.group(1)
204
+ if key in params:
205
+ return params[key]
206
+ if key.isupper() and key.lower() in params:
207
+ return params[key.lower()].upper()
208
+ missing = True
209
+ return match.group(0)
210
+
211
+ filled = _PLACEHOLDER_RE.sub(_replace, template)
212
+ return None if missing else filled
213
+
214
+
215
+ def dependency_depth(plan: Plan) -> int:
216
+ """The longest chain of dependencies, counted in modules (no cycle assumed)."""
217
+ deps = {m.name: [d for d in m.depends_on if d != m.name] for m in plan.modules}
218
+ memo: dict[str, int] = {}
219
+
220
+ def depth(name: str, seen: frozenset[str]) -> int:
221
+ if name in memo:
222
+ return memo[name]
223
+ children = [d for d in deps.get(name, []) if d in deps and d not in seen]
224
+ value = 1 + max((depth(d, seen | {d}) for d in children), default=0)
225
+ memo[name] = value
226
+ return value
227
+
228
+ return max((depth(m.name, frozenset({m.name})) for m in plan.modules), default=0)
229
+
230
+
231
+ def _find_cycle(plan: Plan) -> list[str] | None:
232
+ """One dependency cycle among known modules (as a path ending where it starts), or None."""
233
+ deps = {m.name: [d for d in m.depends_on if d != m.name] for m in plan.modules}
234
+ white, gray, black = 0, 1, 2
235
+ color = dict.fromkeys(deps, white)
236
+ stack: list[str] = []
237
+
238
+ def visit(name: str) -> list[str] | None:
239
+ color[name] = gray
240
+ stack.append(name)
241
+ for dep in deps[name]:
242
+ if dep not in deps:
243
+ continue
244
+ if color[dep] == gray:
245
+ return [*stack[stack.index(dep) :], dep]
246
+ if color[dep] == white:
247
+ found = visit(dep)
248
+ if found is not None:
249
+ return found
250
+ stack.pop()
251
+ color[name] = black
252
+ return None
253
+
254
+ for name in deps:
255
+ if color[name] == white:
256
+ found = visit(name)
257
+ if found is not None:
258
+ return found
259
+ return None
260
+
261
+
262
+ def topo_order(plan: Plan) -> list[str]:
263
+ """The modules in build order: dependencies first, ties by plan order (no cycle)."""
264
+ order: list[str] = []
265
+ done: set[str] = set()
266
+ pending = [m.name for m in plan.modules]
267
+ known = set(pending)
268
+ while pending:
269
+ progressed = False
270
+ for name in list(pending):
271
+ module = plan.module(name)
272
+ assert module is not None
273
+ if all(d in done or d not in known or d == name for d in module.depends_on):
274
+ order.append(name)
275
+ done.add(name)
276
+ pending.remove(name)
277
+ progressed = True
278
+ if not progressed: # a cycle: keep plan order for the rest
279
+ order.extend(pending)
280
+ break
281
+ return order
282
+
283
+
284
+ def validate_plan(
285
+ plan: Plan,
286
+ env: PlanEnv,
287
+ *,
288
+ plan_path: str,
289
+ source: str | None = None,
290
+ check_existing: bool = True,
291
+ ) -> list[Issue]:
292
+ """Every problem with `plan` for `env.block`, as `Issue`s on `plan_path`, sorted by line.
293
+
294
+ Pure: reads nothing but its arguments. `source` (the file's text) only gives the
295
+ issues line numbers. With `check_existing=False` the existing-file rule is skipped:
296
+ once a person approved the plan, the files it writes exist by design.
297
+ """
298
+ key_lines, module_lines = _source_lines(source)
299
+ issues: list[Issue] = []
300
+
301
+ def add(rule: str, msg: str, *, line: int | None = None, severity: str = "error") -> None:
302
+ issues.append(
303
+ Issue(file=plan_path, line=line, rule=rule, severity=severity, msg=msg) # type: ignore[arg-type]
304
+ )
305
+
306
+ index_of = {id(m): i for i, m in enumerate(plan.modules)}
307
+
308
+ def mline(module: PlanModule) -> int | None:
309
+ return module_lines.get(index_of[id(module)])
310
+
311
+ # --- structure
312
+ if plan.block != env.block:
313
+ add(
314
+ "plan.block",
315
+ f"the plan is for block {plan.block!r}, but this is block {env.block!r}",
316
+ line=key_lines.get("block"),
317
+ )
318
+ names: dict[str, PlanModule] = {}
319
+ for module in plan.modules:
320
+ if module.name in names:
321
+ add(
322
+ "plan.duplicate_module",
323
+ f"module {module.name!r} is listed twice",
324
+ line=mline(module),
325
+ )
326
+ else:
327
+ names[module.name] = module
328
+ top = names.get(plan.top)
329
+ if top is None:
330
+ add(
331
+ "plan.top",
332
+ f"top {plan.top!r} is not one of the plan's modules: {', '.join(names)}",
333
+ line=key_lines.get("top"),
334
+ )
335
+
336
+ # --- limits
337
+ limits = env.limits
338
+ if len(plan.modules) > limits.max_modules:
339
+ add(
340
+ "plan.limit.modules",
341
+ f"the plan has {len(plan.modules)} modules; the limit is {limits.max_modules} "
342
+ "(profile plan.max_modules)",
343
+ line=key_lines.get("modules"),
344
+ )
345
+ total_tries = sum(m.budget.tries for m in plan.modules)
346
+ if total_tries > limits.max_total_tries:
347
+ add(
348
+ "plan.limit.tries",
349
+ f"the modules' budgets add up to {total_tries} tries; the limit is "
350
+ f"{limits.max_total_tries} (profile plan.max_total_tries)",
351
+ line=key_lines.get("modules"),
352
+ )
353
+
354
+ # --- dependencies
355
+ for module in plan.modules:
356
+ for dep in module.depends_on:
357
+ if dep == module.name:
358
+ add(
359
+ "plan.self_dependency",
360
+ f"module {module.name!r} depends on itself",
361
+ line=mline(module),
362
+ )
363
+ elif dep not in names:
364
+ add(
365
+ "plan.unknown_dependency",
366
+ f"module {module.name!r} depends on {dep!r}, which is not a module of "
367
+ f"this plan ({', '.join(names)})",
368
+ line=mline(module),
369
+ )
370
+ cycle = _find_cycle(plan)
371
+ if cycle is not None:
372
+ add(
373
+ "plan.dependency_cycle",
374
+ f"dependency cycle: {' -> '.join(cycle)}",
375
+ line=key_lines.get("modules"),
376
+ )
377
+ else:
378
+ depth = dependency_depth(plan)
379
+ if depth > limits.max_depth:
380
+ add(
381
+ "plan.limit.depth",
382
+ f"the longest dependency chain is {depth} modules; the limit is "
383
+ f"{limits.max_depth} (profile plan.max_depth)",
384
+ line=key_lines.get("modules"),
385
+ )
386
+
387
+ # --- requirements
388
+ block_reqs = {ref: req for req in env.requirements for ref in req.refs}
389
+ other_reqs = {ref: req for req in env.other_requirements for ref in req.refs}
390
+
391
+ def known_req(ref: str, where: str, line: int | None) -> ReqInfo | None:
392
+ req = block_reqs.get(ref)
393
+ if req is not None:
394
+ return req
395
+ other = other_reqs.get(ref)
396
+ if other is not None:
397
+ add(
398
+ "plan.unknown_req",
399
+ f"{where}: {ref!r} is a requirement of block {other.block!r}, not of {env.block!r}",
400
+ line=line,
401
+ )
402
+ else:
403
+ add(
404
+ "plan.unknown_req",
405
+ f"{where}: {ref!r} is not a requirement in the Design Model; do not invent "
406
+ "requirements",
407
+ line=line,
408
+ )
409
+ return None
410
+
411
+ covered: set[str] = set()
412
+ for module in plan.modules:
413
+ for ref in module.reqs:
414
+ req = known_req(ref, f"module {module.name!r}", mline(module))
415
+ if req is not None:
416
+ covered.add(req.key)
417
+ for item in plan.unassigned_reqs:
418
+ req = known_req(item.req, "unassigned_reqs", key_lines.get("unassigned_reqs"))
419
+ if req is not None:
420
+ covered.add(req.key)
421
+ for req in env.requirements:
422
+ if req.key not in covered:
423
+ add(
424
+ "plan.unassigned_req",
425
+ f"requirement {req.ref} is neither assigned to a module nor listed in "
426
+ "unassigned_reqs with a reason",
427
+ line=key_lines.get("unassigned_reqs") or key_lines.get("modules"),
428
+ )
429
+
430
+ # --- interfaces
431
+ interface = {p.name: p for p in env.interface}
432
+ for module in plan.modules:
433
+ seen_ports: set[str] = set()
434
+ for port in module.interface.ports:
435
+ where = f"module {module.name!r} port {port.name!r}"
436
+ if port.name in seen_ports:
437
+ add("plan.duplicate_port", f"{where} is listed twice", line=mline(module))
438
+ continue
439
+ seen_ports.add(port.name)
440
+ if port.internal:
441
+ if module.name == plan.top:
442
+ add(
443
+ "plan.internal_port_on_top",
444
+ f"{where} is marked internal, but {module.name!r} is the block's "
445
+ "top: its ports are the block's interface",
446
+ line=mline(module),
447
+ )
448
+ continue
449
+ known = interface.get(port.name)
450
+ if known is None:
451
+ listed = ", ".join(interface) or "none (run `chipgraph ingest`)"
452
+ add(
453
+ "plan.unknown_port",
454
+ f"{where} is not on block {env.block!r}'s interface in the Design Model "
455
+ f"({listed}); take ports from the model, or mark a helper port between "
456
+ "modules `internal: true`",
457
+ line=mline(module),
458
+ )
459
+ continue
460
+ if known.direction is not None and known.direction != port.direction:
461
+ add(
462
+ "plan.port_mismatch",
463
+ f"{where} is {port.direction}, but the Design Model has {known.direction}",
464
+ line=mline(module),
465
+ )
466
+ if known.width is not None and str(known.width) != str(port.width):
467
+ add(
468
+ "plan.port_mismatch",
469
+ f"{where} has width {port.width}, but the Design Model has {known.width}",
470
+ line=mline(module),
471
+ )
472
+
473
+ # --- write sets
474
+ layout = [compile_template(t) for t in env.layout]
475
+ writers: dict[str, str] = {}
476
+ for module in plan.modules:
477
+ for write in module.writes:
478
+ path = write.path
479
+ where = f"module {module.name!r} writes {path}"
480
+ other = writers.get(path)
481
+ if other is not None and other != module.name:
482
+ add(
483
+ "plan.write_overlap",
484
+ f"modules {other!r} and {module.name!r} both write {path}; the write "
485
+ "sets of modules must not overlap (F2)",
486
+ line=mline(module),
487
+ )
488
+ writers.setdefault(path, module.name)
489
+ if not any(t.match(path) is not None for t in layout):
490
+ listed = ", ".join(env.layout) or "none"
491
+ add(
492
+ "plan.write_layout",
493
+ f"{where}, outside block {env.block!r}'s layout paths ({listed})",
494
+ line=mline(module),
495
+ )
496
+ if path in env.engine_outputs:
497
+ add(
498
+ "plan.write_engine",
499
+ f"{where}, which rule instance {env.engine_outputs[path]!r} produces; "
500
+ "a file a gen, import or human rule writes is never a plan's (F2)",
501
+ line=mline(module),
502
+ )
503
+ elif path in env.agent_outputs:
504
+ add(
505
+ "plan.write_taken",
506
+ f"{where}, which rule instance {env.agent_outputs[path]!r} produces",
507
+ line=mline(module),
508
+ )
509
+ elif (
510
+ check_existing
511
+ and path in env.existing
512
+ and not write.replaces
513
+ and env.existing[path] not in env.built.get(path, frozenset())
514
+ ):
515
+ add(
516
+ "plan.write_existing",
517
+ f"{where}, which already exists; set `replaces: true` on that write "
518
+ "to replace it on purpose",
519
+ line=mline(module),
520
+ )
521
+ for rule_id, template in env.module_outputs:
522
+ path_or_none = _fill(template, {"block": env.block, "module": module.name})
523
+ if path_or_none is not None and path_or_none not in module.write_paths:
524
+ add(
525
+ "plan.write_missing",
526
+ f"module {module.name!r}: rule {rule_id!r} writes {path_or_none} for it, "
527
+ "which is not in its writes",
528
+ line=mline(module),
529
+ )
530
+
531
+ # --- naming
532
+ if env.naming is not None:
533
+ for module in plan.modules:
534
+ line = mline(module) or 1
535
+ for issue in _check_decl(plan_path, Decl("module", module.name, line), env.naming):
536
+ add("plan.naming", f"module name {issue.msg} ({issue.rule})", line=line)
537
+
538
+ # --- what the approver must read
539
+ for question in plan.open_questions:
540
+ add(
541
+ "plan.open_questions",
542
+ f"open question for the approver: {question}",
543
+ line=key_lines.get("open_questions"),
544
+ severity="warning",
545
+ )
546
+
547
+ issues.sort(key=lambda i: (i.line or 0, i.rule, i.msg))
548
+ return issues
549
+
550
+
551
+ # --- reading a plan file --------------------------------------------------------------
552
+
553
+
554
+ def parse_plan(text: str, *, plan_path: str) -> tuple[Plan | None, list[Issue]]:
555
+ """Parse a plan file's text: the plan, or `None` and the schema issues."""
556
+ try:
557
+ data = yaml.safe_load(text)
558
+ except yaml.YAMLError as exc:
559
+ return None, [Issue(file=plan_path, rule="plan.schema", msg=f"invalid YAML: {exc}")]
560
+ try:
561
+ return Plan.model_validate(data), []
562
+ except ValidationError as exc:
563
+ issues = [
564
+ Issue(
565
+ file=plan_path,
566
+ rule="plan.schema",
567
+ msg=f"{'.'.join(str(p) for p in err['loc']) or '<file>'}: {err['msg']}",
568
+ )
569
+ for err in exc.errors()
570
+ ]
571
+ return None, issues
572
+
573
+
574
+ # --- the environment, from a project --------------------------------------------------
575
+
576
+
577
+ _PER_BLOCK = ("blocks", "plan.block", "plan.blocks")
578
+ """Selectors with one entry per block (at most): every block, for the outputs they take."""
579
+
580
+
581
+ class _BlocksOnly:
582
+ """Expands the per-block selectors to every block (with its layout directories as
583
+ template values); any other selector (a plan's modules) to nothing."""
584
+
585
+ def __init__(self, blocks: Iterable[str], values: Mapping[str, Mapping[str, str]]) -> None:
586
+ self._blocks = sorted(blocks)
587
+ self._values = values
588
+
589
+ def expand(self, expr: str) -> list[dict[str, str]]:
590
+ return [dict(e.params) for e in self.expand_entries(expr)]
591
+
592
+ def expand_entries(self, expr: str) -> list[ForeachEntry]:
593
+ if expr not in _PER_BLOCK:
594
+ return []
595
+ return [
596
+ ForeachEntry(params={"block": b}, vars=dict(self._values.get(b, {})))
597
+ for b in self._blocks
598
+ ]
599
+
600
+
601
+ def _block_vars(resolved: ResolvedProfile) -> dict[str, dict[str, str]]:
602
+ """Each block's layout directories; a block whose layout is malformed gets none."""
603
+ out: dict[str, dict[str, str]] = {}
604
+ for block in resolved.profile.blocks:
605
+ try:
606
+ out[block] = layout_vars(resolved, block)
607
+ except LayoutError:
608
+ continue
609
+ return out
610
+
611
+
612
+ def project_rules(root: Path, resolved: ResolvedProfile) -> list[RuleSpec]:
613
+ """The rules of the profile's packs (the same as `chipgraph build` loads)."""
614
+ from chipgraph.app.build import rules_for_packs # lazy: app imports this module's users
615
+
616
+ return rules_for_packs(root, resolved.profile.packs)
617
+
618
+
619
+ def _other_outputs(
620
+ rules: Sequence[RuleSpec], resolved: ResolvedProfile
621
+ ) -> tuple[dict[str, str], dict[str, str]]:
622
+ """Outputs of the graph's rules that are not expanded from a plan: non-agent, agent."""
623
+ fixed = [r for r in rules if r.foreach != PLAN_MODULES]
624
+ try:
625
+ graph = build_graph(fixed, _BlocksOnly(resolved.profile.blocks, _block_vars(resolved)))
626
+ except GraphError:
627
+ return {}, {}
628
+ engine: dict[str, str] = {}
629
+ agent: dict[str, str] = {}
630
+ for iid, instance in graph.instances.items():
631
+ target = agent if graph.rules[instance.rule_id].kind == "agent" else engine
632
+ for ref in instance.outputs:
633
+ if ref.path is not None:
634
+ target[ref.path] = iid
635
+ return engine, agent
636
+
637
+
638
+ def _instance_params(instance_id: str) -> tuple[str, dict[str, str]]:
639
+ rule_id, _, rest = instance_id.partition("[")
640
+ params: dict[str, str] = {}
641
+ for pair in rest.removesuffix("]").split(","):
642
+ key, sep, value = pair.partition("=")
643
+ if sep:
644
+ params[key] = value
645
+ return rule_id, params
646
+
647
+
648
+ def _built(root: Path, rules: Sequence[RuleSpec], block: str) -> dict[str, frozenset[str]]:
649
+ """Path -> hashes recorded when this block's plan nodes last produced it."""
650
+ plan_rules = {r.id for r in rules if r.foreach == PLAN_MODULES}
651
+ found: dict[str, set[str]] = {}
652
+ for iid, record in RecordStore(StateLayout(root)).all().items():
653
+ rule_id, params = _instance_params(iid)
654
+ if rule_id not in plan_rules or params.get("block") != block:
655
+ continue
656
+ for key, digest in record.output_hashes.items():
657
+ found.setdefault(key.partition(":")[2], set()).add(digest)
658
+ return {path: frozenset(hashes) for path, hashes in found.items()}
659
+
660
+
661
+ def _req(entity: Any) -> ReqInfo:
662
+ block = entity.attrs.get("block")
663
+ return ReqInfo(
664
+ key=entity.key,
665
+ name=str(entity.name),
666
+ text=str(getattr(entity, "text", None) or ""),
667
+ block=block.removeprefix("block:") if isinstance(block, str) else None,
668
+ declared=entity.attrs.get("id_source", "declared") == "declared",
669
+ )
670
+
671
+
672
+ def _port(entity: Any) -> PortInfo:
673
+ return PortInfo(name=str(entity.name), direction=entity.direction, width=entity.width)
674
+
675
+
676
+ def _interface(
677
+ model: DesignModel, block: str, profile: Profile, modules: Sequence[ModuleInfo]
678
+ ) -> tuple[tuple[PortInfo, ...], str]:
679
+ """The block's interface: its spec ports, else its top RTL module's (ports_diff `top`)."""
680
+ key = f"block:{block}"
681
+ spec = [
682
+ _port(p)
683
+ for p in model.by_kind("port")
684
+ if p.attrs.get("block") == key and p.attrs.get("origin") == "spec"
685
+ ]
686
+ if spec:
687
+ return tuple(spec), "spec"
688
+ top: str | None = None
689
+ cfg = profile.adapters.get("ports_diff")
690
+ raw_top = cfg.model_dump().get("top") if cfg is not None else None
691
+ if isinstance(raw_top, str):
692
+ top = _fill(raw_top, {"block": block})
693
+ if top is None and len(modules) == 1:
694
+ top = modules[0].name
695
+ for module in modules:
696
+ if module.name == top:
697
+ return module.ports, f"rtl:{module.name}"
698
+ return (), "none"
699
+
700
+
701
+ def _naming(profile: Profile, root: Path) -> tuple[_CompiledRules | None, str | None]:
702
+ ref = profile.naming.rules
703
+ if ref is None:
704
+ cfg = profile.adapters.get("naming")
705
+ dumped = cfg.model_dump() if cfg is not None else {}
706
+ if dumped.get("use") == "naming" and isinstance(dumped.get("rules"), str):
707
+ ref = dumped["rules"]
708
+ if ref is None:
709
+ return None, None
710
+ try:
711
+ rules, _ = _load_rules(_resolve_rules_path(ref, root))
712
+ except _RulesError as exc:
713
+ raise PlanEnvError(f"naming rules {ref!r}: {exc}") from exc
714
+ return _CompiledRules(rules), ref
715
+
716
+
717
+ def load_env(
718
+ root: Path,
719
+ block: str,
720
+ *,
721
+ resolved: ResolvedProfile | None = None,
722
+ rules: Sequence[RuleSpec] | None = None,
723
+ write_paths: Iterable[str] = (),
724
+ ) -> PlanEnv:
725
+ """The `PlanEnv` of `block` in the project at `root`.
726
+
727
+ `resolved` and `rules` default to the project's profile and its packs' rules.
728
+ `write_paths` are the plan's writes, whose current hashes are looked up. Raises
729
+ `PlanEnvError` when there is no profile or no Design Model (run `chipgraph ingest`).
730
+ """
731
+ if resolved is None:
732
+ resolved = load_profile(root)
733
+ if resolved is None:
734
+ raise PlanEnvError(f"no .chipgraph.yml found from {root}")
735
+ profile = resolved.for_block(block)
736
+ db = default_model_db_path(root)
737
+ if not db.is_file():
738
+ raise PlanEnvError(f"no Design Model at {db}; run `chipgraph ingest` first")
739
+ model = ModelStore(db).read()
740
+ if rules is None:
741
+ rules = project_rules(root, resolved)
742
+
743
+ key = f"block:{block}"
744
+ reqs = [_req(e) for e in model.by_kind("requirement")]
745
+ modules = tuple(
746
+ ModuleInfo(
747
+ name=str(m.name),
748
+ file=getattr(m, "file", None),
749
+ ports=tuple(_port(p) for p in model.by_kind("port") if p.module == m.key), # type: ignore[attr-defined]
750
+ )
751
+ for m in model.by_kind("module")
752
+ if getattr(m, "block", None) == key
753
+ )
754
+ interface, source = _interface(model, block, profile, modules)
755
+ try:
756
+ values = layout_vars(resolved, block)
757
+ except LayoutError as exc:
758
+ raise PlanEnvError(str(exc)) from exc
759
+ layout = write_globs(resolved, block)
760
+ engine, agent = _other_outputs(rules, resolved)
761
+ existing = {
762
+ path: sha256_file(root / path)
763
+ for path in dict.fromkeys(write_paths)
764
+ if (root / path).is_file()
765
+ }
766
+ naming, naming_ref = _naming(profile, root)
767
+ return PlanEnv(
768
+ block=block,
769
+ requirements=tuple(r for r in reqs if r.block == block),
770
+ other_requirements=tuple(r for r in reqs if r.block != block),
771
+ interface=interface,
772
+ interface_source=source,
773
+ existing_modules=modules,
774
+ layout=tuple(dict.fromkeys(layout)),
775
+ engine_outputs=engine,
776
+ agent_outputs=agent,
777
+ module_outputs=tuple(
778
+ (r.id, fill_vars(t, values))
779
+ for r in rules
780
+ if r.foreach == PLAN_MODULES
781
+ for t in r.outputs
782
+ ),
783
+ existing=existing,
784
+ built=_built(root, rules, block),
785
+ limits=resolved.profile.plan,
786
+ naming=naming,
787
+ naming_ref=naming_ref,
788
+ )
789
+
790
+
791
+ @dataclass(frozen=True)
792
+ class PlanReport:
793
+ """A block's plan file, checked: the plan (when it parses) and every issue."""
794
+
795
+ block: str
796
+ path: str
797
+ exists: bool
798
+ plan: Plan | None
799
+ issues: tuple[Issue, ...]
800
+ sha256: str | None
801
+ env: PlanEnv | None
802
+
803
+ @property
804
+ def ok(self) -> bool:
805
+ """The plan exists, parses and has no error issue."""
806
+ return self.plan is not None and not any(i.severity == "error" for i in self.issues)
807
+
808
+
809
+ def check_plan_file(
810
+ root: Path,
811
+ block: str,
812
+ *,
813
+ plan_path: str | None = None,
814
+ resolved: ResolvedProfile | None = None,
815
+ rules: Sequence[RuleSpec] | None = None,
816
+ check_existing: bool = True,
817
+ ) -> PlanReport:
818
+ """Read, parse and validate `block`'s plan in the project at `root`.
819
+
820
+ Raises `PlanEnvError` when there is nothing to check it against (see `load_env`).
821
+ """
822
+ rel = _fill(plan_path or DEFAULT_PLAN_PATH, {"block": block}) or DEFAULT_PLAN_PATH
823
+ path = root / rel
824
+ if not path.is_file():
825
+ return PlanReport(block, rel, False, None, (), None, None)
826
+ raw = path.read_bytes()
827
+ text = raw.decode("utf-8", errors="replace")
828
+ plan, issues = parse_plan(text, plan_path=rel)
829
+ if plan is None:
830
+ return PlanReport(block, rel, True, None, tuple(issues), sha256_bytes(raw), None)
831
+ writes = [w.path for m in plan.modules for w in m.writes]
832
+ env = load_env(root, block, resolved=resolved, rules=rules, write_paths=writes)
833
+ issues = validate_plan(plan, env, plan_path=rel, source=text, check_existing=check_existing)
834
+ return PlanReport(block, rel, True, plan, tuple(issues), sha256_bytes(raw), env)
835
+
836
+
837
+ # --- the check plugin -----------------------------------------------------------------
838
+
839
+
840
+ class PlanCheck:
841
+ """Checks a block's plan file against the Design Model, the profile and the graph."""
842
+
843
+ id = "plan_check"
844
+ name = "Plan"
845
+
846
+ async def run(self, spec: CheckSpec, ctx: ToolContext) -> CheckResult:
847
+ start = time.monotonic()
848
+ block = ctx.params.get("block")
849
+ if not block:
850
+ return error_result(spec, "plan_check runs per block: no block param", start)
851
+ template = spec.args.get("plan", DEFAULT_PLAN_PATH)
852
+ if not isinstance(template, str):
853
+ return error_result(spec, "args['plan'] must be a path template string", start)
854
+ try:
855
+ report = check_plan_file(ctx.repo_root, block, plan_path=template)
856
+ except PlanEnvError as exc:
857
+ return error_result(spec, str(exc), start, rule="plan.env")
858
+
859
+ if not report.exists:
860
+ issues: tuple[Issue, ...] = (
861
+ Issue(
862
+ file=report.path,
863
+ rule="plan.missing",
864
+ severity="info",
865
+ msg=f"block {block!r} has no plan yet",
866
+ ),
867
+ )
868
+ else:
869
+ issues = report.issues
870
+ status: CheckStatus = "fail" if any(i.severity == "error" for i in issues) else "pass"
871
+ hashes = [(report.path, report.sha256 or "none")]
872
+ if report.env is not None:
873
+ hashes.extend(sorted(report.env.existing.items()))
874
+ key = compute_idempotency_key(spec.id, {**spec.args, "block": block}, hashes)
875
+ return CheckResult(
876
+ check_id=spec.id,
877
+ status=status,
878
+ issues=issues,
879
+ duration_s=time.monotonic() - start,
880
+ idempotency_key=key,
881
+ )
882
+
883
+
884
+ __all__ = [
885
+ "DEFAULT_PLAN_PATH",
886
+ "PLAN_MODULES",
887
+ "ModuleInfo",
888
+ "PlanCheck",
889
+ "PlanEnv",
890
+ "PlanEnvError",
891
+ "PlanReport",
892
+ "PortInfo",
893
+ "ReqInfo",
894
+ "check_plan_file",
895
+ "dependency_depth",
896
+ "load_env",
897
+ "parse_plan",
898
+ "project_rules",
899
+ "topo_order",
900
+ "validate_plan",
901
+ ]