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,224 @@
1
+ """`TraceCheck`: every requirement must be referenced by at least one test (layer 5).
2
+
3
+ A layer-5 (process) cross check (DESIGN.md 4.8, DECISIONS D37). It reads the Design Model
4
+ `chipgraph ingest` wrote and the test files under the `tests` globs, and reports:
5
+
6
+ * `req.no_test` -- a *declared* requirement whose ID appears in no test file.
7
+ * `test.unknown_req` -- a test mentions a string that matches the profile's requirement
8
+ `id_pattern` for some block but is not a requirement in the model (a stale or mistyped
9
+ REQ-ID).
10
+ * `req.inferred_untraceable` -- an `info` per block with requirements that have no
11
+ declared ID: *inferred* ones (D37) and Verification items *missing* their ID in a file
12
+ that declares IDs (`spec_schema` reports each of those as `requirement.missing_id`).
13
+ They have no stable ID to grep for, so instead of one failure each, one `info` says how
14
+ many a block has, of which kind, and that they cannot be traced by ID.
15
+
16
+ A missing model is a whole-check `error` (run `chipgraph ingest` first). With a `block`
17
+ param (`chipgraph check --block`), only that block's requirements and its inferred summary
18
+ are considered; `test.unknown_req` still uses every block's pattern, so a test naming
19
+ another block's stale ID is still caught.
20
+
21
+ Example profile usage (`.chipgraph.yml`):
22
+
23
+ ```yaml
24
+ adapters:
25
+ trace: { use: trace, tests: ["dv/**/*.py", "dv/**/*.sv"] }
26
+ ```
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import re
32
+ import time
33
+ from pathlib import Path
34
+
35
+ from chipgraph.checks._common import (
36
+ ArgError,
37
+ compute_idempotency_key,
38
+ error_result,
39
+ glob_to_regex,
40
+ iter_repo_files,
41
+ require_list_of_str,
42
+ sha256_file,
43
+ )
44
+ from chipgraph.checks._model import ModelUnavailable, block_param, load_model
45
+ from chipgraph.core.config.models import RequirementsCfg
46
+ from chipgraph.core.contracts import CheckResult, CheckSpec, Issue
47
+ from chipgraph.core.contracts.types import CheckStatus
48
+ from chipgraph.core.model.model import DesignModel
49
+ from chipgraph.core.plugin_api.types import ToolContext
50
+
51
+
52
+ class TraceCheck:
53
+ """Checks that every declared requirement is referenced by at least one test."""
54
+
55
+ id = "trace"
56
+ name = "Trace"
57
+
58
+ async def run(self, spec: CheckSpec, ctx: ToolContext) -> CheckResult:
59
+ start = time.monotonic()
60
+ args = spec.args
61
+
62
+ try:
63
+ test_globs = require_list_of_str(args, "tests")
64
+ if not test_globs:
65
+ raise ArgError("args['tests'] must list at least one glob")
66
+ id_pattern = args.get("id_pattern")
67
+ if id_pattern is not None and not isinstance(id_pattern, str):
68
+ raise ArgError("args['id_pattern'] must be a string")
69
+ except ArgError as exc:
70
+ return error_result(spec, str(exc), start)
71
+
72
+ try:
73
+ loaded = load_model(ctx.repo_root)
74
+ except ModelUnavailable as exc:
75
+ return error_result(spec, str(exc), start, rule="model")
76
+
77
+ block = block_param(ctx)
78
+ req_cfg = RequirementsCfg(id_pattern=id_pattern) if id_pattern else RequirementsCfg()
79
+
80
+ test_files = _match_files(ctx.repo_root, test_globs)
81
+ texts = {rel: (ctx.repo_root / rel).read_text(errors="ignore") for rel in test_files}
82
+
83
+ issues = _trace_issues(loaded.model, texts, req_cfg, block)
84
+ issues.sort(key=lambda i: (i.file or "", i.line or 0, i.rule, i.msg))
85
+
86
+ file_hashes = [(rel, sha256_file(ctx.repo_root / rel)) for rel in test_files]
87
+ file_hashes.append(("<model>", loaded.build_inputs_hash or "none"))
88
+ key = compute_idempotency_key(spec.id, args, file_hashes)
89
+ status: CheckStatus = "fail" if any(i.severity == "error" for i in issues) else "pass"
90
+ return CheckResult(
91
+ check_id=spec.id,
92
+ status=status,
93
+ issues=tuple(issues),
94
+ duration_s=time.monotonic() - start,
95
+ idempotency_key=key,
96
+ )
97
+
98
+
99
+ def _match_files(root: Path, globs: list[str]) -> list[str]:
100
+ regexes = [glob_to_regex(g) for g in globs]
101
+ return [f for f in iter_repo_files(root) if any(r.fullmatch(f) for r in regexes)]
102
+
103
+
104
+ def _trace_issues(
105
+ model: DesignModel,
106
+ texts: dict[str, str],
107
+ req_cfg: RequirementsCfg,
108
+ block: str | None,
109
+ ) -> list[Issue]:
110
+ issues: list[Issue] = []
111
+ block_key = f"block:{block}" if block else None
112
+
113
+ requirements = model.by_kind("requirement")
114
+ declared = [
115
+ r
116
+ for r in requirements
117
+ if r.attrs.get("id_source") == "declared"
118
+ and (block_key is None or r.attrs.get("block") == block_key)
119
+ ]
120
+ # No declared ID: inferred (D37) or missing in a file that declares IDs.
121
+ untraceable = [
122
+ r
123
+ for r in requirements
124
+ if r.attrs.get("id_source") in ("inferred", "missing")
125
+ and (block_key is None or r.attrs.get("block") == block_key)
126
+ ]
127
+
128
+ # req.no_test: a declared ID mentioned in no test (word-boundary search on its name).
129
+ for req in sorted(declared, key=lambda r: r.key):
130
+ pattern = re.compile(rf"(?<![\w-]){re.escape(req.name)}(?![\w-])")
131
+ if not any(pattern.search(text) for text in texts.values()):
132
+ issues.append(
133
+ Issue(
134
+ file=req.source.file,
135
+ line=req.source.line,
136
+ rule="req.no_test",
137
+ severity="error",
138
+ msg=f"requirement {req.name!r} is referenced by no test under `tests`",
139
+ )
140
+ )
141
+
142
+ # test.unknown_req: a test names a REQ-ID-shaped string that is no requirement.
143
+ known_names = {r.name for r in requirements}
144
+ id_regexes = _block_id_regexes(model, req_cfg)
145
+ for rel in sorted(texts):
146
+ for line_no, token in _req_like_tokens(texts[rel], id_regexes):
147
+ if token not in known_names:
148
+ issues.append(
149
+ Issue(
150
+ file=rel,
151
+ line=line_no,
152
+ rule="test.unknown_req",
153
+ severity="error",
154
+ msg=(
155
+ f"test names {token!r}, which matches a requirement id pattern "
156
+ "but is not a requirement in the model"
157
+ ),
158
+ )
159
+ )
160
+
161
+ # No declared ID: one info per block, not one failure per requirement (D37).
162
+ by_block: dict[str, dict[str, int]] = {}
163
+ for req in untraceable:
164
+ b = str(req.attrs.get("block") or "")
165
+ counts = by_block.setdefault(b, {"inferred": 0, "missing": 0})
166
+ counts[str(req.attrs.get("id_source"))] += 1
167
+ for b in sorted(by_block):
168
+ issues.append(
169
+ Issue(
170
+ rule="req.inferred_untraceable",
171
+ severity="info",
172
+ msg=_untraceable_msg(b or "(no block)", by_block[b]),
173
+ )
174
+ )
175
+
176
+ return issues
177
+
178
+
179
+ def _untraceable_msg(block: str, counts: dict[str, int]) -> str:
180
+ """The `req.inferred_untraceable` message for one block's requirements with no ID."""
181
+ inferred, missing = counts["inferred"], counts["missing"]
182
+ if not missing:
183
+ return (
184
+ f"{inferred} inferred requirement(s) in {block} cannot be traced by ID "
185
+ "(they are inferred, not declared REQ-IDs)"
186
+ )
187
+ parts = [f"{inferred} inferred"] if inferred else []
188
+ parts.append(f"{missing} Verification item(s) missing an ID (requirement.missing_id)")
189
+ return (
190
+ f"{inferred + missing} requirement(s) in {block} cannot be traced by ID: "
191
+ f"{', '.join(parts)}; none of them is a declared REQ-ID"
192
+ )
193
+
194
+
195
+ def _block_id_regexes(model: DesignModel, req_cfg: RequirementsCfg) -> list[re.Pattern[str]]:
196
+ """A requirement-id regex per block name in the model (plus a block-less default).
197
+
198
+ The `id_pattern` may use `{block}`/`{BLOCK}`; compiled once per block so a test naming
199
+ another block's stale ID is still recognised as REQ-ID-shaped.
200
+ """
201
+ uses_block = "{block}" in req_cfg.id_pattern or "{BLOCK}" in req_cfg.id_pattern
202
+ if not uses_block:
203
+ return [req_cfg.id_regex()]
204
+ regexes: list[re.Pattern[str]] = []
205
+ for blk in model.by_kind("block"):
206
+ regexes.append(req_cfg.id_regex(blk.name))
207
+ return regexes or [req_cfg.id_regex("x")]
208
+
209
+
210
+ _TOKEN = re.compile(r"[A-Za-z0-9_-]+")
211
+
212
+
213
+ def _req_like_tokens(text: str, id_regexes: list[re.Pattern[str]]) -> list[tuple[int, str]]:
214
+ """Every (line, token) in `text` whose token fully matches a requirement id regex."""
215
+ out: list[tuple[int, str]] = []
216
+ for line_no, line in enumerate(text.splitlines(), start=1):
217
+ for m in _TOKEN.finditer(line):
218
+ token = m.group(0)
219
+ if any(rx.fullmatch(token) for rx in id_regexes):
220
+ out.append((line_no, token))
221
+ return out
222
+
223
+
224
+ __all__ = ["TraceCheck"]
@@ -0,0 +1,369 @@
1
+ """`VendorGuardCheck` (`vendor_guard`): vendored IP is never edited in place.
2
+
3
+ Third-party IP is copied into the repository so that a tape-out has a frozen, auditable
4
+ source; that holds only while the files match what was recorded. The check reads a
5
+ manifest in `sha256sum` format (`<sha256> <path>` per line, paths relative to the
6
+ manifest's directory, `#` comments), so `shasum -a 256 -c MANIFEST.sha256` run in that
7
+ directory verifies the same thing, and fails when, under the manifest's directory:
8
+
9
+ - a listed file's content differs from its recorded hash (`vendor_guard.modified`);
10
+ - a listed file is gone, or is not a regular file (`vendor_guard.missing`);
11
+ - a file is not listed (`vendor_guard.unlisted`), except the manifest itself and the
12
+ `allow` globs (e.g. a `patches/**` directory for reviewed changes kept apart);
13
+ - a file or directory is a symbolic link, or a listed path resolves outside the
14
+ manifest's directory (`vendor_guard.symlink`): a link would let the guarded tree
15
+ point at files the manifest does not cover. The manifest itself, and every directory
16
+ on the way to it, must not be a link either.
17
+
18
+ It never passes on what it could not read: an unreadable file or directory is an
19
+ `error` (`vendor_guard.unreadable`). Only the `.git` directory directly under the
20
+ manifest's directory is skipped; one deeper is walked like any other directory. Two
21
+ manifest entries that differ only in case (one file on a case-insensitive file system)
22
+ are refused.
23
+
24
+ An IP update is then a deliberate change: new files and a new manifest in the same
25
+ commit, reviewed as such. A project change to how an IP is used goes in its wrapper.
26
+
27
+ Args (all optional): `manifest` (default `vendor/MANIFEST.sha256`; a repo-relative
28
+ path, no `..`, inside the repository), `allow` (globs, relative to the manifest's
29
+ directory). Chip-wide: it runs once, not per block.
30
+
31
+ Profile example (`.chipgraph.yml`)::
32
+
33
+ adapters:
34
+ vendor_guard: { use: vendor_guard }
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import hashlib
40
+ import os
41
+ import re
42
+ import stat
43
+ import time
44
+ from dataclasses import dataclass, field
45
+ from pathlib import Path
46
+
47
+ from chipgraph.checks._common import (
48
+ ArgError,
49
+ compute_idempotency_key,
50
+ error_result,
51
+ glob_to_regex,
52
+ optional_list_of_str,
53
+ optional_str,
54
+ )
55
+ from chipgraph.core.contracts import CheckResult, CheckSpec, Issue
56
+ from chipgraph.core.contracts.types import CheckStatus
57
+ from chipgraph.core.plugin_api.types import ToolContext
58
+
59
+ DEFAULT_MANIFEST = "vendor/MANIFEST.sha256"
60
+ _LINE_RE = re.compile(r"^([0-9a-fA-F]{64}) [ *](.+)$")
61
+ _OPEN_FLAGS = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
62
+ _CHUNK = 1 << 20
63
+
64
+
65
+ class ManifestError(Exception):
66
+ """The manifest cannot be read."""
67
+
68
+
69
+ def read_manifest(path: Path) -> dict[str, str]:
70
+ """The manifest's entries: path (relative to its directory) -> sha256.
71
+
72
+ Errors name the line number only, never quote the line."""
73
+ return parse_manifest(_read_regular(path).decode("utf-8"), path.name)
74
+
75
+
76
+ def parse_manifest(text: str, name: str) -> dict[str, str]:
77
+ """The entries of the manifest text `text` (`name` is its file name, for errors)."""
78
+ entries: dict[str, str] = {}
79
+ folded: dict[str, str] = {}
80
+ for number, raw in enumerate(text.splitlines(), start=1):
81
+ line = raw.strip()
82
+ if not line or line.startswith("#"):
83
+ continue
84
+ match = _LINE_RE.match(line)
85
+ if match is None:
86
+ raise ManifestError(f"{name}:{number}: not `<sha256> <path>`")
87
+ rel = match.group(2).strip().removeprefix("./")
88
+ if rel.startswith("/") or ".." in rel.split("/"):
89
+ raise ManifestError(f"{name}:{number}: the path leaves the manifest's directory")
90
+ if "\x00" in rel:
91
+ raise ManifestError(f"{name}:{number}: the path holds a NUL byte")
92
+ if rel in entries:
93
+ raise ManifestError(f"{name}:{number}: {rel!r} is listed twice")
94
+ other = folded.get(rel.casefold())
95
+ if other is not None:
96
+ raise ManifestError(
97
+ f"{name}:{number}: {rel!r} and {other!r} differ only in case (one file on a "
98
+ "case-insensitive file system): list it once"
99
+ )
100
+ entries[rel] = match.group(1).lower()
101
+ folded[rel.casefold()] = rel
102
+ return entries
103
+
104
+
105
+ def _why(exc: OSError) -> str:
106
+ return exc.strerror or str(exc)
107
+
108
+
109
+ def write_manifest(directory: Path, manifest: str = "MANIFEST.sha256") -> str:
110
+ """The manifest text for every file under `directory` (for a person updating an IP).
111
+
112
+ Raises `ManifestError` when the tree holds a link or something unreadable."""
113
+ tree = _walk(directory)
114
+ if tree.links or tree.unreadable:
115
+ bad = ", ".join([*tree.links, *tree.unreadable])
116
+ raise ManifestError(f"cannot record {directory}: links or unreadable entries: {bad}")
117
+ lines = [f"{_sha256_regular(directory / rel)} {rel}" for rel in tree.files if rel != manifest]
118
+ return "\n".join(lines) + "\n"
119
+
120
+
121
+ @dataclass
122
+ class _Tree:
123
+ files: list[str] = field(default_factory=list)
124
+ """Regular files (and other non-directory, non-link entries), relative, sorted."""
125
+ links: list[str] = field(default_factory=list)
126
+ """Symbolic links, to files or directories (never followed)."""
127
+ unreadable: list[str] = field(default_factory=list)
128
+ """Directories that could not be listed."""
129
+
130
+
131
+ def _walk(directory: Path) -> _Tree:
132
+ tree = _Tree()
133
+
134
+ def _error(exc: OSError) -> None:
135
+ where = Path(exc.filename) if exc.filename else directory
136
+ try:
137
+ rel = where.relative_to(directory).as_posix()
138
+ except ValueError:
139
+ rel = where.as_posix()
140
+ tree.unreadable.append(rel or ".")
141
+
142
+ for dirpath, dirnames, filenames in os.walk(directory, onerror=_error):
143
+ here = Path(dirpath)
144
+ keep = []
145
+ for name in sorted(dirnames):
146
+ if here == directory and name == ".git":
147
+ continue
148
+ rel = (here / name).relative_to(directory).as_posix()
149
+ if (here / name).is_symlink():
150
+ tree.links.append(rel)
151
+ else:
152
+ keep.append(name)
153
+ dirnames[:] = keep
154
+ for name in filenames:
155
+ rel = (here / name).relative_to(directory).as_posix()
156
+ (tree.links if (here / name).is_symlink() else tree.files).append(rel)
157
+ tree.files.sort()
158
+ tree.links.sort()
159
+ tree.unreadable.sort()
160
+ return tree
161
+
162
+
163
+ def _read_regular(path: Path) -> bytes:
164
+ """The bytes of the regular file `path`, never through a link (`OSError` otherwise)."""
165
+ fd = os.open(path, _OPEN_FLAGS)
166
+ try:
167
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
168
+ raise OSError(f"{path.name} is not a regular file")
169
+ chunks = []
170
+ while chunk := os.read(fd, _CHUNK):
171
+ chunks.append(chunk)
172
+ return b"".join(chunks)
173
+ finally:
174
+ os.close(fd)
175
+
176
+
177
+ def _sha256_regular(path: Path) -> str:
178
+ return hashlib.sha256(_read_regular(path)).hexdigest()
179
+
180
+
181
+ def _manifest_rel(value: str) -> str:
182
+ """`value` as a normalised repo-relative POSIX path (`ArgError` if it is not one)."""
183
+ text = value.replace("\\", "/").strip()
184
+ parts = [p for p in text.split("/") if p not in ("", ".")]
185
+ if not parts or text.startswith("/") or ".." in parts or (len(text) > 1 and text[1] == ":"):
186
+ raise ArgError("args['manifest'] must be a repo-relative path without '..'")
187
+ return "/".join(parts)
188
+
189
+
190
+ def _linked_part(root: Path, rel: str) -> str | None:
191
+ """The first component of `rel` under `root` that is a symbolic link, if any."""
192
+ current = root
193
+ for part in rel.split("/"):
194
+ current = current / part
195
+ if current.is_symlink():
196
+ return current.relative_to(root).as_posix()
197
+ return None
198
+
199
+
200
+ class VendorGuardCheck:
201
+ """Fails when a file under the vendor manifest's directory differs from the manifest."""
202
+
203
+ id = "vendor_guard"
204
+ name = "Vendor guard"
205
+ per_block = False
206
+
207
+ async def run(self, spec: CheckSpec, ctx: ToolContext) -> CheckResult:
208
+ start = time.monotonic()
209
+ try:
210
+ manifest_rel = _manifest_rel(optional_str(spec.args, "manifest", DEFAULT_MANIFEST))
211
+ allow = [glob_to_regex(g) for g in optional_list_of_str(spec.args, "allow")]
212
+ except ArgError as exc:
213
+ return error_result(spec, str(exc), start)
214
+ root = ctx.repo_root
215
+ manifest = root / manifest_rel
216
+ base = manifest.parent
217
+ base_rel = Path(manifest_rel).parent.as_posix()
218
+ prefix = "" if base_rel in ("", ".") else base_rel + "/"
219
+ linked = _linked_part(root, manifest_rel)
220
+ if linked is not None:
221
+ issue = Issue(
222
+ file=linked,
223
+ rule="vendor_guard.symlink",
224
+ msg=f"{linked} is a symbolic link: the manifest and its directory must be real",
225
+ )
226
+ return self._result(spec, "error", [issue], start, [])
227
+ if not Path(os.path.realpath(manifest)).is_relative_to(Path(os.path.realpath(root))):
228
+ issue = Issue(rule="vendor_guard.manifest", msg="the manifest is outside the repo")
229
+ return self._result(spec, "error", [issue], start, [])
230
+ if not manifest.is_file():
231
+ issue = Issue(
232
+ file=manifest_rel,
233
+ rule="vendor_guard.no_manifest",
234
+ msg=f"no vendor manifest at {manifest_rel}",
235
+ )
236
+ return self._result(spec, "error", [issue], start, [])
237
+ try:
238
+ manifest_bytes = _read_regular(manifest)
239
+ entries = parse_manifest(manifest_bytes.decode("utf-8"), manifest.name)
240
+ except ManifestError as exc:
241
+ issue = Issue(file=manifest_rel, rule="vendor_guard.manifest", msg=str(exc))
242
+ return self._result(spec, "error", [issue], start, [])
243
+ except (OSError, UnicodeDecodeError) as exc:
244
+ why = "not UTF-8" if isinstance(exc, UnicodeDecodeError) else _why(exc)
245
+ issue = Issue(
246
+ file=manifest_rel,
247
+ rule="vendor_guard.manifest",
248
+ msg=f"cannot read {manifest_rel}: {why}",
249
+ )
250
+ return self._result(spec, "error", [issue], start, [])
251
+
252
+ tree = _walk(base)
253
+ issues: list[Issue] = []
254
+ infra = False
255
+ hashes: list[tuple[str, str]] = [(manifest_rel, hashlib.sha256(manifest_bytes).hexdigest())]
256
+ for rel in tree.links:
257
+ hashes.append((prefix + rel, "symlink"))
258
+ issues.append(
259
+ Issue(
260
+ file=prefix + rel,
261
+ rule="vendor_guard.symlink",
262
+ msg=(
263
+ f"{prefix + rel} is a symbolic link: vendored IP is real files, so "
264
+ "the manifest covers what is built; replace the link by the files"
265
+ ),
266
+ )
267
+ )
268
+ for rel in tree.unreadable:
269
+ infra = True
270
+ hashes.append((prefix + rel, "unreadable"))
271
+ issues.append(
272
+ Issue(
273
+ file=prefix + rel,
274
+ rule="vendor_guard.unreadable",
275
+ msg=f"cannot list {prefix + rel}: its files cannot be verified",
276
+ )
277
+ )
278
+ real_base = Path(os.path.realpath(base))
279
+ for rel, recorded in sorted(entries.items()):
280
+ path = base / rel
281
+ link = _linked_part(base, rel)
282
+ if link is not None or not Path(os.path.realpath(path)).is_relative_to(real_base):
283
+ if link is None or link not in tree.links:
284
+ issues.append(
285
+ Issue(
286
+ file=prefix + rel,
287
+ rule="vendor_guard.symlink",
288
+ msg=f"{prefix + rel} resolves outside {base_rel or '.'}",
289
+ )
290
+ )
291
+ continue
292
+ try:
293
+ if not stat.S_ISREG(path.lstat().st_mode):
294
+ raise FileNotFoundError(rel) # a FIFO, a directory...: not the file
295
+ actual = _sha256_regular(path)
296
+ except (FileNotFoundError, ValueError):
297
+ issues.append(
298
+ Issue(
299
+ file=prefix + rel,
300
+ rule="vendor_guard.missing",
301
+ msg=f"{prefix + rel} is in {manifest_rel} but does not exist",
302
+ )
303
+ )
304
+ continue
305
+ except OSError as exc:
306
+ infra = True
307
+ hashes.append((prefix + rel, "unreadable"))
308
+ issues.append(
309
+ Issue(
310
+ file=prefix + rel,
311
+ rule="vendor_guard.unreadable",
312
+ msg=f"cannot read {prefix + rel} ({_why(exc)}): it cannot be verified",
313
+ )
314
+ )
315
+ continue
316
+ hashes.append((prefix + rel, actual))
317
+ if actual != recorded:
318
+ issues.append(
319
+ Issue(
320
+ file=prefix + rel,
321
+ rule="vendor_guard.modified",
322
+ msg=(
323
+ f"{prefix + rel} differs from {manifest_rel}: vendored IP is never "
324
+ "edited in place. Revert it, and adapt the IP in its wrapper; an "
325
+ "IP update replaces its files and the manifest in one reviewed "
326
+ "commit"
327
+ ),
328
+ )
329
+ )
330
+ name = manifest.name
331
+ for rel in tree.files:
332
+ if rel == name or rel in entries or any(r.fullmatch(rel) for r in allow):
333
+ continue
334
+ hashes.append((prefix + rel, "unlisted"))
335
+ issues.append(
336
+ Issue(
337
+ file=prefix + rel,
338
+ rule="vendor_guard.unlisted",
339
+ msg=f"{prefix + rel} is not in {manifest_rel}: record it, or remove it",
340
+ )
341
+ )
342
+ status: CheckStatus = "error" if infra else "fail" if issues else "pass"
343
+ return self._result(spec, status, issues, start, hashes)
344
+
345
+ @staticmethod
346
+ def _result(
347
+ spec: CheckSpec,
348
+ status: CheckStatus,
349
+ issues: list[Issue],
350
+ start: float,
351
+ hashes: list[tuple[str, str]],
352
+ ) -> CheckResult:
353
+ return CheckResult(
354
+ check_id=spec.id,
355
+ status=status,
356
+ issues=tuple(issues),
357
+ duration_s=time.monotonic() - start,
358
+ idempotency_key=compute_idempotency_key(spec.id, spec.args, hashes),
359
+ )
360
+
361
+
362
+ __all__ = [
363
+ "DEFAULT_MANIFEST",
364
+ "ManifestError",
365
+ "VendorGuardCheck",
366
+ "parse_manifest",
367
+ "read_manifest",
368
+ "write_manifest",
369
+ ]