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,975 @@
1
+ """``mas-markdown``: a deterministic extractor for QNSC-style MAS Markdown.
2
+
3
+ Reads a Micro-Architecture Specification written in the QNSC template into the Design
4
+ Model. It never calls a model or a network; every fact comes from parsing the text, and
5
+ every problem is returned as a :class:`MasDiagnostic`, never raised (an unreadable row
6
+ still yields a model with what could be read).
7
+
8
+ What it extracts (see DESIGN.md 4.1-4.5, 4.8 and DECISIONS D37):
9
+
10
+ * **Requirements** -- declared REQ-IDs (matched by :meth:`RequirementsCfg.id_regex`) and,
11
+ when ``requirements.infer == "verification"``, one inferred requirement per top-level
12
+ numbered item of the ``infer_heading`` section. Declared wins over inferred. An
13
+ inferred key is a content hash, so renumbering the list does not change it. In a file
14
+ that declares at least one ID, a top-level numbered ``infer_heading`` item that neither
15
+ starts with an ID nor names one is recorded with ``attrs.id_source = "missing"`` (in
16
+ both modes, never also as inferred) and an ``attrs.suggested_id`` when one can be
17
+ proposed; ``spec_schema`` reports it as ``requirement.missing_id``.
18
+ * **Interface ports** -- one :class:`PortEntity` per signal of the interface table(s),
19
+ keyed ``port:spec.<block>.<signal>`` (the ``spec`` part keeps them apart from RTL
20
+ ports for the M1-07 ``ports_diff``).
21
+ * **Registers and fields** -- from the register-map table, with a ``contains`` relation
22
+ from each register to its fields.
23
+ * **Open items** -- dependency rows of the "Requirements on other owners" table and
24
+ prose ``Open:`` lines / ``Open items:`` bullet lists.
25
+
26
+ The block name is *not* emitted as an entity here: the contract format adapter owns the
27
+ ``block:<b>`` entity, and emitting one would conflict on merge.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import hashlib
33
+ import re
34
+ from collections.abc import Iterable, Mapping
35
+ from dataclasses import dataclass, field
36
+ from pathlib import Path
37
+ from typing import Literal
38
+
39
+ from chipgraph.core.config.models import RequirementsCfg
40
+ from chipgraph.core.model import (
41
+ DesignModel,
42
+ EntityBase,
43
+ FieldEntity,
44
+ JSONValue,
45
+ OpenItemEntity,
46
+ PortEntity,
47
+ RegisterEntity,
48
+ Relation,
49
+ RequirementEntity,
50
+ make_key,
51
+ )
52
+ from chipgraph.core.model.provenance import Provenance
53
+ from chipgraph.packs.spec_core.extract import _markdown as md
54
+ from chipgraph.packs.spec_core.extract import _text as tx
55
+
56
+ _EXTRACTOR = "mas-markdown"
57
+
58
+ Severity = Literal["error", "warning", "info"]
59
+
60
+ _DIRECTIONS: dict[str, Literal["input", "output", "inout"]] = {
61
+ "in": "input",
62
+ "input": "input",
63
+ "out": "output",
64
+ "output": "output",
65
+ "inout": "inout",
66
+ "io": "inout",
67
+ }
68
+ _DEFAULT_ACCESS_MODES = ("RW", "RO", "WO", "W1C", "RSVD")
69
+ """Access modes accepted by default; kept in sync with ``SpecCfg.register_access``."""
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class MasDiagnostic:
74
+ """A problem found while extracting, with file:line context. Never raised."""
75
+
76
+ file: str | None
77
+ line: int | None
78
+ severity: Severity
79
+ code: str
80
+ message: str
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class MasTemplate:
85
+ """Section titles the extractor looks for, defaulting to the QNSC MAS template.
86
+
87
+ Titles are matched against a heading's number-stripped title, case-insensitive, at
88
+ any ``#`` level.
89
+ """
90
+
91
+ interface: str = "Interface"
92
+ registers: str = "Register map"
93
+ open_items: str = "Requirements on others, and open items"
94
+
95
+
96
+ # Frozen, immutable defaults for the public API (avoids a call in a default argument).
97
+ _DEFAULT_REQUIREMENTS = RequirementsCfg()
98
+ _DEFAULT_TEMPLATE = MasTemplate()
99
+
100
+
101
+ @dataclass(slots=True)
102
+ class _Builder:
103
+ """Accumulates entities, relations and diagnostics during one extraction."""
104
+
105
+ file: str | None
106
+ artifact: str | None
107
+ entities: list[EntityBase] = field(default_factory=list)
108
+ relations: list[Relation] = field(default_factory=list)
109
+ diagnostics: list[MasDiagnostic] = field(default_factory=list)
110
+
111
+ def prov(self, line: int | None) -> Provenance:
112
+ return Provenance(file=self.file, line=line, extractor=_EXTRACTOR, artifact=self.artifact)
113
+
114
+ def diag(self, line: int | None, severity: Severity, code: str, message: str) -> None:
115
+ self.diagnostics.append(MasDiagnostic(self.file, line, severity, code, message))
116
+
117
+
118
+ class MasExtractor:
119
+ """Extracts Design Model facts from a QNSC-style MAS Markdown file."""
120
+
121
+ name = "mas-markdown"
122
+
123
+ def extract(self, path: Path) -> Iterable[Mapping[str, object]]:
124
+ """The ``Extractor`` protocol: every entity and relation as a plain mapping.
125
+
126
+ The block is guessed from the file name (``QNSC_<BLOCK>_MAS.md`` -> ``<BLOCK>``),
127
+ falling back to ``"unknown"``. Callers that know the block and its config should
128
+ use :meth:`extract_model` instead.
129
+ """
130
+ block = _block_from_filename(path) or "unknown"
131
+ model, _ = self.extract_model(path, block=block)
132
+ facts: list[Mapping[str, object]] = [
133
+ model.entities[key].model_dump(mode="json") for key in sorted(model.entities)
134
+ ]
135
+ facts.extend(r.model_dump(mode="json") for r in model.relations)
136
+ return facts
137
+
138
+ @classmethod
139
+ def extract_model(
140
+ cls,
141
+ path: Path,
142
+ *,
143
+ block: str,
144
+ requirements: RequirementsCfg = _DEFAULT_REQUIREMENTS,
145
+ root: Path | None = None,
146
+ template: MasTemplate = _DEFAULT_TEMPLATE,
147
+ access_modes: tuple[str, ...] = _DEFAULT_ACCESS_MODES,
148
+ ) -> tuple[DesignModel, tuple[MasDiagnostic, ...]]:
149
+ """Extract a :class:`DesignModel` from the MAS at ``path`` for ``block``.
150
+
151
+ Args:
152
+ path: The MAS Markdown file.
153
+ block: The block name (lower/upper filled into ``id_pattern``, and used in keys).
154
+ requirements: How REQ-IDs are found (declared always, inferred if configured).
155
+ root: Repo root; provenance ``file`` is written relative to it, POSIX.
156
+ template: Section titles to look for.
157
+ access_modes: The register access modes a cell may use (case-insensitive); any
158
+ other mode is an ``error``. Defaults to the built-in set.
159
+
160
+ Returns:
161
+ ``(model, diagnostics)``. Diagnostics are never raised; the model always holds
162
+ whatever could be read.
163
+ """
164
+ text = path.read_text(encoding="utf-8")
165
+ artifact = hashlib.sha256(text.encode("utf-8")).hexdigest()
166
+ rel_file = _display_path(path, root)
167
+ builder = _Builder(file=rel_file, artifact=artifact)
168
+ doc = md.scan(text)
169
+
170
+ allowed_access = frozenset(mode.upper() for mode in access_modes)
171
+ block_lower = block.lower()
172
+ block_key = make_key("block", block_lower)
173
+
174
+ _extract_requirements(builder, doc, block_lower, block_key, requirements)
175
+ _extract_ports(builder, doc, block_lower, block_key, template)
176
+ _extract_registers(builder, doc, block_lower, block_key, template, allowed_access)
177
+ _extract_open_items(builder, doc, block_lower, template)
178
+
179
+ model = DesignModel.build(builder.entities, builder.relations)
180
+ return model, tuple(builder.diagnostics)
181
+
182
+
183
+ # --- requirements -----------------------------------------------------------------
184
+
185
+
186
+ def _extract_requirements(
187
+ builder: _Builder,
188
+ doc: md.Document,
189
+ block: str,
190
+ block_key: str,
191
+ cfg: RequirementsCfg,
192
+ ) -> None:
193
+ id_regex = cfg.id_regex(block)
194
+ declared: dict[str, int] = {} # ID -> first source line
195
+
196
+ # Declared REQ-IDs: first token of a list item, a paragraph, a heading, or the first
197
+ # cell of a table row.
198
+ for item in doc.all_list_items:
199
+ _try_declare(
200
+ builder,
201
+ block_key,
202
+ id_regex,
203
+ declared,
204
+ tx.strip_leading_number(item.text),
205
+ item.text,
206
+ item.line,
207
+ )
208
+ for section in doc.sections:
209
+ if section.heading is not None:
210
+ _try_declare(
211
+ builder,
212
+ block_key,
213
+ id_regex,
214
+ declared,
215
+ section.heading.raw_title,
216
+ section.heading.raw_title,
217
+ section.heading.line,
218
+ )
219
+ for paragraph in section.paragraphs:
220
+ _try_declare(
221
+ builder,
222
+ block_key,
223
+ id_regex,
224
+ declared,
225
+ paragraph.text,
226
+ paragraph.text,
227
+ paragraph.line,
228
+ )
229
+ for table in doc.all_tables:
230
+ for row in table.rows:
231
+ if row.cells:
232
+ _try_declare(
233
+ builder, block_key, id_regex, declared, row.cells[0], row.cells[0], row.line
234
+ )
235
+
236
+ has_declared = bool(declared)
237
+ if not has_declared and cfg.infer != "verification":
238
+ return
239
+
240
+ # Each top-level numbered item of the infer_heading section with no ID of its own:
241
+ # in a file that declares IDs it is a requirement *missing* its ID; otherwise, in the
242
+ # `infer: verification` mode, an *inferred* requirement (D37).
243
+ suggester = _IdSuggester(declared, id_regex) if has_declared else None
244
+ mention = _mention_regex(id_regex)
245
+ hashed: dict[str, int] = {} # content-hash key -> first source line
246
+ for section in md.iter_sections_by_title(doc, cfg.infer_heading):
247
+ top_indent = _min_ordered_indent(section.list_items)
248
+ for item in section.list_items:
249
+ if not item.ordered or item.indent != top_indent:
250
+ continue
251
+ token = tx.strip_markup_token(tx.first_token(tx.strip_leading_number(item.text)))
252
+ if token and id_regex.fullmatch(token):
253
+ continue # declared wins; already emitted above
254
+ if suggester is not None and not mention.search(item.text):
255
+ _emit_hashed(builder, block, block_key, item, hashed, "missing", suggester)
256
+ elif cfg.infer == "verification":
257
+ _emit_hashed(builder, block, block_key, item, hashed, "inferred", None)
258
+
259
+
260
+ def _try_declare(
261
+ builder: _Builder,
262
+ block_key: str,
263
+ id_regex: re.Pattern[str],
264
+ declared: dict[str, int],
265
+ head: str,
266
+ full_text: str,
267
+ line: int,
268
+ ) -> None:
269
+ token = tx.strip_markup_token(tx.first_token(head))
270
+ if not token or not id_regex.fullmatch(token):
271
+ return
272
+ if token in declared:
273
+ builder.diag(
274
+ line,
275
+ "error",
276
+ "req.duplicate",
277
+ f"requirement {token!r} declared again (first at line {declared[token]})",
278
+ )
279
+ return
280
+ declared[token] = line
281
+ text = _requirement_text(full_text, token)
282
+ builder.entities.append(
283
+ RequirementEntity(
284
+ key=make_key("requirement", token),
285
+ name=token,
286
+ text=text or None,
287
+ source=builder.prov(line),
288
+ attrs={"id_source": "declared", "block": block_key},
289
+ )
290
+ )
291
+
292
+
293
+ def _emit_hashed(
294
+ builder: _Builder,
295
+ block: str,
296
+ block_key: str,
297
+ item: md.ListItem,
298
+ hashed: dict[str, int],
299
+ id_source: Literal["inferred", "missing"],
300
+ suggester: _IdSuggester | None,
301
+ ) -> None:
302
+ """Emit a Verification item with no ID, keyed by block and content hash (D37).
303
+
304
+ ``id_source`` is ``"missing"`` (the file declares IDs and this item neither starts
305
+ with nor names one) or ``"inferred"`` (any other item without an ID of its own, in the
306
+ ``infer: verification`` mode). A ``missing`` item carries ``attrs.suggested_id`` when
307
+ ``suggester`` can propose one.
308
+ """
309
+ normalized = tx.normalize_item_text(item.text)
310
+ digest = tx.content_hash8(normalized)
311
+ key = make_key("requirement", f"{block}.h{digest}")
312
+ if key in hashed:
313
+ # Same text, same key: a second copy would conflict in the model (D37 keys by text).
314
+ builder.diag(
315
+ item.line,
316
+ "warning",
317
+ "req.duplicate_text",
318
+ f"verification item repeats the text of line {hashed[key]}; not read twice",
319
+ )
320
+ return
321
+ hashed[key] = item.line
322
+ attrs: dict[str, JSONValue] = {"id_source": id_source, "block": block_key}
323
+ if suggester is not None:
324
+ suggested = suggester.next()
325
+ if suggested is not None:
326
+ attrs["suggested_id"] = suggested
327
+ builder.entities.append(
328
+ RequirementEntity(
329
+ key=key,
330
+ name=_short_name(normalized),
331
+ text=normalized or None,
332
+ source=builder.prov(item.line),
333
+ attrs=attrs,
334
+ )
335
+ )
336
+
337
+
338
+ def _mention_regex(id_regex: re.Pattern[str]) -> re.Pattern[str]:
339
+ """``id_regex`` anywhere in a text, as a whole word (``-`` counts as a word character).
340
+
341
+ A Verification item that names an ID anywhere (``1. Count load (`REQ-TIM-002`): ...``)
342
+ is tied to that requirement, so it is not reported as missing its ID.
343
+ """
344
+ return re.compile(rf"(?<![\w-])(?:{id_regex.pattern})(?![\w-])")
345
+
346
+
347
+ _ID_NUMBER = re.compile(r"^(.*?)(\d+)$")
348
+
349
+
350
+ class _IdSuggester:
351
+ """Proposes the next free requirement ID of a file, in line order.
352
+
353
+ From the file's declared IDs it takes the prefix family that fits ``<prefix><digits>``
354
+ (the most frequent prefix; ties go to the family with the higher number), keeps its
355
+ zero-padding width and proposes ``max + 1``, then ``max + 2``, ... on each call, so
356
+ suggestions within a file never collide. A suggestion that does not fully match the
357
+ block's ``id_regex`` is withheld (``None``).
358
+ """
359
+
360
+ def __init__(self, declared: Iterable[str], id_regex: re.Pattern[str]) -> None:
361
+ self._id_regex = id_regex
362
+ families: dict[str, list[tuple[int, int]]] = {} # prefix -> [(number, width)]
363
+ for ident in declared:
364
+ match = _ID_NUMBER.match(ident)
365
+ if match:
366
+ digits = match.group(2)
367
+ families.setdefault(match.group(1), []).append((int(digits), len(digits)))
368
+ self._prefix: str | None = None
369
+ self._next = 0
370
+ self._width = 0
371
+ if families:
372
+ prefix, members = max(
373
+ families.items(), key=lambda kv: (len(kv[1]), max(n for n, _ in kv[1]))
374
+ )
375
+ self._prefix = prefix
376
+ self._next = max(n for n, _ in members) + 1
377
+ self._width = max(w for _, w in members)
378
+
379
+ def next(self) -> str | None:
380
+ """The next suggested ID, or ``None`` when no valid one can be proposed."""
381
+ if self._prefix is None:
382
+ return None
383
+ candidate = f"{self._prefix}{self._next:0{self._width}d}"
384
+ self._next += 1
385
+ return candidate if self._id_regex.fullmatch(candidate) else None
386
+
387
+
388
+ def _requirement_text(full_text: str, token: str) -> str:
389
+ """The requirement body: the item minus a leading number and the ID token."""
390
+ body = tx.strip_leading_number(full_text).strip()
391
+ # Drop the leading ID token (possibly wrapped in `` ` `` / ``**``).
392
+ first = tx.first_token(body)
393
+ if tx.strip_markup_token(first) == token:
394
+ body = body[len(first) :].strip()
395
+ return tx.collapse_whitespace(body)
396
+
397
+
398
+ def _min_ordered_indent(items: Iterable[md.ListItem]) -> int:
399
+ indents = [i.indent for i in items if i.ordered]
400
+ return min(indents) if indents else 0
401
+
402
+
403
+ # --- interface ports --------------------------------------------------------------
404
+
405
+
406
+ def _extract_ports(
407
+ builder: _Builder,
408
+ doc: md.Document,
409
+ block: str,
410
+ block_key: str,
411
+ template: MasTemplate,
412
+ ) -> None:
413
+ seen: set[str] = set()
414
+ for section in md.iter_sections_by_title(doc, template.interface):
415
+ for table in section.tables:
416
+ cols = _column_index(table.headers)
417
+ if not {"signal", "dir", "width"} <= set(cols):
418
+ continue # not an interface table (memory map, etc.)
419
+ _ports_from_table(builder, table, cols, block, block_key, seen)
420
+
421
+
422
+ def _ports_from_table(
423
+ builder: _Builder,
424
+ table: md.Table,
425
+ cols: dict[str, int],
426
+ block: str,
427
+ block_key: str,
428
+ seen: set[str],
429
+ ) -> None:
430
+ ncols = len(table.headers)
431
+ table_keys: set[str] = set()
432
+ for row in table.rows:
433
+ if len(row.cells) != ncols:
434
+ builder.diag(
435
+ row.line,
436
+ "error",
437
+ "table.row_width",
438
+ f"row has {len(row.cells)} cells, header has {ncols}",
439
+ )
440
+ continue
441
+ signal_cell = row.cells[cols["signal"]]
442
+ dir_cell = row.cells[cols["dir"]]
443
+ width_cell = row.cells[cols["width"]]
444
+ desc = row.cells[cols["description"]] if "description" in cols else ""
445
+ signals = _expand_signals(builder, signal_cell, row.line)
446
+ if not signals:
447
+ continue
448
+ directions = _split_multi(dir_cell, len(signals))
449
+ widths = _split_multi(width_cell, len(signals))
450
+ for idx, signal in enumerate(signals):
451
+ direction = _parse_direction(builder, directions[idx], row.line)
452
+ width = _parse_width(widths[idx])
453
+ key = make_key("port", "spec", block, signal)
454
+ if key in seen:
455
+ # Twice in one table is a template error. In two tables it is usually the
456
+ # same signal on two modules of the block (a wrapper and its core); the
457
+ # first row is kept.
458
+ if key in table_keys:
459
+ builder.diag(
460
+ row.line, "error", "port.duplicate", f"port {signal!r} listed twice"
461
+ )
462
+ else:
463
+ builder.diag(
464
+ row.line,
465
+ "info",
466
+ "port.repeated",
467
+ f"port {signal!r} also in an earlier interface table; first row kept",
468
+ )
469
+ continue
470
+ seen.add(key)
471
+ table_keys.add(key)
472
+ builder.entities.append(
473
+ PortEntity(
474
+ key=key,
475
+ name=signal,
476
+ direction=direction,
477
+ width=width,
478
+ module=None,
479
+ source=builder.prov(row.line),
480
+ attrs={
481
+ "block": block_key,
482
+ "origin": "spec",
483
+ **({"description": desc} if desc else {}),
484
+ },
485
+ )
486
+ )
487
+
488
+
489
+ def _expand_signals(builder: _Builder, cell: str, line: int) -> list[str]:
490
+ """Split a signal cell into individual signal names, resolving shorthands.
491
+
492
+ Handles ``` `a`, `b`, `c` ``` (several signals in one cell) and a single signal. A
493
+ fragment that starts with ``_`` is a shorthand for the most recent full name in the
494
+ same cell with its last ``_<segment>`` replaced: after ``o_bus_axi_aw_len`` the
495
+ fragment ``_size`` means ``o_bus_axi_aw_size``. A leading ``_`` with no full name
496
+ before it in the cell is an ``error`` at the line. A fragment containing ``*`` is a
497
+ wildcard, not a port: it yields an ``info`` diagnostic and no entity. Other
498
+ non-identifier fragments are dropped silently (e.g. a stray note).
499
+ """
500
+ cleaned = tx.strip_cell_markup(cell)
501
+ if not cleaned:
502
+ return []
503
+ signals: list[str] = []
504
+ previous: str | None = None
505
+ for raw in cleaned.split(","):
506
+ part = raw.strip()
507
+ if not part:
508
+ continue
509
+ if "*" in part:
510
+ builder.diag(
511
+ line,
512
+ "info",
513
+ "port.wildcard",
514
+ f"signal {part!r} is a wildcard, not a port; no entity",
515
+ )
516
+ continue
517
+ if part.startswith("_"):
518
+ resolved = _resolve_shorthand(previous, part)
519
+ if resolved is None:
520
+ builder.diag(
521
+ line,
522
+ "error",
523
+ "port.shorthand",
524
+ f"shorthand {part!r} has no full signal name before it in the cell",
525
+ )
526
+ continue
527
+ signals.append(resolved)
528
+ previous = resolved
529
+ continue
530
+ if tx.is_identifier(part):
531
+ signals.append(part)
532
+ previous = part
533
+ return signals
534
+
535
+
536
+ def _resolve_shorthand(previous: str | None, shorthand: str) -> str | None:
537
+ """Resolve ``_<segment>`` against ``previous`` by replacing its last ``_<segment>``.
538
+
539
+ ``o_bus_axi_aw_len`` + ``_size`` -> ``o_bus_axi_aw_size``. Returns ``None`` when there
540
+ is no previous full name.
541
+ """
542
+ if previous is None:
543
+ return None
544
+ prefix, sep, _last = previous.rpartition("_")
545
+ if not sep:
546
+ return None
547
+ return f"{prefix}{shorthand}"
548
+
549
+
550
+ def _split_multi(cell: str, count: int) -> list[str]:
551
+ """Distribute a Dir/Width cell across ``count`` signals.
552
+
553
+ ``1 each`` and a single value apply to every signal; a comma-separated list maps one
554
+ value per signal (padded with the last value if short).
555
+ """
556
+ value = tx.strip_cell_markup(cell)
557
+ lowered = value.lower()
558
+ if lowered.endswith(" each"):
559
+ base = value[: -len(" each")].strip()
560
+ return [base] * count
561
+ if "," in value:
562
+ parts = [p.strip() for p in value.split(",")]
563
+ if len(parts) < count:
564
+ parts += [parts[-1]] * (count - len(parts))
565
+ return parts[:count]
566
+ return [value] * count
567
+
568
+
569
+ def _parse_direction(
570
+ builder: _Builder, cell: str, line: int
571
+ ) -> Literal["input", "output", "inout"] | None:
572
+ value = tx.strip_cell_markup(cell).lower()
573
+ if value in _DIRECTIONS:
574
+ return _DIRECTIONS[value]
575
+ builder.diag(line, "error", "port.direction", f"unknown port direction {cell.strip()!r}")
576
+ return None
577
+
578
+
579
+ def _parse_width(cell: str) -> int | str | None:
580
+ value = tx.strip_cell_markup(cell)
581
+ if not value:
582
+ return None
583
+ if value.isdigit():
584
+ return int(value)
585
+ return value
586
+
587
+
588
+ # --- registers and fields ---------------------------------------------------------
589
+
590
+
591
+ def _extract_registers(
592
+ builder: _Builder,
593
+ doc: md.Document,
594
+ block: str,
595
+ block_key: str,
596
+ template: MasTemplate,
597
+ allowed_access: frozenset[str],
598
+ ) -> None:
599
+ reg_names: dict[str, int] = {} # register name -> first line, per block
600
+ for section in md.iter_sections_by_title(doc, template.registers):
601
+ for table in section.tables:
602
+ cols = _column_index(table.headers)
603
+ colset = set(cols)
604
+ has_full = {"register", "field", "bits", "access"} <= colset
605
+ has_short = (
606
+ {"offset", "register", "access", "reset"} <= colset
607
+ and "field" not in cols
608
+ and "bits" not in cols
609
+ )
610
+ if has_full:
611
+ _registers_from_table(
612
+ builder,
613
+ table,
614
+ cols,
615
+ block,
616
+ block_key,
617
+ reg_names,
618
+ allowed_access,
619
+ with_fields=True,
620
+ )
621
+ elif has_short:
622
+ _registers_from_table(
623
+ builder,
624
+ table,
625
+ cols,
626
+ block,
627
+ block_key,
628
+ reg_names,
629
+ allowed_access,
630
+ with_fields=False,
631
+ )
632
+ elif "offset" in colset:
633
+ builder.diag(
634
+ table.header_line,
635
+ "warning",
636
+ "register.table_form",
637
+ "register table not in template form; no registers read from it",
638
+ )
639
+ else:
640
+ # No Offset column: a memory map or a field sub-table, not a register map.
641
+ builder.diag(
642
+ table.header_line,
643
+ "info",
644
+ "register.other_table",
645
+ "table in the register section without an Offset column; skipped",
646
+ )
647
+
648
+
649
+ def _registers_from_table(
650
+ builder: _Builder,
651
+ table: md.Table,
652
+ cols: dict[str, int],
653
+ block: str,
654
+ block_key: str,
655
+ reg_names: dict[str, int],
656
+ allowed_access: frozenset[str],
657
+ *,
658
+ with_fields: bool,
659
+ ) -> None:
660
+ ncols = len(table.headers)
661
+ current_reg_key: str | None = None
662
+ for row in table.rows:
663
+ if len(row.cells) != ncols:
664
+ builder.diag(
665
+ row.line,
666
+ "error",
667
+ "table.row_width",
668
+ f"row has {len(row.cells)} cells, header has {ncols}",
669
+ )
670
+ continue
671
+ offset_cell = row.cells[cols["offset"]] if "offset" in cols else ""
672
+ register_cell = row.cells[cols["register"]]
673
+ reg_name = tx.strip_cell_markup(register_cell)
674
+
675
+ is_continuation = with_fields and not tx.strip_cell_markup(offset_cell) and not reg_name
676
+ if is_continuation:
677
+ if current_reg_key is not None:
678
+ _emit_field(
679
+ builder, table, cols, row, current_reg_key, block, block_key, allowed_access
680
+ )
681
+ continue
682
+
683
+ # A reserved / array / range row: no register entity.
684
+ if _is_reserved_offset(offset_cell) or not tx.is_identifier(register_cell):
685
+ builder.diag(
686
+ row.line,
687
+ "info",
688
+ "register.reserved",
689
+ f"reserved or non-register row {reg_name or '--'!r}; no register entity",
690
+ )
691
+ current_reg_key = None
692
+ continue
693
+
694
+ if reg_name in reg_names:
695
+ builder.diag(
696
+ row.line,
697
+ "error",
698
+ "register.duplicate",
699
+ f"register {reg_name!r} defined again (first at line {reg_names[reg_name]})",
700
+ )
701
+ continue
702
+ reg_names[reg_name] = row.line
703
+ offset = tx.parse_int_maybe(offset_cell)
704
+ access, access_note = _parse_access(builder, row.cells, cols, row.line, allowed_access)
705
+ reset = _parse_reset(row.cells, cols)
706
+ reg_key = make_key("register", block, reg_name)
707
+ attrs: dict[str, JSONValue] = {}
708
+ if access_note:
709
+ attrs["access_note"] = access_note
710
+ builder.entities.append(
711
+ RegisterEntity(
712
+ key=reg_key,
713
+ name=reg_name,
714
+ block=block_key,
715
+ offset=offset,
716
+ reset_value=reset,
717
+ access=access,
718
+ source=builder.prov(row.line),
719
+ attrs=attrs,
720
+ )
721
+ )
722
+ current_reg_key = reg_key
723
+ if with_fields:
724
+ _emit_field(builder, table, cols, row, reg_key, block, block_key, allowed_access)
725
+
726
+
727
+ def _emit_field(
728
+ builder: _Builder,
729
+ table: md.Table,
730
+ cols: dict[str, int],
731
+ row: md.TableRow,
732
+ reg_key: str,
733
+ block: str,
734
+ block_key: str,
735
+ allowed_access: frozenset[str],
736
+ ) -> None:
737
+ if "field" not in cols:
738
+ return
739
+ field_cell = row.cells[cols["field"]]
740
+ field_name = tx.strip_cell_markup(field_cell)
741
+ if not tx.is_identifier(field_cell):
742
+ builder.diag(
743
+ row.line,
744
+ "info",
745
+ "field.reserved",
746
+ f"reserved or non-field cell {field_name or '--'!r}; no field entity",
747
+ )
748
+ return
749
+ bits = tx.parse_bits(row.cells[cols["bits"]]) if "bits" in cols else None
750
+ msb, lsb = (bits[0], bits[1]) if bits is not None else (None, None)
751
+ access, access_note = _parse_access(builder, row.cells, cols, row.line, allowed_access)
752
+ reset = _parse_reset(row.cells, cols)
753
+ _, reg_name = reg_key.split(":", 1)
754
+ reg_name = reg_name.split(".", 1)[1]
755
+ field_key = make_key("field", block, reg_name, field_name)
756
+ attrs: dict[str, JSONValue] = {}
757
+ if access_note:
758
+ attrs["access_note"] = access_note
759
+ builder.entities.append(
760
+ FieldEntity(
761
+ key=field_key,
762
+ name=field_name,
763
+ register=reg_key,
764
+ msb=msb,
765
+ lsb=lsb,
766
+ access=access,
767
+ reset_value=reset,
768
+ source=builder.prov(row.line),
769
+ attrs=attrs,
770
+ )
771
+ )
772
+ builder.relations.append(
773
+ Relation(kind="contains", src=reg_key, dst=field_key, source=builder.prov(row.line))
774
+ )
775
+
776
+
777
+ def _is_reserved_offset(cell: str) -> bool:
778
+ """True for an offset range (``0x28``-``0x3C``) or an ``--`` placeholder."""
779
+ value = tx.strip_cell_markup(cell)
780
+ if value in ("", "--", "—"):
781
+ return True
782
+ # Ranges: two hex offsets joined by - or -- (already markup-stripped).
783
+ return bool(re.fullmatch(r"0x[0-9a-fA-F_]+\s*-{1,2}\s*0x[0-9a-fA-F_]+", value))
784
+
785
+
786
+ def _parse_access(
787
+ builder: _Builder,
788
+ cells: tuple[str, ...],
789
+ cols: dict[str, int],
790
+ line: int,
791
+ allowed_access: frozenset[str],
792
+ ) -> tuple[str | None, str | None]:
793
+ if "access" not in cols:
794
+ return None, None
795
+ raw = tx.strip_cell_markup(cells[cols["access"]])
796
+ if not raw or raw in ("--", "—"):
797
+ return None, None
798
+ head, _, rest = raw.partition(",")
799
+ kind = head.strip().upper()
800
+ if kind not in allowed_access:
801
+ builder.diag(line, "error", "register.access", f"unknown access mode {head.strip()!r}")
802
+ return None, None
803
+ note = rest.strip() or None
804
+ return kind.lower(), note
805
+
806
+
807
+ def _parse_reset(cells: tuple[str, ...], cols: dict[str, int]) -> int | str | None:
808
+ if "reset" not in cols:
809
+ return None
810
+ return tx.parse_int_maybe(cells[cols["reset"]])
811
+
812
+
813
+ # --- open items -------------------------------------------------------------------
814
+
815
+ _DEP_HEADER = ("item", "owner", "what it blocks")
816
+ _OPEN_PROSE = re.compile(r"^\s*(?:\*\*\s*)?open\s*:?\s*", re.IGNORECASE)
817
+ _OPEN_LIST_LEAD = re.compile(r"^\s*open items\b", re.IGNORECASE)
818
+ _OPEN_NONE = re.compile(r"^\s*open items\s*:?\s*none\.?\s*$", re.IGNORECASE)
819
+
820
+
821
+ def _extract_open_items(
822
+ builder: _Builder, doc: md.Document, block: str, template: MasTemplate
823
+ ) -> None:
824
+ seen: set[str] = set()
825
+ for section in md.iter_sections_by_title(doc, template.open_items):
826
+ _dependency_rows(builder, section, block, seen)
827
+ _prose_open_items(builder, section, block, seen)
828
+
829
+
830
+ def _dependency_rows(builder: _Builder, section: md.Section, block: str, seen: set[str]) -> None:
831
+ for table in section.tables:
832
+ cols = _column_index(table.headers)
833
+ if not {"item", "owner", "what it blocks"} <= set(cols):
834
+ continue
835
+ ncols = len(table.headers)
836
+ for row in table.rows:
837
+ if len(row.cells) != ncols:
838
+ builder.diag(
839
+ row.line,
840
+ "error",
841
+ "table.row_width",
842
+ f"row has {len(row.cells)} cells, header has {ncols}",
843
+ )
844
+ continue
845
+ item_text = tx.collapse_whitespace(row.cells[cols["item"]])
846
+ if not item_text:
847
+ continue
848
+ owner = tx.collapse_whitespace(row.cells[cols["owner"]])
849
+ blocks = tx.collapse_whitespace(row.cells[cols["what it blocks"]])
850
+ _emit_open_item(
851
+ builder,
852
+ block,
853
+ item_text,
854
+ row.line,
855
+ seen,
856
+ item_type="dependency",
857
+ extra={"owner": owner, "blocks": blocks},
858
+ )
859
+
860
+
861
+ def _prose_open_items(builder: _Builder, section: md.Section, block: str, seen: set[str]) -> None:
862
+ # "Open: ..." / "**Open:** ..." single-line items (from prose).
863
+ for paragraph in section.paragraphs:
864
+ text = paragraph.text
865
+ if _OPEN_NONE.match(text):
866
+ continue
867
+ if _OPEN_PROSE.match(text) and not _OPEN_LIST_LEAD.match(text):
868
+ body = _OPEN_PROSE.sub("", text).strip().rstrip("*").strip()
869
+ if body:
870
+ _emit_open_item(builder, block, body, paragraph.line, seen, item_type="open")
871
+
872
+ # "Open items[, <owner>]:" followed by a bullet list.
873
+ bullet_context = _open_bullet_context(section)
874
+ for item in section.list_items:
875
+ if id(item) in bullet_context:
876
+ body = tx.collapse_whitespace(item.text).rstrip("*").strip()
877
+ if body:
878
+ _emit_open_item(builder, block, body, item.line, seen, item_type="open")
879
+
880
+
881
+ def _open_bullet_context(section: md.Section) -> set[int]:
882
+ """Return ids of the list items that follow an ``Open items:`` prose lead.
883
+
884
+ A bullet list belongs to the open items only when the closest preceding paragraph is
885
+ an ``Open items[, owner]:`` lead (and not ``Accepted limits`` or ``none``).
886
+ """
887
+ leads = [
888
+ p.line
889
+ for p in section.paragraphs
890
+ if _OPEN_LIST_LEAD.match(p.text) and not _OPEN_NONE.match(p.text)
891
+ ]
892
+ if not leads:
893
+ return set()
894
+ stops = sorted(p.line for p in section.paragraphs if not _OPEN_LIST_LEAD.match(p.text))
895
+ result: set[int] = set()
896
+ for lead_line in leads:
897
+ # The bullets governed by this lead run until the next stopping paragraph.
898
+ next_stop = min((s for s in stops if s > lead_line), default=None)
899
+ for item in section.list_items:
900
+ if item.line > lead_line and (next_stop is None or item.line < next_stop):
901
+ result.add(id(item))
902
+ return result
903
+
904
+
905
+ def _emit_open_item(
906
+ builder: _Builder,
907
+ block: str,
908
+ text: str,
909
+ line: int,
910
+ seen: set[str],
911
+ *,
912
+ item_type: str,
913
+ extra: Mapping[str, JSONValue] | None = None,
914
+ ) -> None:
915
+ normalized = tx.collapse_whitespace(text)
916
+ digest = tx.content_hash8(normalized)
917
+ key = make_key("open_item", f"{block}.h{digest}")
918
+ if key in seen:
919
+ return
920
+ seen.add(key)
921
+ attrs: dict[str, JSONValue] = {"type": item_type}
922
+ if extra:
923
+ attrs.update(extra)
924
+ builder.entities.append(
925
+ OpenItemEntity(
926
+ key=key,
927
+ name=_short_name(normalized),
928
+ status="open",
929
+ source=builder.prov(line),
930
+ attrs=attrs,
931
+ )
932
+ )
933
+
934
+
935
+ # --- shared helpers ---------------------------------------------------------------
936
+
937
+
938
+ def _column_index(headers: Iterable[str]) -> dict[str, int]:
939
+ """Map a lower-cased, markup-stripped header name to its column index."""
940
+ index: dict[str, int] = {}
941
+ for i, header in enumerate(headers):
942
+ name = tx.strip_cell_markup(header).lower()
943
+ if name and name not in index:
944
+ index[name] = i
945
+ return index
946
+
947
+
948
+ def _short_name(text: str, limit: int = 60) -> str:
949
+ """A short human name: the first ~``limit`` chars of ``text``."""
950
+ collapsed = tx.collapse_whitespace(text)
951
+ if len(collapsed) <= limit:
952
+ return collapsed
953
+ return collapsed[:limit].rstrip()
954
+
955
+
956
+ def _block_from_filename(path: Path) -> str | None:
957
+ """Guess the block from ``QNSC_<BLOCK>_MAS.md`` / ``<BLOCK>_MAS.md`` file names."""
958
+ stem = path.stem
959
+ match = re.fullmatch(r"(?:QNSC_)?(.+?)_MAS", stem)
960
+ if match:
961
+ return match.group(1).lower()
962
+ return None
963
+
964
+
965
+ def _display_path(path: Path, root: Path | None) -> str:
966
+ """``path`` relative to ``root`` (POSIX) when under it, else its POSIX form."""
967
+ if root is not None:
968
+ try:
969
+ return path.resolve().relative_to(root.resolve()).as_posix()
970
+ except ValueError:
971
+ pass
972
+ return path.as_posix()
973
+
974
+
975
+ __all__ = ["MasDiagnostic", "MasExtractor", "MasTemplate"]