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.
- chipgraph/__init__.py +7 -0
- chipgraph/adapters/__init__.py +1 -0
- chipgraph/adapters/format/__init__.py +6 -0
- chipgraph/adapters/format/__main__.py +16 -0
- chipgraph/adapters/format/_yaml_lines.py +54 -0
- chipgraph/adapters/format/chip_yaml.py +323 -0
- chipgraph/adapters/format/qsoc_contract.py +246 -0
- chipgraph/adapters/llm/__init__.py +58 -0
- chipgraph/adapters/llm/_budget.py +173 -0
- chipgraph/adapters/llm/_errors.py +50 -0
- chipgraph/adapters/llm/_nda.py +30 -0
- chipgraph/adapters/llm/_retry.py +85 -0
- chipgraph/adapters/llm/anthropic.py +247 -0
- chipgraph/adapters/llm/decide_backend.py +179 -0
- chipgraph/adapters/llm/fake.py +87 -0
- chipgraph/adapters/llm/guard.py +68 -0
- chipgraph/adapters/parser/__init__.py +22 -0
- chipgraph/adapters/parser/cocotb.py +304 -0
- chipgraph/adapters/parser/generic_regex.py +75 -0
- chipgraph/adapters/parser/verible.py +55 -0
- chipgraph/adapters/parser/verilator.py +46 -0
- chipgraph/adapters/review/__init__.py +7 -0
- chipgraph/adapters/review/file.py +123 -0
- chipgraph/adapters/review/github.py +886 -0
- chipgraph/adapters/runner/__init__.py +7 -0
- chipgraph/adapters/runner/local.py +122 -0
- chipgraph/adapters/runtime/__init__.py +5 -0
- chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
- chipgraph/adapters/runtime/claude_code/agents.py +158 -0
- chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
- chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
- chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
- chipgraph/adapters/runtime/claude_code/loop.py +342 -0
- chipgraph/adapters/runtime/claude_code/previous.py +419 -0
- chipgraph/adapters/runtime/claude_code/review.py +492 -0
- chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
- chipgraph/adapters/runtime/claude_code/service.py +2285 -0
- chipgraph/adapters/tool/__init__.py +7 -0
- chipgraph/adapters/tool/cmd.py +229 -0
- chipgraph/adapters/tool/edalize.py +773 -0
- chipgraph/adapters/tool/filelist.py +259 -0
- chipgraph/adapters/tool/pyslang.py +485 -0
- chipgraph/adapters/vcs/__init__.py +1 -0
- chipgraph/adapters/vcs/git.py +521 -0
- chipgraph/app/__init__.py +10 -0
- chipgraph/app/baseline.py +339 -0
- chipgraph/app/build.py +332 -0
- chipgraph/app/checks.py +202 -0
- chipgraph/app/context.py +191 -0
- chipgraph/app/errors.py +11 -0
- chipgraph/app/executors.py +79 -0
- chipgraph/app/findings.py +203 -0
- chipgraph/app/ingest.py +523 -0
- chipgraph/checks/__init__.py +52 -0
- chipgraph/checks/_cdc_analyze.py +272 -0
- chipgraph/checks/_cdc_elaborate.py +455 -0
- chipgraph/checks/_common.py +268 -0
- chipgraph/checks/_cross.py +96 -0
- chipgraph/checks/_model.py +64 -0
- chipgraph/checks/_naming_pyslang.py +235 -0
- chipgraph/checks/_naming_rules.py +127 -0
- chipgraph/checks/_xref_syntax.py +260 -0
- chipgraph/checks/cdc_struct.py +235 -0
- chipgraph/checks/connect.py +353 -0
- chipgraph/checks/cross_chip.py +454 -0
- chipgraph/checks/duplicate.py +153 -0
- chipgraph/checks/filelist.py +323 -0
- chipgraph/checks/format.py +268 -0
- chipgraph/checks/generated.py +151 -0
- chipgraph/checks/hardcode.py +233 -0
- chipgraph/checks/layout.py +155 -0
- chipgraph/checks/naming.py +311 -0
- chipgraph/checks/plan_check.py +901 -0
- chipgraph/checks/ports_diff.py +203 -0
- chipgraph/checks/spec_schema.py +277 -0
- chipgraph/checks/style.py +910 -0
- chipgraph/checks/tb_static.py +599 -0
- chipgraph/checks/trace.py +224 -0
- chipgraph/checks/vendor_guard.py +369 -0
- chipgraph/checks/wrap_check.py +486 -0
- chipgraph/cli/__init__.py +1688 -0
- chipgraph/cli/next_steps.py +137 -0
- chipgraph/core/__init__.py +1 -0
- chipgraph/core/config/__init__.py +93 -0
- chipgraph/core/config/defaults.py +14 -0
- chipgraph/core/config/errors.py +32 -0
- chipgraph/core/config/loader.py +766 -0
- chipgraph/core/config/models.py +845 -0
- chipgraph/core/config/templates.py +340 -0
- chipgraph/core/contracts/__init__.py +75 -0
- chipgraph/core/contracts/_paths.py +26 -0
- chipgraph/core/contracts/agent.py +49 -0
- chipgraph/core/contracts/artifact.py +68 -0
- chipgraph/core/contracts/check.py +76 -0
- chipgraph/core/contracts/event.py +70 -0
- chipgraph/core/contracts/export.py +128 -0
- chipgraph/core/contracts/finding.py +171 -0
- chipgraph/core/contracts/gate.py +50 -0
- chipgraph/core/contracts/rule.py +177 -0
- chipgraph/core/contracts/types.py +45 -0
- chipgraph/core/engine/__init__.py +70 -0
- chipgraph/core/engine/agent_rule.py +1362 -0
- chipgraph/core/engine/baseline.py +283 -0
- chipgraph/core/engine/decide.py +462 -0
- chipgraph/core/engine/fanout.py +1358 -0
- chipgraph/core/engine/gate.py +170 -0
- chipgraph/core/engine/graph.py +647 -0
- chipgraph/core/engine/records.py +60 -0
- chipgraph/core/engine/rules.py +92 -0
- chipgraph/core/engine/scheduler.py +637 -0
- chipgraph/core/engine/select.py +1239 -0
- chipgraph/core/engine/targets.py +342 -0
- chipgraph/core/model/__init__.py +98 -0
- chipgraph/core/model/entities.py +244 -0
- chipgraph/core/model/export.py +16 -0
- chipgraph/core/model/ingest.py +693 -0
- chipgraph/core/model/json_value.py +4 -0
- chipgraph/core/model/keys.py +95 -0
- chipgraph/core/model/model.py +153 -0
- chipgraph/core/model/provenance.py +27 -0
- chipgraph/core/model/query.py +583 -0
- chipgraph/core/model/registry.py +80 -0
- chipgraph/core/model/relations.py +61 -0
- chipgraph/core/model/store.py +279 -0
- chipgraph/core/plugin_api/__init__.py +81 -0
- chipgraph/core/plugin_api/local.py +167 -0
- chipgraph/core/plugin_api/pack.py +159 -0
- chipgraph/core/plugin_api/protocols.py +177 -0
- chipgraph/core/plugin_api/registry.py +111 -0
- chipgraph/core/plugin_api/types.py +94 -0
- chipgraph/core/runtime/__init__.py +41 -0
- chipgraph/core/runtime/executor.py +79 -0
- chipgraph/core/runtime/queue.py +565 -0
- chipgraph/core/runtime/roles/__init__.py +91 -0
- chipgraph/core/runtime/roles/data/author.md +41 -0
- chipgraph/core/runtime/roles/data/critic.md +56 -0
- chipgraph/core/runtime/roles/data/planner.md +38 -0
- chipgraph/core/runtime/roles/data/researcher.md +37 -0
- chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
- chipgraph/core/runtime/roles/data/tb-author.md +48 -0
- chipgraph/core/runtime/roles/data/triage.md +34 -0
- chipgraph/core/runtime/roles/policy.py +299 -0
- chipgraph/core/runtime/roles/registry.py +52 -0
- chipgraph/core/runtime/roles/review.py +275 -0
- chipgraph/core/runtime/roles/schema.py +82 -0
- chipgraph/core/runtime/roles/skills.py +134 -0
- chipgraph/core/runtime/roles/spec.py +232 -0
- chipgraph/core/state/__init__.py +1 -0
- chipgraph/core/state/artifacts.py +156 -0
- chipgraph/core/state/backend.py +117 -0
- chipgraph/core/state/findings.py +151 -0
- chipgraph/core/state/handoff.py +411 -0
- chipgraph/core/state/idempotency.py +59 -0
- chipgraph/core/state/journal.py +238 -0
- chipgraph/core/state/layout.py +82 -0
- chipgraph/core/state/lock.py +195 -0
- chipgraph/core/state/trace.py +225 -0
- chipgraph/learn/__init__.py +96 -0
- chipgraph/learn/draft.py +187 -0
- chipgraph/learn/infer.py +580 -0
- chipgraph/learn/models.py +145 -0
- chipgraph/learn/try_run.py +228 -0
- chipgraph/mcp/__init__.py +12 -0
- chipgraph/mcp/model_tools.py +167 -0
- chipgraph/mcp/server.py +787 -0
- chipgraph/orgs/qnsc/naming-v1.yml +113 -0
- chipgraph/orgs/qnsc/profile.yml +5 -0
- chipgraph/packs/__init__.py +8 -0
- chipgraph/packs/assist/__init__.py +16 -0
- chipgraph/packs/assist/ask/__init__.py +58 -0
- chipgraph/packs/assist/ask/_db.py +57 -0
- chipgraph/packs/assist/ask/_project.py +230 -0
- chipgraph/packs/assist/ask/answer.py +266 -0
- chipgraph/packs/assist/ask/check.py +182 -0
- chipgraph/packs/assist/ask/contract.py +115 -0
- chipgraph/packs/assist/ask/documents.py +157 -0
- chipgraph/packs/assist/ask/fts.py +80 -0
- chipgraph/packs/assist/ask/retrieve.py +331 -0
- chipgraph/packs/assist/audit/__init__.py +383 -0
- chipgraph/packs/assist/pack.yml +13 -0
- chipgraph/packs/assist/triage/__init__.py +64 -0
- chipgraph/packs/assist/triage/contract.py +94 -0
- chipgraph/packs/assist/triage/parse.py +371 -0
- chipgraph/packs/assist/triage/question.py +384 -0
- chipgraph/packs/assist/triage/report.py +160 -0
- chipgraph/packs/assist/triage/rules.py +447 -0
- chipgraph/packs/assist/triage/run.py +224 -0
- chipgraph/packs/digital_rtl/__init__.py +19 -0
- chipgraph/packs/digital_rtl/layout.py +144 -0
- chipgraph/packs/digital_rtl/pack.yml +25 -0
- chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
- chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
- chipgraph/packs/digital_rtl/plan/context.py +82 -0
- chipgraph/packs/digital_rtl/plan/expand.py +94 -0
- chipgraph/packs/digital_rtl/plan/model.py +150 -0
- chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
- chipgraph/packs/digital_rtl/plan/show.py +176 -0
- chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
- chipgraph/packs/digital_rtl/pr/body.py +165 -0
- chipgraph/packs/digital_rtl/pr/gate.py +110 -0
- chipgraph/packs/digital_rtl/pr/open.py +397 -0
- chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
- chipgraph/packs/digital_rtl/rtl/context.py +272 -0
- chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
- chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
- chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
- chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
- chipgraph/packs/digital_rtl/rules/review.yml +31 -0
- chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
- chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
- chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
- chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
- chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
- chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
- chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
- chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
- chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
- chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
- chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
- chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
- chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
- chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
- chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
- chipgraph/packs/dv/__init__.py +15 -0
- chipgraph/packs/dv/declaration.py +392 -0
- chipgraph/packs/dv/interface.py +596 -0
- chipgraph/packs/dv/pack.yml +14 -0
- chipgraph/packs/dv/rules/tb_module.yml +42 -0
- chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
- chipgraph/packs/spec_core/__init__.py +9 -0
- chipgraph/packs/spec_core/extract/__init__.py +12 -0
- chipgraph/packs/spec_core/extract/_markdown.py +371 -0
- chipgraph/packs/spec_core/extract/_text.py +116 -0
- chipgraph/packs/spec_core/extract/mas.py +975 -0
- chipgraph/packs/spec_core/gen/__init__.py +11 -0
- chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
- chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
- chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
- chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
- chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
- chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
- chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
- chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
- chipgraph/packs/spec_core/pack.yml +6 -0
- chipgraph/py.typed +0 -0
- chipgraph-0.1.0.dist-info/METADATA +53 -0
- chipgraph-0.1.0.dist-info/RECORD +252 -0
- chipgraph-0.1.0.dist-info/WHEEL +4 -0
- chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
- 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"]
|