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,72 @@
1
+ ---
2
+ id: dv/cocotb
3
+ description: How to write a cocotb 2.1 test module for a block from its spec and interface, never its RTL.
4
+ roles: [tb-author]
5
+ version: 0.1.0
6
+ ---
7
+ Write one cocotb 2.1 test module (`outputs[0]`) that checks the block **against its
8
+ spec**. You see the spec, its requirements and the interface; you never see the RTL, and
9
+ must not guess it.
10
+
11
+ What to use from the context:
12
+
13
+ - `interface.ports`: the only signals you may touch, as `dut.<name>`. Drive inputs
14
+ with `dut.<name>.value = ...`, sample outputs with `int(dut.<name>.value)` (see the
15
+ table below: a one-bit port is not a vector).
16
+ A port marked `rtl differs` or `missing in rtl` disagrees with the design: test what
17
+ the spec says, and say so in `assumptions`.
18
+ - `interface.clock` and `interface.reset` (with `reset_active_low`): start the clock with
19
+ `cocotb.start_soon(Clock(dut.<clock>, 10, unit="ns").start())`, hold reset active for a
20
+ few cycles with every input at 0, then release it.
21
+ - The register bus: the spec's interface table and register map (`registers` in the
22
+ block's model input, the register-map table in the spec text) give the address,
23
+ write-strobe and data ports and each register's offset, access and reset value. Write
24
+ small `write(dut, offset, value)` and `read(dut, offset)` helpers on those ports, and
25
+ name each offset as a constant from the register map.
26
+ - `requirements`: every id must be cited at least once.
27
+
28
+ cocotb 2.x API (checked against cocotb 2.1.0; a name that is not in this table is a
29
+ guess: look for it here first):
30
+
31
+ | You want | Write | Not this |
32
+ |---|---|---|
33
+ | Read any port as a number | `int(dut.x.value)` (raises `ValueError` if a bit is `X`/`Z`) | `dut.x.value.integer` (deprecated) |
34
+ | Read a multi-bit port, explicit | `dut.x.value.to_unsigned()`, `.to_signed()` (a `LogicArray`) | |
35
+ | Read a **one-bit** port | `int(dut.irq.value)` or `dut.irq.value == 1` (its value is a `Logic`) | `dut.irq.value.to_unsigned()`: `Logic` has no such method (`AttributeError`) |
36
+ | Check for `X`/`Z` | `dut.x.value.is_resolvable` | comparing to a string |
37
+ | Compare | `dut.x.value == 0xA5`, `dut.irq.value == 1` | |
38
+ | Write a port | `dut.x.value = 5`; takes effect at the end of the current time step, so read the effect after the next `await RisingEdge(dut.clk)` | writing and reading in the same step |
39
+ | One bit, a slice of a vector | `dut.x.value[0]` is a `Logic`; `dut.x.value[7:4]` is a `LogicArray` | |
40
+ | Build a vector | `from cocotb.types import LogicArray, Range`; `LogicArray.from_unsigned(5, 8)`, `LogicArray(0xA5, Range(7, 0))` | `BinaryValue` / `cocotb.binary` (removed) |
41
+ | Clock | `Clock(dut.clk, 10, unit="ns")`, started with `cocotb.start_soon(clock.start())` | `units=` (deprecated), `cocotb.fork`, `@cocotb.coroutine` (removed) |
42
+ | Wait | `await RisingEdge(dut.clk)`, `await ClockCycles(dut.clk, n)`, `await Timer(10, unit="ns")` | `yield` |
43
+ | Start a task | `task = cocotb.start_soon(coro())`; `await cocotb.start(coro())` runs it now; `task.cancel()` | `cocotb.fork` |
44
+ | Fail a test | a plain `assert cond, "message"` | raising `TestFailure` |
45
+ | Time limit for a test | `@cocotb.test(timeout_time=50, timeout_unit="us")` | a hand-made counter loop |
46
+
47
+ How to write it:
48
+
49
+ - `import cocotb`, `from cocotb.clock import Clock`, `from cocotb.triggers import
50
+ ClockCycles, ReadOnly, RisingEdge, Timer`. Nothing that reads files or runs programs:
51
+ no `open`, `os`, `pathlib`, `glob`, `subprocess`, no path strings. Use no `dut.<name>`
52
+ that is not in `interface.ports`: no internal signal, no `dut._id(...)`, no
53
+ `getattr(dut, ...)` on a computed name.
54
+ - One `@cocotb.test()` `async def` per requirement group (the requirements of one spec
55
+ subsection, or ones that need the same setup). Put `# verifies: REQ-...` (comma
56
+ separated) on the line after the `def`, and name the ids in its docstring.
57
+ - Every `assert` has a message that quotes the spec value it checks, e.g.
58
+ `assert got == 0x3C, f"REQ-GPIO-002: DATA_IN reads pin_in; expected 0x3c, got {got:#x}"`.
59
+ - Reset values: check each register's spec reset value right after reset.
60
+ - Keep each test short and deterministic: fixed stimulus, or `random.Random(<seed>)`
61
+ with a fixed seed; wait a stated number of cycles, never "long enough".
62
+ - Test only behaviour the spec states. Do not assume internals (pipeline depth, state
63
+ names, encodings) the spec does not give.
64
+
65
+ When the spec is ambiguous or silent on something a test needs (a latency, what a
66
+ reserved offset reads, which edge), do not guess: write the tests you can, and report
67
+ `needs_human` with the question.
68
+
69
+ If a redo says a failure in the design was hidden, the test failed against the RTL in a
70
+ way you cannot see. Change the test only where it does not follow the spec; if it does
71
+ follow the spec, report `needs_human` and say which requirement the design seems to
72
+ break.
@@ -0,0 +1,9 @@
1
+ """The ``spec-core`` pack: deterministic extractors for text specifications.
2
+
3
+ This pack ships the ``mas-markdown`` extractor (:mod:`chipgraph.packs.spec_core.extract.mas`),
4
+ which reads QNSC-style Micro-Architecture Specification (MAS) Markdown into the Design
5
+ Model: requirements (declared or inferred, D37), interface ports, register maps with
6
+ fields, and open items. The extractor is registered through the
7
+ ``chipgraph.adapters.extractor`` entry point; the pack directory itself is discovered by
8
+ :func:`chipgraph.app.build.builtin_packs_dir`.
9
+ """
@@ -0,0 +1,12 @@
1
+ """Deterministic extractors for the ``spec-core`` pack.
2
+
3
+ Currently the MAS Markdown extractor, :class:`~chipgraph.packs.spec_core.extract.mas.MasExtractor`.
4
+ """
5
+
6
+ from chipgraph.packs.spec_core.extract.mas import (
7
+ MasDiagnostic,
8
+ MasExtractor,
9
+ MasTemplate,
10
+ )
11
+
12
+ __all__ = ["MasDiagnostic", "MasExtractor", "MasTemplate"]
@@ -0,0 +1,371 @@
1
+ """A small, line-exact Markdown scanner for MAS documents.
2
+
3
+ The scanner keeps every source line with its 1-based number and classifies it so the
4
+ extractor never has to rescan raw text. It skips YAML front matter, HTML comments
5
+ (multi-line; the QNSC template is full of them) and fenced code blocks, but keeps line
6
+ numbers exact so a diagnostic can always cite the real source line.
7
+
8
+ ``<!-- gen:... -->`` and ``<!-- /gen -->`` markers are ordinary comments and are skipped,
9
+ but the table between them is content and is scanned normally.
10
+
11
+ Nothing here knows about ports, registers or requirements: it produces headings, tables,
12
+ list items and paragraphs, and the MAS extractor gives them meaning.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from collections.abc import Iterator, Sequence
19
+ from dataclasses import dataclass, field
20
+
21
+ _HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
22
+ _HEADING_NUMBER = re.compile(r"^\s*\d+(?:\.\d+)*\.?\s+")
23
+ _LIST_ITEM = re.compile(r"^(\s*)(?:[-*+]|(\d+)[.)])\s+(.*)$")
24
+ _TABLE_DIVIDER = re.compile(r"^\s*\|?\s*:?-{1,}:?\s*(?:\|\s*:?-{1,}:?\s*)*\|?\s*$")
25
+ _FRONT_MATTER = "---"
26
+ _FENCE = re.compile(r"^\s*(`{3,}|~{3,})")
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class Heading:
31
+ """A section heading: its raw title, the number-stripped title, and its level."""
32
+
33
+ line: int
34
+ level: int
35
+ raw_title: str
36
+ title: str
37
+ """The title with any leading section number (``5.``, ``7.1``) stripped."""
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class ListItem:
42
+ """A top-level or nested list item, with its continuation lines already joined."""
43
+
44
+ line: int
45
+ indent: int
46
+ ordered: bool
47
+ number: int | None
48
+ text: str
49
+ """The item body (marker removed), continuation lines joined with single spaces."""
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class Paragraph:
54
+ """A run of non-list, non-table prose lines."""
55
+
56
+ line: int
57
+ text: str
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class TableRow:
62
+ """One row of a pipe table: its cells (outer pipes stripped) and its source line."""
63
+
64
+ line: int
65
+ cells: tuple[str, ...]
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class Table:
70
+ """A GitHub-style pipe table: a header row, its column names, and body rows."""
71
+
72
+ header_line: int
73
+ headers: tuple[str, ...]
74
+ header_cells_raw: tuple[str, ...]
75
+ rows: tuple[TableRow, ...]
76
+
77
+
78
+ @dataclass(slots=True)
79
+ class Section:
80
+ """A heading and every block that belongs to it, until the next same-or-higher heading."""
81
+
82
+ heading: Heading | None
83
+ tables: list[Table] = field(default_factory=list)
84
+ list_items: list[ListItem] = field(default_factory=list)
85
+ paragraphs: list[Paragraph] = field(default_factory=list)
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class Document:
90
+ """A scanned MAS document: its sections and every declared block, in source order."""
91
+
92
+ sections: tuple[Section, ...]
93
+ all_tables: tuple[Table, ...]
94
+ all_list_items: tuple[ListItem, ...]
95
+
96
+
97
+ def strip_heading_number(title: str) -> str:
98
+ """Remove a leading section number (``5.``, ``7.1``, ``11.``) from a heading title."""
99
+ return _HEADING_NUMBER.sub("", title).strip()
100
+
101
+
102
+ def split_row(line: str) -> tuple[str, ...]:
103
+ """Split a pipe-table row into trimmed cells, honouring escaped ``\\|``.
104
+
105
+ Outer pipes are stripped. ``\\|`` inside a cell is kept as a literal ``|``.
106
+ """
107
+ body = line.strip()
108
+ if body.startswith("|"):
109
+ body = body[1:]
110
+ if body.endswith("|"):
111
+ body = body[:-1]
112
+ cells: list[str] = []
113
+ current: list[str] = []
114
+ escaped = False
115
+ for ch in body:
116
+ if escaped:
117
+ current.append(ch)
118
+ escaped = False
119
+ elif ch == "\\":
120
+ escaped = True
121
+ current.append(ch)
122
+ elif ch == "|":
123
+ cells.append("".join(current).strip())
124
+ current = []
125
+ else:
126
+ current.append(ch)
127
+ cells.append("".join(current).strip())
128
+ return tuple(cells)
129
+
130
+
131
+ class _LineKind:
132
+ """Internal per-line classification produced by the pre-pass."""
133
+
134
+ __slots__ = ("kind", "line", "text")
135
+
136
+ def __init__(self, line: int, kind: str, text: str) -> None:
137
+ self.line = line
138
+ self.kind = kind # "content" or "skip"
139
+ self.text = text
140
+
141
+
142
+ def _prepass(text: str) -> list[_LineKind]:
143
+ """Classify every physical line as content or skipped, keeping numbers exact."""
144
+ lines = text.splitlines()
145
+ result: list[_LineKind] = []
146
+ in_front_matter = False
147
+ front_matter_possible = True
148
+ in_comment = False
149
+ fence: str | None = None
150
+
151
+ for index, raw in enumerate(lines):
152
+ number = index + 1
153
+ stripped = raw.strip()
154
+
155
+ # YAML front matter: only a leading '---' on line 1 opens it.
156
+ if front_matter_possible and number == 1 and stripped == _FRONT_MATTER:
157
+ in_front_matter = True
158
+ front_matter_possible = False
159
+ result.append(_LineKind(number, "skip", raw))
160
+ continue
161
+ front_matter_possible = False
162
+ if in_front_matter:
163
+ result.append(_LineKind(number, "skip", raw))
164
+ if stripped == _FRONT_MATTER:
165
+ in_front_matter = False
166
+ continue
167
+
168
+ # Fenced code blocks.
169
+ if fence is not None:
170
+ result.append(_LineKind(number, "skip", raw))
171
+ if stripped.startswith(fence):
172
+ fence = None
173
+ continue
174
+
175
+ # HTML comments (may open and close on the same line, or span many lines).
176
+ if in_comment:
177
+ result.append(_LineKind(number, "skip", raw))
178
+ if "-->" in raw:
179
+ in_comment = False
180
+ continue
181
+
182
+ working = raw
183
+ if "<!--" in working:
184
+ # A comment may sit inline; skip the whole physical line for simplicity, and
185
+ # track whether it stays open past this line.
186
+ if "-->" in working[working.index("<!--") :]:
187
+ # Opens and closes on this line: treat the line as skipped content.
188
+ result.append(_LineKind(number, "skip", raw))
189
+ continue
190
+ in_comment = True
191
+ result.append(_LineKind(number, "skip", raw))
192
+ continue
193
+
194
+ fence_match = _FENCE.match(raw)
195
+ if fence_match:
196
+ fence = fence_match.group(1)[0] * 3
197
+ result.append(_LineKind(number, "skip", raw))
198
+ continue
199
+
200
+ result.append(_LineKind(number, "content", raw))
201
+
202
+ return result
203
+
204
+
205
+ def _looks_like_table_header(lines: Sequence[_LineKind], i: int) -> bool:
206
+ """True when line ``i`` is a header row followed by a divider row."""
207
+ if "|" not in lines[i].text:
208
+ return False
209
+ if i + 1 >= len(lines):
210
+ return False
211
+ nxt = lines[i + 1]
212
+ return nxt.kind == "content" and bool(_TABLE_DIVIDER.match(nxt.text))
213
+
214
+
215
+ def scan(text: str) -> Document:
216
+ """Scan MAS Markdown into a :class:`Document`."""
217
+ lines = _prepass(text)
218
+ sections: list[Section] = []
219
+ current = Section(heading=None)
220
+ sections.append(current)
221
+ all_tables: list[Table] = []
222
+ all_items: list[ListItem] = []
223
+
224
+ i = 0
225
+ n = len(lines)
226
+ while i < n:
227
+ entry = lines[i]
228
+ if entry.kind == "skip":
229
+ i += 1
230
+ continue
231
+ raw = entry.text
232
+ stripped = raw.strip()
233
+
234
+ if not stripped:
235
+ i += 1
236
+ continue
237
+
238
+ heading_match = _HEADING.match(raw)
239
+ if heading_match:
240
+ level = len(heading_match.group(1))
241
+ raw_title = heading_match.group(2).strip()
242
+ heading = Heading(
243
+ line=entry.line,
244
+ level=level,
245
+ raw_title=raw_title,
246
+ title=strip_heading_number(raw_title),
247
+ )
248
+ current = Section(heading=heading)
249
+ sections.append(current)
250
+ i += 1
251
+ continue
252
+
253
+ if _looks_like_table_header(lines, i):
254
+ table, consumed = _consume_table(lines, i)
255
+ current.tables.append(table)
256
+ all_tables.append(table)
257
+ i += consumed
258
+ continue
259
+
260
+ item_match = _LIST_ITEM.match(raw)
261
+ if item_match:
262
+ item, consumed = _consume_list_item(lines, i)
263
+ current.list_items.append(item)
264
+ all_items.append(item)
265
+ i += consumed
266
+ continue
267
+
268
+ paragraph, consumed = _consume_paragraph(lines, i)
269
+ current.paragraphs.append(paragraph)
270
+ i += consumed
271
+
272
+ return Document(
273
+ sections=tuple(sections),
274
+ all_tables=tuple(all_tables),
275
+ all_list_items=tuple(all_items),
276
+ )
277
+
278
+
279
+ def _consume_table(lines: Sequence[_LineKind], start: int) -> tuple[Table, int]:
280
+ """Consume a pipe table beginning at ``start`` (header + divider + body rows)."""
281
+ header = lines[start]
282
+ header_cells = split_row(header.text)
283
+ headers = tuple(c.strip() for c in header_cells)
284
+ rows: list[TableRow] = []
285
+ i = start + 2 # skip header and divider
286
+ n = len(lines)
287
+ while i < n:
288
+ entry = lines[i]
289
+ if entry.kind == "skip":
290
+ # A comment or fence inside a table ends it (tables are contiguous).
291
+ break
292
+ if "|" not in entry.text or not entry.text.strip():
293
+ break
294
+ rows.append(TableRow(line=entry.line, cells=split_row(entry.text)))
295
+ i += 1
296
+ table = Table(
297
+ header_line=header.line,
298
+ headers=headers,
299
+ header_cells_raw=header_cells,
300
+ rows=tuple(rows),
301
+ )
302
+ return table, i - start
303
+
304
+
305
+ def _consume_list_item(lines: Sequence[_LineKind], start: int) -> tuple[ListItem, int]:
306
+ """Consume a list item and its continuation lines beginning at ``start``."""
307
+ match = _LIST_ITEM.match(lines[start].text)
308
+ assert match is not None
309
+ indent = len(match.group(1))
310
+ number = int(match.group(2)) if match.group(2) else None
311
+ ordered = number is not None
312
+ parts = [match.group(3).strip()]
313
+ i = start + 1
314
+ n = len(lines)
315
+ while i < n:
316
+ entry = lines[i]
317
+ if entry.kind == "skip":
318
+ break
319
+ raw = entry.text
320
+ if not raw.strip():
321
+ break
322
+ if _LIST_ITEM.match(raw):
323
+ break
324
+ if _HEADING.match(raw):
325
+ break
326
+ if "|" in raw and _looks_like_table_header(lines, i):
327
+ break
328
+ # A continuation line is indented under the item.
329
+ leading = len(raw) - len(raw.lstrip())
330
+ if leading <= indent and not raw.startswith(" "):
331
+ break
332
+ parts.append(raw.strip())
333
+ i += 1
334
+ text = " ".join(p for p in parts if p)
335
+ item = ListItem(
336
+ line=lines[start].line,
337
+ indent=indent,
338
+ ordered=ordered,
339
+ number=number,
340
+ text=text,
341
+ )
342
+ return item, i - start
343
+
344
+
345
+ def _consume_paragraph(lines: Sequence[_LineKind], start: int) -> tuple[Paragraph, int]:
346
+ """Consume a run of prose lines beginning at ``start``."""
347
+ parts = [lines[start].text.strip()]
348
+ i = start + 1
349
+ n = len(lines)
350
+ while i < n:
351
+ entry = lines[i]
352
+ if entry.kind == "skip":
353
+ break
354
+ raw = entry.text
355
+ if not raw.strip():
356
+ break
357
+ if _HEADING.match(raw) or _LIST_ITEM.match(raw):
358
+ break
359
+ if "|" in raw and _looks_like_table_header(lines, i):
360
+ break
361
+ parts.append(raw.strip())
362
+ i += 1
363
+ return Paragraph(line=lines[start].line, text=" ".join(parts)), i - start
364
+
365
+
366
+ def iter_sections_by_title(doc: Document, title: str) -> Iterator[Section]:
367
+ """Yield every section whose number-stripped title equals ``title`` (case-insensitive)."""
368
+ wanted = title.strip().casefold()
369
+ for section in doc.sections:
370
+ if section.heading is not None and section.heading.title.casefold() == wanted:
371
+ yield section
@@ -0,0 +1,116 @@
1
+ """Text helpers shared by the MAS extractor: normalization, hashing, cell parsing.
2
+
3
+ Kept separate from the scanner and the extractor so the rules for turning a cell or an
4
+ item into a value are all in one place and easy to test.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import re
11
+
12
+ _WHITESPACE = re.compile(r"\s+")
13
+ _LEADING_NUMBER = re.compile(r"^\s*\d+(?:\.\d+)*[.)]?\s+")
14
+ _INT_LITERAL = re.compile(r"^[+-]?(?:0[xX][0-9a-fA-F_]+|0[bB][01_]+|[0-9][0-9_]*)$")
15
+ _IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
16
+
17
+
18
+ def collapse_whitespace(text: str) -> str:
19
+ """Collapse every run of whitespace to a single space and strip the ends."""
20
+ return _WHITESPACE.sub(" ", text).strip()
21
+
22
+
23
+ def strip_leading_number(text: str) -> str:
24
+ """Remove a leading ordered-list number (``1.``, ``2)``) from item text."""
25
+ return _LEADING_NUMBER.sub("", text, count=1)
26
+
27
+
28
+ def normalize_item_text(text: str) -> str:
29
+ """Normalize a list item for hashing: drop its number, collapse whitespace.
30
+
31
+ Inline markup (backticks, ``**``) is intentionally kept: two items that differ only
32
+ in emphasis are different requirements.
33
+ """
34
+ return collapse_whitespace(strip_leading_number(text))
35
+
36
+
37
+ def content_hash8(text: str) -> str:
38
+ """The first 8 hex chars of the SHA-256 of ``text``, for a stable inferred key."""
39
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()[:8]
40
+
41
+
42
+ def strip_markup_token(token: str) -> str:
43
+ """Strip surrounding backticks and ``**`` bold markers from a single token."""
44
+ value = token.strip()
45
+ changed = True
46
+ while changed:
47
+ changed = False
48
+ if value.startswith("**") and value.endswith("**") and len(value) >= 4:
49
+ value = value[2:-2]
50
+ changed = True
51
+ if len(value) >= 2 and value.startswith("`") and value.endswith("`"):
52
+ value = value[1:-1]
53
+ changed = True
54
+ return value.strip()
55
+
56
+
57
+ def strip_cell_markup(cell: str) -> str:
58
+ """Remove backticks and bold markers throughout a table cell, then collapse spaces."""
59
+ without = cell.replace("`", "").replace("**", "")
60
+ return collapse_whitespace(without)
61
+
62
+
63
+ def first_token(text: str) -> str:
64
+ """Return the first whitespace-separated token of ``text`` (empty if none)."""
65
+ stripped = text.strip()
66
+ if not stripped:
67
+ return ""
68
+ return stripped.split()[0]
69
+
70
+
71
+ def parse_int_maybe(text: str) -> int | str | None:
72
+ """Parse ``text`` as an int (hex/bin/decimal, ``_`` separators allowed).
73
+
74
+ Returns the int on success, the collapsed string if it is non-empty but not an int,
75
+ and ``None`` if empty.
76
+ """
77
+ value = strip_cell_markup(text)
78
+ if not value:
79
+ return None
80
+ compact = value.replace(" ", "")
81
+ if not _INT_LITERAL.match(compact):
82
+ return value
83
+ cleaned = compact.replace("_", "")
84
+ lowered = cleaned.lstrip("+-").lower()
85
+ try:
86
+ if lowered.startswith("0x"):
87
+ return int(cleaned, 16)
88
+ if lowered.startswith("0b"):
89
+ return int(cleaned, 2)
90
+ return int(cleaned, 10)
91
+ except ValueError: # pragma: no cover - guarded by the regex
92
+ return value
93
+
94
+
95
+ def is_identifier(text: str) -> bool:
96
+ """True when ``text`` (after markup removal) is a single simple identifier."""
97
+ return bool(_IDENTIFIER.match(strip_cell_markup(text)))
98
+
99
+
100
+ def parse_bits(text: str) -> tuple[int, int] | None:
101
+ """Parse a Bits cell (``15:8`` or ``0``) into ``(msb, lsb)``.
102
+
103
+ Returns ``None`` when the cell is not a plain bit or bit range (e.g. ``bits 0-15``).
104
+ """
105
+ value = strip_cell_markup(text)
106
+ if not value:
107
+ return None
108
+ if ":" in value:
109
+ hi, _, lo = value.partition(":")
110
+ if hi.strip().isdigit() and lo.strip().isdigit():
111
+ return int(hi), int(lo)
112
+ return None
113
+ if value.isdigit():
114
+ n = int(value)
115
+ return n, n
116
+ return None