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,773 @@
1
+ """`EdalizeTool`: cocotb simulation on Verilator or Icarus, configured by Edalize.
2
+
3
+ Shape (docs/spikes/S3.md): Edalize 0.6's `sim` flow is used **in process, to configure
4
+ only** (it writes the Makefile and the tool files into a work root and starts no
5
+ subprocess for these two simulators). The build (`make`) and the run (the model, or
6
+ ``make run`` for Icarus) then go through `ctx.runner`, with a cocotb 2.x environment
7
+ chipgraph computes itself (`GPI_USERS`, `COCOTB_TEST_MODULES`, `COCOTB_TOPLEVEL`,
8
+ `COCOTB_RESULTS_FILE`, the virtualenv's `bin` first on `PATH`, ...). Edalize's own
9
+ `build()`/`run()` are never called: they call `subprocess` directly and, with cocotb 2.1,
10
+ exit 0 without running a test. The result comes from cocotb's `results.xml` only
11
+ (`CocotbParser`): failing tests leave every exit code at 0, and **no `results.xml` is an
12
+ `error`, never a `pass`**.
13
+
14
+ Edalize and cocotb are the optional `sim` extra (``uv sync --extra sim``) and are imported
15
+ lazily: without them the adapter still loads, and a run returns an `error` saying how to
16
+ install them. A missing simulator, `make` or C++ compiler is an `error` that names it.
17
+
18
+ Profile example (`.chipgraph.yml`)::
19
+
20
+ adapters:
21
+ sim:
22
+ use: edalize
23
+ simulator: verilator # or icarus; `tool:` is accepted too
24
+ top: "tiny_{block}"
25
+ filelist: "filelists/{block}.f"
26
+ test_module: "dv/{block}_tb.py" # a file, or dotted names found in `test_dir`
27
+ waves: false
28
+ timeout_s: 600
29
+
30
+ `spec.args` (every string may use `ctx.params` placeholders such as ``{block}``):
31
+
32
+ - ``top`` (required): the HDL toplevel, also `COCOTB_TOPLEVEL`.
33
+ - ``filelist`` and/or ``files``: a `.f` filelist (read by
34
+ `chipgraph.adapters.tool.filelist.read_filelist`: sources, ``+incdir+``,
35
+ ``+define+``) and/or a list of source files, repo-relative. ``filelist_relative_to``:
36
+ ``auto`` (default: filelist-relative, else repo-root-relative, whichever resolves),
37
+ ``filelist`` or ``cwd`` (the repo root).
38
+ - ``parameters`` (HDL parameters), ``defines``, ``plusargs``: mappings.
39
+ - ``simulator`` (or ``tool``): ``verilator`` (default) or ``icarus``.
40
+ - ``test_module`` (or ``test_modules``): one or more cocotb test modules, each a ``.py``
41
+ path (its directory goes on `PYTHONPATH`) or a dotted module name found in
42
+ ``test_dir`` (default: the repo root).
43
+ - ``testcase``: a test name or a list of names to run (default: all).
44
+ - ``waves`` (bool): write ``dump.fst`` in the work root.
45
+ - ``seed`` (int, default 0; ``null`` for cocotb's random seed): `COCOTB_RANDOM_SEED`.
46
+ - ``timeout_s``: wall-time budget for the build and the run together.
47
+ - ``work_root``: ``state`` (default, ``.chipgraph/state/sim/<check>/<params>/<simulator>``,
48
+ machine-local and not tracked), ``temp`` (a new temporary directory) or a path outside
49
+ the project's source tree.
50
+ - ``env``: extra environment variables for the build and the run (the cocotb variables
51
+ above win over them).
52
+ - ``tool_options``: extra Edalize tool options (e.g. ``verilator_options``,
53
+ ``iverilog_options``, ``run_options``, ``timescale``), merged into the defaults.
54
+ - ``jobs``: parallel jobs for the Verilator model's `make` (default: CPU count, max 8).
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import hashlib
60
+ import importlib
61
+ import importlib.metadata
62
+ import json
63
+ import os
64
+ import re
65
+ import shutil
66
+ import sys
67
+ import tempfile
68
+ import time
69
+ from collections.abc import Callable, Mapping
70
+ from dataclasses import dataclass
71
+ from pathlib import Path
72
+ from typing import Any, cast
73
+
74
+ from chipgraph.adapters.parser.cocotb import CocotbOutcome, CocotbParser, Simulator
75
+ from chipgraph.adapters.tool.filelist import Filelist, FilelistError, read_filelist
76
+ from chipgraph.core.contracts import CheckResult, CheckSpec, Issue
77
+ from chipgraph.core.plugin_api.types import RunResult, ToolContext
78
+ from chipgraph.core.state.layout import StateLayout
79
+
80
+ __all__ = ["EdalizeTool", "SimArgs", "SimModules", "cocotb_env", "load_sim_modules"]
81
+
82
+ SIM_EXTRA_HINT = "uv sync --extra sim"
83
+ _SIMULATORS: tuple[Simulator, ...] = ("verilator", "icarus")
84
+ _EDAM_NAME = "chipgraph_sim"
85
+ _DUMP_TOP = "chipgraph_dump"
86
+ _RESULTS_FILE = "results.xml"
87
+ _WAVES_FILE = "dump.fst"
88
+ _DEFAULT_TIMESCALE = "1ns/1ps"
89
+ _CXX_CANDIDATES = ("g++", "c++", "clang++")
90
+ _LOG_TAIL_MAX_CHARS = 4000
91
+ _SLUG_RE = re.compile(r"[^A-Za-z0-9_.=-]+")
92
+
93
+
94
+ class _ArgError(Exception):
95
+ """A malformed `spec.args`, turned into an `error` `CheckResult`."""
96
+
97
+
98
+ class _SetupError(Exception):
99
+ """The environment cannot run the simulation (missing extra, tool, libpython ...)."""
100
+
101
+
102
+ @dataclass(frozen=True, slots=True)
103
+ class SimModules:
104
+ """What the adapter needs from the `sim` extra, loaded lazily (see `load_sim_modules`)."""
105
+
106
+ sim_flow: Callable[[dict[str, Any], Path], Any]
107
+ """`edalize.flows.sim.Sim`: ``Sim(edam, work_root)``."""
108
+ pygpi_entry_point: Callable[[], str]
109
+ """`cocotb_tools.config.pygpi_entry_point`: the ``lib,func`` cocotb's GPI loads."""
110
+ find_libpython: Callable[[], str | None]
111
+ """`find_libpython.find_libpython`: the shared libpython of this interpreter."""
112
+ versions: Mapping[str, str]
113
+ """Installed versions of edalize and cocotb (part of the idempotency key)."""
114
+
115
+
116
+ def load_sim_modules() -> SimModules:
117
+ """Import Edalize and cocotb (the `sim` extra); `_SetupError` if they are missing."""
118
+ try:
119
+ sim = importlib.import_module("edalize.flows.sim")
120
+ cocotb_config = importlib.import_module("cocotb_tools.config")
121
+ find_libpython = importlib.import_module("find_libpython")
122
+ except ImportError as exc:
123
+ raise _SetupError(
124
+ f"the `sim` extra is not installed ({exc.name or exc}): run `{SIM_EXTRA_HINT}` "
125
+ "(or `pip install 'chipgraph[sim]'`) to install edalize and cocotb"
126
+ ) from exc
127
+ return SimModules(
128
+ sim_flow=sim.Sim,
129
+ pygpi_entry_point=cocotb_config.pygpi_entry_point,
130
+ find_libpython=find_libpython.find_libpython,
131
+ versions=_package_versions(),
132
+ )
133
+
134
+
135
+ @dataclass(frozen=True, slots=True)
136
+ class SimArgs:
137
+ """`spec.args`, validated and resolved against the repo root and `ctx.params`."""
138
+
139
+ simulator: Simulator
140
+ top: str
141
+ sources: tuple[Path, ...]
142
+ incdirs: tuple[Path, ...]
143
+ defines: dict[str, Any]
144
+ parameters: dict[str, Any]
145
+ plusargs: dict[str, Any]
146
+ test_modules: tuple[str, ...]
147
+ test_dirs: tuple[Path, ...]
148
+ testcase: tuple[str, ...]
149
+ waves: bool
150
+ seed: int | None
151
+ timeout_s: float | None
152
+ work_root: str
153
+ env: dict[str, str]
154
+ tool_options: dict[str, Any]
155
+ jobs: int
156
+ warnings: tuple[str, ...] = ()
157
+
158
+ @classmethod
159
+ def from_spec(cls, spec: CheckSpec, ctx: ToolContext) -> SimArgs:
160
+ """Validate `spec.args`; raises `_ArgError` with a message naming the bad key."""
161
+ args = spec.args
162
+ root = ctx.repo_root.resolve()
163
+
164
+ def fmt(value: object, key: str) -> str:
165
+ if not isinstance(value, str):
166
+ raise _ArgError(f"args[{key!r}] must be a string, got {value!r}")
167
+ try:
168
+ return value.format(**ctx.params)
169
+ except (KeyError, IndexError, ValueError) as exc:
170
+ raise _ArgError(f"args[{key!r}] = {value!r}: cannot substitute {exc}") from exc
171
+
172
+ def str_list(key: str) -> list[str]:
173
+ raw = args.get(key)
174
+ if raw is None:
175
+ return []
176
+ items = [raw] if isinstance(raw, str) else raw
177
+ if not isinstance(items, list):
178
+ raise _ArgError(f"args[{key!r}] must be a string or a list of strings")
179
+ return [fmt(item, key) for item in items]
180
+
181
+ def mapping(key: str) -> dict[str, Any]:
182
+ raw = args.get(key) or {}
183
+ if not isinstance(raw, dict):
184
+ raise _ArgError(f"args[{key!r}] must be a mapping")
185
+ return {str(k): v for k, v in raw.items()}
186
+
187
+ simulator = args.get("simulator", args.get("tool", "verilator"))
188
+ if simulator not in _SIMULATORS:
189
+ raise _ArgError(f"args['simulator'] must be one of {list(_SIMULATORS)}: {simulator!r}")
190
+ if "top" not in args:
191
+ raise _ArgError("args['top'] (the HDL toplevel) is required")
192
+ top = fmt(args["top"], "top")
193
+
194
+ sources: list[Path] = []
195
+ incdirs: list[Path] = []
196
+ defines: dict[str, Any] = {}
197
+ warnings: list[str] = []
198
+ if args.get("filelist") is not None:
199
+ style = args.get("filelist_relative_to", "auto")
200
+ if style not in ("auto", "filelist", "cwd"):
201
+ raise _ArgError("args['filelist_relative_to'] must be auto, filelist or cwd")
202
+ filelist = _read_filelist(root / fmt(args["filelist"], "filelist"), root, style)
203
+ sources += filelist.sources
204
+ incdirs += filelist.incdirs
205
+ defines.update({k: "" if v is None else v for k, v in filelist.defines.items()})
206
+ warnings += filelist.warnings
207
+ sources += [(root / f).resolve() for f in str_list("files")]
208
+ if not sources:
209
+ raise _ArgError("no HDL source: set args['filelist'] and/or args['files']")
210
+ missing = [str(p) for p in sources if not p.is_file()]
211
+ if missing:
212
+ raise _ArgError(f"HDL source(s) not found: {', '.join(missing)}")
213
+ defines.update(mapping("defines"))
214
+
215
+ modules: list[str] = []
216
+ test_dirs: list[Path] = []
217
+ default_dir = (root / fmt(args.get("test_dir", "."), "test_dir")).resolve()
218
+ for raw in str_list("test_module") + str_list("test_modules"):
219
+ name, directory = _resolve_test_module(raw, root, default_dir)
220
+ modules.append(name)
221
+ if directory not in test_dirs:
222
+ test_dirs.append(directory)
223
+ if not modules:
224
+ raise _ArgError("args['test_module'] (the cocotb test module) is required")
225
+
226
+ seed = args.get("seed", 0)
227
+ if seed is not None and (isinstance(seed, bool) or not isinstance(seed, int)):
228
+ raise _ArgError("args['seed'] must be an integer or null")
229
+ timeout_s = args.get("timeout_s")
230
+ if timeout_s is not None and (
231
+ isinstance(timeout_s, bool) or not isinstance(timeout_s, int | float) or timeout_s <= 0
232
+ ):
233
+ raise _ArgError("args['timeout_s'] must be a positive number")
234
+ jobs = args.get("jobs", min(os.cpu_count() or 1, 8))
235
+ if isinstance(jobs, bool) or not isinstance(jobs, int) or jobs < 1:
236
+ raise _ArgError("args['jobs'] must be a positive integer")
237
+ env = {str(k): str(v) for k, v in mapping("env").items()}
238
+
239
+ return cls(
240
+ simulator=cast(Simulator, simulator),
241
+ top=top,
242
+ sources=tuple(dict.fromkeys(sources)),
243
+ incdirs=tuple(dict.fromkeys(incdirs)),
244
+ defines=defines,
245
+ parameters=mapping("parameters"),
246
+ plusargs=mapping("plusargs"),
247
+ test_modules=tuple(dict.fromkeys(modules)),
248
+ test_dirs=tuple(test_dirs),
249
+ testcase=tuple(str_list("testcase")),
250
+ waves=bool(args.get("waves", False)),
251
+ seed=seed,
252
+ timeout_s=float(timeout_s) if timeout_s is not None else None,
253
+ work_root=fmt(args.get("work_root", "state"), "work_root"),
254
+ env=env,
255
+ tool_options=mapping("tool_options"),
256
+ jobs=jobs,
257
+ warnings=tuple(warnings),
258
+ )
259
+
260
+
261
+ def cocotb_env(
262
+ *,
263
+ simulator: Simulator,
264
+ top: str,
265
+ test_modules: tuple[str, ...],
266
+ test_dirs: tuple[Path, ...],
267
+ results_file: Path,
268
+ python: str,
269
+ libpython: str,
270
+ pygpi_entry_point: str,
271
+ path: str,
272
+ pythonpath: str = "",
273
+ seed: int | None = 0,
274
+ testcase: tuple[str, ...] = (),
275
+ ) -> dict[str, str]:
276
+ """The environment cocotb 2.x needs at build and run time, computed in process.
277
+
278
+ It mirrors what `cocotb_tools.runner` sets (S3, pitfall 1): `GPI_USERS` is the one
279
+ cocotb's GPI reads (without it the model prints "No GPI_USERS specified" and exits 0);
280
+ `PYGPI_PYTHON_BIN` is this interpreter; the test directories, then `pythonpath`, then
281
+ this interpreter's `sys.path` make up `PYTHONPATH`. `PATH` gets the interpreter's
282
+ `bin` first, because Edalize's Makefile calls `cocotb-config` through the shell and
283
+ another one (OSS CAD Suite has its own) must not shadow it.
284
+ """
285
+ py_path = [str(d) for d in test_dirs]
286
+ py_path += [p for p in pythonpath.split(os.pathsep) if p]
287
+ py_path += [p for p in sys.path[1:] if p]
288
+ env = {
289
+ "PATH": os.pathsep.join([str(Path(python).parent), path]),
290
+ "PYTHONPATH": os.pathsep.join(dict.fromkeys(py_path)),
291
+ "PYGPI_PYTHON_BIN": python,
292
+ "GPI_USERS": f"{libpython};{pygpi_entry_point}",
293
+ "COCOTB_TEST_MODULES": ",".join(test_modules),
294
+ "COCOTB_TOPLEVEL": top,
295
+ "TOPLEVEL_LANG": "verilog",
296
+ "COCOTB_RESULTS_FILE": str(results_file),
297
+ }
298
+ if seed is not None:
299
+ env["COCOTB_RANDOM_SEED"] = str(seed)
300
+ if testcase:
301
+ names = "|".join(re.escape(name) for name in testcase)
302
+ env["COCOTB_TEST_FILTER"] = rf"(^|\.)({names})$"
303
+ if simulator == "verilator":
304
+ env["COCOTB_TRUST_INERTIAL_WRITES"] = "1" # what cocotb's own Verilator runner sets
305
+ return env
306
+
307
+
308
+ class EdalizeTool:
309
+ """Runs a cocotb testbench on Verilator or Icarus (`capability="sim"`)."""
310
+
311
+ name = "edalize"
312
+ capability = "sim"
313
+
314
+ def __init__(self, *, modules: Callable[[], SimModules] | None = None) -> None:
315
+ self._load_modules = modules or load_sim_modules
316
+ self._parser = CocotbParser()
317
+
318
+ async def run(self, spec: CheckSpec, ctx: ToolContext) -> CheckResult:
319
+ start = time.monotonic()
320
+ key = self.key_for(spec, ctx)
321
+
322
+ def error(msg: str, rule: str, log: str = "") -> CheckResult:
323
+ return CheckResult(
324
+ check_id=spec.id,
325
+ status="error",
326
+ issues=(Issue(msg=msg, rule=rule, severity="error"),),
327
+ log_tail=log[-_LOG_TAIL_MAX_CHARS:],
328
+ duration_s=time.monotonic() - start,
329
+ idempotency_key=key,
330
+ )
331
+
332
+ try:
333
+ args = SimArgs.from_spec(spec, ctx)
334
+ except _ArgError as exc:
335
+ return error(f"check {spec.id!r}: {exc}", "sim/args")
336
+ try:
337
+ modules = self._load_modules()
338
+ except _SetupError as exc:
339
+ return error(str(exc), "sim/setup")
340
+
341
+ base_env = {**os.environ, **ctx.env, **args.env}
342
+ path = base_env.get("PATH", "")
343
+ python = sys.executable
344
+ tools_path = os.pathsep.join([str(Path(python).parent), path])
345
+ try:
346
+ cxx = _check_tools(args.simulator, tools_path, base_env)
347
+ libpython = modules.find_libpython()
348
+ if not libpython:
349
+ raise _SetupError(
350
+ f"no shared libpython found for {python} (find_libpython): cocotb "
351
+ "embeds Python in the simulator and needs a Python built with a "
352
+ "shared library"
353
+ )
354
+ work_root = _work_root(args, spec, ctx)
355
+ except _SetupError as exc:
356
+ return error(str(exc), "sim/setup")
357
+ except _ArgError as exc:
358
+ return error(f"check {spec.id!r}: {exc}", "sim/args")
359
+
360
+ results = work_root / _RESULTS_FILE
361
+ env = {
362
+ **ctx.env,
363
+ **args.env,
364
+ **cocotb_env(
365
+ simulator=args.simulator,
366
+ top=args.top,
367
+ test_modules=args.test_modules,
368
+ test_dirs=args.test_dirs,
369
+ results_file=results,
370
+ python=python,
371
+ libpython=libpython,
372
+ pygpi_entry_point=modules.pygpi_entry_point(),
373
+ path=path,
374
+ pythonpath=base_env.get("PYTHONPATH", ""),
375
+ seed=args.seed,
376
+ testcase=args.testcase,
377
+ ),
378
+ }
379
+
380
+ deadline = start + args.timeout_s if args.timeout_s is not None else None
381
+
382
+ def remaining() -> float | None:
383
+ return None if deadline is None else max(deadline - time.monotonic(), 1.0)
384
+
385
+ options = await _tool_options(args, ctx, cxx, work_root, env)
386
+ edam = _edam(args, work_root, options)
387
+ _reset_work_root(work_root, edam)
388
+ try:
389
+ flow = modules.sim_flow(edam, work_root)
390
+ flow.configure()
391
+ build_cmd, build_args = flow.build_runner.get_build_command()
392
+ run_cmd, run_args, run_cwd = flow.flow.get_node(args.simulator).inst.run()
393
+ except Exception as exc: # Edalize raises plain RuntimeError/ValueError/KeyError ...
394
+ return error(
395
+ f"Edalize could not configure the {args.simulator} flow: {exc}", "sim/setup"
396
+ )
397
+
398
+ build = await ctx.runner.run(
399
+ [build_cmd, *build_args], cwd=work_root, env=env, timeout_s=remaining()
400
+ )
401
+ build_log = build.stdout + build.stderr
402
+ _write(work_root / "build.log", build_log)
403
+
404
+ run: RunResult | None = None
405
+ run_log = ""
406
+ if build.returncode == 0 and not build.timed_out:
407
+ run_cwd = Path(run_cwd)
408
+ run_exe = str(run_cwd / run_cmd) if str(run_cmd).startswith("./") else str(run_cmd)
409
+ run = await ctx.runner.run(
410
+ [run_exe, *run_args], cwd=run_cwd, env=env, timeout_s=remaining()
411
+ )
412
+ run_log = run.stdout + run.stderr
413
+ _write(work_root / "run.log", run_log)
414
+
415
+ outcome = self._parser.parse_run(
416
+ results,
417
+ simulator=args.simulator,
418
+ build_rc=build.returncode,
419
+ build_log=build_log,
420
+ build_timed_out=build.timed_out,
421
+ run_rc=run.returncode if run is not None else None,
422
+ run_log=run_log,
423
+ run_timed_out=run.timed_out if run is not None else False,
424
+ )
425
+ warnings = tuple(
426
+ Issue(msg=w, rule="sim/filelist", severity="warning") for w in args.warnings
427
+ )
428
+ return CheckResult(
429
+ check_id=spec.id,
430
+ status=outcome.status,
431
+ issues=(*(_in_repo(i, ctx.repo_root) for i in outcome.issues), *warnings),
432
+ log_tail=_log_tail(build_log if run is None else run_log, outcome, args, work_root),
433
+ duration_s=time.monotonic() - start,
434
+ idempotency_key=key,
435
+ )
436
+
437
+ @staticmethod
438
+ def key_for(spec: CheckSpec, ctx: ToolContext) -> str:
439
+ """A stable idempotency key: the inputs that decide what the simulation does.
440
+
441
+ Hashes the check id, the resolved arguments, the sha256 of every HDL source and of
442
+ every `.py` file in the test directories, `ctx.env`/`ctx.params`, the Python
443
+ version, the installed edalize and cocotb versions, and the simulator's
444
+ executables (resolved path, size, mtime). Not the work root, the timeout or any
445
+ output. Never raises: with invalid args, it keys on the raw `spec.args`.
446
+ """
447
+ try:
448
+ args = SimArgs.from_spec(spec, ctx)
449
+ except _ArgError:
450
+ raw = json.dumps(spec.args, sort_keys=True, default=str)
451
+ return _sha256_text(repr((spec.id, "invalid-args", raw)))
452
+ root = ctx.repo_root.resolve()
453
+ test_files = sorted(
454
+ {p.resolve() for d in args.test_dirs if d.is_dir() for p in d.glob("*.py")}
455
+ )
456
+ path = {**os.environ, **ctx.env, **args.env}.get("PATH", "")
457
+ tools = _REQUIRED_TOOLS[args.simulator]
458
+ payload = {
459
+ "check": spec.id,
460
+ "simulator": args.simulator,
461
+ "top": args.top,
462
+ "sources": [[_relative(str(p), root), _sha256_file(p)] for p in args.sources],
463
+ "tests": [[_relative(str(p), root), _sha256_file(p)] for p in test_files],
464
+ "incdirs": [_relative(str(p), root) for p in args.incdirs],
465
+ "test_modules": list(args.test_modules),
466
+ "testcase": list(args.testcase),
467
+ "defines": args.defines,
468
+ "parameters": args.parameters,
469
+ "plusargs": args.plusargs,
470
+ "waves": args.waves,
471
+ "seed": args.seed,
472
+ "env": args.env,
473
+ "tool_options": args.tool_options,
474
+ "ctx_env": ctx.env,
475
+ "ctx_params": ctx.params,
476
+ "python": list(sys.version_info[:3]),
477
+ "packages": dict(_package_versions()),
478
+ "executables": {t: _fingerprint(t, path) for t in tools},
479
+ }
480
+ return _sha256_text(json.dumps(payload, sort_keys=True, default=str))
481
+
482
+
483
+ _REQUIRED_TOOLS: dict[Simulator, tuple[str, ...]] = {
484
+ "verilator": ("verilator", "make"),
485
+ "icarus": ("iverilog", "vvp", "make"),
486
+ }
487
+
488
+
489
+ def _check_tools(simulator: Simulator, path: str, env: Mapping[str, str]) -> str | None:
490
+ """Raise `_SetupError` naming what is missing; return the C++ compiler (Verilator)."""
491
+ missing = [t for t in _REQUIRED_TOOLS[simulator] if shutil.which(t, path=path) is None]
492
+ cxx: str | None = None
493
+ if simulator == "verilator":
494
+ wanted = env.get("CXX")
495
+ candidates = (wanted,) if wanted else _CXX_CANDIDATES
496
+ cxx = next((c for c in candidates if shutil.which(c, path=path)), None)
497
+ if cxx is None:
498
+ missing.append(f"a C++ compiler ({wanted or 'g++'})")
499
+ if missing:
500
+ hint = (
501
+ "Verilator compiles the model with a C++ compiler (e.g. `apt-get install g++`)"
502
+ if simulator == "verilator"
503
+ else "install Icarus Verilog from the distribution (e.g. `apt-get install iverilog`)"
504
+ )
505
+ raise _SetupError(f"{simulator} simulation needs {', '.join(missing)} on PATH: {hint}")
506
+ return cxx
507
+
508
+
509
+ async def _tool_options(
510
+ args: SimArgs, ctx: ToolContext, cxx: str | None, work_root: Path, env: Mapping[str, str]
511
+ ) -> dict[str, Any]:
512
+ """Per-simulator Edalize options, with the S3 fixes as detected on this machine."""
513
+ opts: dict[str, Any]
514
+ if args.simulator == "verilator":
515
+ verilator_options = await _cxx_std_args(ctx, cxx, work_root, env)
516
+ verilator_options += [f"+incdir+{d}" for d in args.incdirs]
517
+ run_options: list[str] = []
518
+ if args.waves:
519
+ verilator_options += ["--trace-fst", *_fst_lib_args()]
520
+ run_options.append("--trace")
521
+ opts = {
522
+ "verilator_options": verilator_options,
523
+ "run_options": run_options,
524
+ "make_options": [f"-j{args.jobs}"],
525
+ }
526
+ else:
527
+ # tinysoc-style SystemVerilog (logic, always_ff) needs -g2012, and a timescale so
528
+ # a 10 ns cocotb clock is representable (Icarus defaults to 1 s): S3, pitfall 8.
529
+ opts = {
530
+ "iverilog_options": ["-g2012", *(f"-I{d}" for d in args.incdirs)],
531
+ "timescale": _DEFAULT_TIMESCALE,
532
+ }
533
+ for key, value in args.tool_options.items():
534
+ if isinstance(value, list) and isinstance(opts.get(key), list):
535
+ opts[key] = [*opts[key], *(str(v) for v in value)]
536
+ else:
537
+ opts[key] = value
538
+ return opts
539
+
540
+
541
+ async def _cxx_std_args(
542
+ ctx: ToolContext, cxx: str | None, work_root: Path, env: Mapping[str, str]
543
+ ) -> list[str]:
544
+ """``-CFLAGS -std=gnu++17`` when the C++ compiler defaults below C++14 (S3, pitfall 6).
545
+
546
+ Verilator 5.05x needs C++14 and adds a ``-std=`` flag only if the compiler it was
547
+ built with needed one; old Apple Command Line Tools default to C++98.
548
+ """
549
+ if cxx is None:
550
+ return []
551
+ probe = await ctx.runner.run(
552
+ [cxx, "-dM", "-E", "-x", "c++", os.devnull], cwd=work_root, env=dict(env), timeout_s=30
553
+ )
554
+ for line in probe.stdout.splitlines():
555
+ if line.startswith("#define __cplusplus "):
556
+ try:
557
+ value = int(line.split()[2].rstrip("L"))
558
+ except (IndexError, ValueError):
559
+ return []
560
+ return ["-CFLAGS", "-std=gnu++17"] if value < 201402 else []
561
+ return []
562
+
563
+
564
+ def _fst_lib_args() -> list[str]:
565
+ """Where Verilator's FST writer finds lz4/zlib on macOS with Homebrew (S3, pitfall 7).
566
+
567
+ On Linux the headers come from the distribution (`liblz4-dev`, `zlib1g-dev`) on the
568
+ default paths; Apple clang does not search the Homebrew prefix.
569
+ """
570
+ if sys.platform != "darwin":
571
+ return []
572
+ for prefix in (os.environ.get("HOMEBREW_PREFIX", ""), "/opt/homebrew", "/usr/local"):
573
+ if prefix and (Path(prefix) / "include" / "lz4.h").is_file():
574
+ return ["-CFLAGS", f"-I{prefix}/include", "-LDFLAGS", f"-L{prefix}/lib"]
575
+ return []
576
+
577
+
578
+ def _edam(args: SimArgs, work_root: Path, options: Mapping[str, Any]) -> dict[str, Any]:
579
+ """The EDAM for Edalize's `sim` flow; `cocotb_module` turns its cocotb glue on."""
580
+ files = [{"name": str(p), "file_type": _file_type(p)} for p in args.sources]
581
+ toplevel = args.top
582
+ if args.simulator == "icarus" and args.waves:
583
+ # Edalize already runs `vvp ... -fst`; a second top with $dumpvars does the rest.
584
+ dump = work_root / f"{_DUMP_TOP}.v"
585
+ timescale = str(options.get("timescale", _DEFAULT_TIMESCALE))
586
+ _write(
587
+ dump,
588
+ f"`timescale {timescale.replace('/', ' / ')}\nmodule {_DUMP_TOP};\n"
589
+ f' initial begin\n $dumpfile("{_WAVES_FILE}");\n'
590
+ f" $dumpvars(0, {args.top});\n end\nendmodule\n",
591
+ )
592
+ files.append({"name": str(dump), "file_type": "verilogSource"})
593
+ toplevel = f"{args.top} {_DUMP_TOP}"
594
+ parameters: dict[str, Any] = {}
595
+ for kind, values in (
596
+ ("vlogparam", args.parameters),
597
+ ("vlogdefine", args.defines),
598
+ ("plusarg", args.plusargs),
599
+ ):
600
+ for name, value in values.items():
601
+ parameters[name] = {"datatype": _datatype(value), "paramtype": kind, "default": value}
602
+ return {
603
+ "name": _EDAM_NAME,
604
+ "toplevel": toplevel,
605
+ "files": files,
606
+ "parameters": parameters,
607
+ "flow_options": {
608
+ "tool": args.simulator,
609
+ "cocotb_module": args.test_modules[0],
610
+ **options,
611
+ },
612
+ }
613
+
614
+
615
+ def _work_root(args: SimArgs, spec: CheckSpec, ctx: ToolContext) -> Path:
616
+ """The work root: under the run state, a new temp dir, or a path outside the sources."""
617
+ repo = ctx.repo_root.resolve()
618
+ state_dir = StateLayout(repo).state_dir
619
+ if args.work_root == "state":
620
+ params = ",".join(f"{k}={v}" for k, v in sorted(ctx.params.items())) or "_"
621
+ path = state_dir / "sim" / _slug(spec.id) / _slug(params) / args.simulator
622
+ elif args.work_root == "temp":
623
+ path = Path(tempfile.mkdtemp(prefix=f"chipgraph-sim-{_slug(spec.id)}-"))
624
+ else:
625
+ path = Path(args.work_root)
626
+ path = (path if path.is_absolute() else repo / path).resolve()
627
+ if path.is_relative_to(repo) and not path.is_relative_to(state_dir.resolve()):
628
+ raise _ArgError(
629
+ f"args['work_root'] {args.work_root!r} is inside the project's source tree; "
630
+ "use `state`, `temp` or a directory outside the repo"
631
+ )
632
+ path.mkdir(parents=True, exist_ok=True)
633
+ return path.resolve()
634
+
635
+
636
+ def _reset_work_root(work_root: Path, edam: Mapping[str, Any]) -> None:
637
+ """Remove stale outputs; wipe the build when the EDAM changed since the last run.
638
+
639
+ `results.xml` and `dump.fst` are always removed first, so an old result can never be
640
+ read as this run's.
641
+ """
642
+ for name in (_RESULTS_FILE, _WAVES_FILE, "build.log", "run.log"):
643
+ (work_root / name).unlink(missing_ok=True)
644
+ stamp = work_root / "edam.json"
645
+ text = json.dumps(edam, indent=2, sort_keys=True, default=str) + "\n"
646
+ old = stamp.read_text(encoding="utf-8") if stamp.is_file() else None
647
+ if old is not None and old != text:
648
+ for child in work_root.iterdir():
649
+ if child.is_dir() and not child.is_symlink():
650
+ shutil.rmtree(child)
651
+ elif child.name != f"{_DUMP_TOP}.v":
652
+ child.unlink()
653
+ _write(stamp, text)
654
+
655
+
656
+ def _read_filelist(path: Path, root: Path, style: str) -> Filelist:
657
+ try:
658
+ if style in ("filelist", "cwd"):
659
+ return read_filelist(path, relative_to=cast(Any, style), root=root)
660
+ first = read_filelist(path, relative_to="filelist", root=root)
661
+ if first.sources and all(p.is_file() for p in first.sources):
662
+ return first
663
+ alt = read_filelist(path, relative_to="cwd", root=root)
664
+ if sum(p.is_file() for p in alt.sources) > sum(p.is_file() for p in first.sources):
665
+ return alt
666
+ return first
667
+ except FilelistError as exc:
668
+ raise _ArgError(str(exc)) from exc
669
+
670
+
671
+ def _resolve_test_module(raw: str, root: Path, default_dir: Path) -> tuple[str, Path]:
672
+ """(module name, directory for PYTHONPATH) of a `.py` path or a dotted module name."""
673
+ if raw.endswith(".py") or "/" in raw:
674
+ file = (root / raw).resolve()
675
+ if not file.is_file():
676
+ raise _ArgError(f"cocotb test module not found: {raw}")
677
+ return file.stem, file.parent
678
+ if not re.fullmatch(r"[A-Za-z_]\w*(\.[A-Za-z_]\w*)*", raw):
679
+ raise _ArgError(f"invalid cocotb test module name: {raw!r}")
680
+ rel = Path(*raw.split("."))
681
+ if not ((default_dir / rel).with_suffix(".py").is_file() or (default_dir / rel).is_dir()):
682
+ raise _ArgError(f"cocotb test module {raw!r} not found in {default_dir}")
683
+ return raw, default_dir
684
+
685
+
686
+ def _log_tail(log: str, outcome: CocotbOutcome, args: SimArgs, work_root: Path) -> str:
687
+ footer = (
688
+ f"\n--- chipgraph sim ({args.simulator}, top {args.top}): {outcome.status}, "
689
+ f"{outcome.summary}; work root {work_root}\n"
690
+ )
691
+ return log[-(_LOG_TAIL_MAX_CHARS - len(footer)) :] + footer
692
+
693
+
694
+ def _in_repo(issue: Issue, root: Path) -> Issue:
695
+ """`issue` with a repo-relative `file`; a file outside the repo moves into the message.
696
+
697
+ Findings (and their evidence) only take repo-relative paths, and a test module or a
698
+ source may live outside the project (e.g. a shared testbench).
699
+ """
700
+ if issue.file is None:
701
+ return issue
702
+ rel = _relative(issue.file, root)
703
+ if not Path(rel).is_absolute():
704
+ return issue.model_copy(update={"file": rel})
705
+ where = f"{issue.file}:{issue.line}" if issue.line is not None else issue.file
706
+ return issue.model_copy(update={"file": None, "line": None, "msg": f"{issue.msg} (at {where})"})
707
+
708
+
709
+ def _relative(path: str, root: Path) -> str:
710
+ """`path` relative to `root` (a relative `path` is taken from `root`); else absolute."""
711
+ full = Path(path) if Path(path).is_absolute() else root / path
712
+ try:
713
+ return full.resolve().relative_to(root.resolve()).as_posix()
714
+ except ValueError:
715
+ return str(full.resolve())
716
+ except OSError:
717
+ return str(full)
718
+
719
+
720
+ def _file_type(path: Path) -> str:
721
+ return "verilogSource" if path.suffix in (".v", ".vh") else "systemVerilogSource"
722
+
723
+
724
+ def _datatype(value: object) -> str:
725
+ if isinstance(value, bool):
726
+ return "bool"
727
+ if isinstance(value, int):
728
+ return "int"
729
+ if isinstance(value, float):
730
+ return "real"
731
+ return "str"
732
+
733
+
734
+ def _slug(text: str) -> str:
735
+ return _SLUG_RE.sub("_", text).strip("_") or "_"
736
+
737
+
738
+ def _write(path: Path, text: str) -> None:
739
+ path.write_text(text, encoding="utf-8")
740
+
741
+
742
+ def _sha256_text(text: str) -> str:
743
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
744
+
745
+
746
+ def _sha256_file(path: Path) -> str:
747
+ try:
748
+ return hashlib.sha256(path.read_bytes()).hexdigest()
749
+ except OSError:
750
+ return "unreadable"
751
+
752
+
753
+ def _fingerprint(tool: str, path: str) -> list[object]:
754
+ """(resolved path, size, mtime_ns) of `tool` on `path`: changes when it is upgraded."""
755
+ found = shutil.which(tool, path=path)
756
+ if found is None:
757
+ return ["missing"]
758
+ real = Path(found).resolve()
759
+ try:
760
+ st = real.stat()
761
+ except OSError:
762
+ return [str(real)]
763
+ return [str(real), st.st_size, st.st_mtime_ns]
764
+
765
+
766
+ def _package_versions() -> dict[str, str]:
767
+ versions: dict[str, str] = {}
768
+ for dist in ("edalize", "cocotb"):
769
+ try:
770
+ versions[dist] = importlib.metadata.version(dist)
771
+ except importlib.metadata.PackageNotFoundError:
772
+ versions[dist] = "missing"
773
+ return versions