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,693 @@
|
|
|
1
|
+
"""Deterministically merge per-source models into one Design Model (DESIGN.md 4.1-4.5).
|
|
2
|
+
|
|
3
|
+
`ingest` is the pure core of `chipgraph ingest`: it takes the models that the format
|
|
4
|
+
adapters and extractors already built, each tagged with where it came from
|
|
5
|
+
(`SourcePart`), and merges them into a single `DesignModel`, a `build_inputs_hash` and a
|
|
6
|
+
JSON-dumpable `IngestStats`. It never touches disk, never calls a tool, and names no
|
|
7
|
+
chip, bus, PDK or tool: the app layer (`chipgraph.app.ingest`) does all of that and hands
|
|
8
|
+
the results here.
|
|
9
|
+
|
|
10
|
+
The merge is deterministic (independent of the order the parts arrive) and never raises on
|
|
11
|
+
content: two parts that disagree on an entity's content produce one kept entity plus an
|
|
12
|
+
`IngestIssue(code="conflict")`, rather than a `ModelConflict`. Ownership of a module that
|
|
13
|
+
several blocks elaborate (vendor cells, a `top.f` that pulls in every block) is decided by
|
|
14
|
+
a fixed rule, so the same inputs always give the same owner.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Literal
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
25
|
+
|
|
26
|
+
from chipgraph.core.model.entities import BlockEntity, EntityBase, InterruptEntity, ModuleEntity
|
|
27
|
+
from chipgraph.core.model.json_value import JSONValue
|
|
28
|
+
from chipgraph.core.model.model import DesignModel
|
|
29
|
+
from chipgraph.core.model.relations import Relation
|
|
30
|
+
|
|
31
|
+
Severity = Literal["error", "warning", "info"]
|
|
32
|
+
|
|
33
|
+
# Source priority for tie-breaking a content conflict: a chip-level spec beats a MAS,
|
|
34
|
+
# which beats extracted RTL. Anything unlisted sorts last. This is a role, not a concrete
|
|
35
|
+
# format or tool name, so core stays chip/tool-agnostic.
|
|
36
|
+
_SOURCE_PRIORITY: dict[str, int] = {"chip": 0, "mas": 1, "rtl": 2}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class SourcePart:
|
|
41
|
+
"""One already-built model, tagged with where it came from.
|
|
42
|
+
|
|
43
|
+
`source` is a coarse role used only for conflict tie-breaking: `"chip"` (the
|
|
44
|
+
chip-level spec), `"mas"` (a per-block spec) or `"rtl"` (extracted RTL). `block` is
|
|
45
|
+
the block this part belongs to, or None for a part that spans blocks (a chip-level
|
|
46
|
+
spec, or RTL elaborated with no single owning block). `diagnostics` are issues the
|
|
47
|
+
adapter/extractor already found; ingest passes them through unchanged.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
source: str
|
|
51
|
+
block: str | None
|
|
52
|
+
model: DesignModel
|
|
53
|
+
diagnostics: tuple[IngestIssue, ...] = ()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class IngestIssue(BaseModel):
|
|
57
|
+
"""One problem found while ingesting, JSON-dumpable and never raised."""
|
|
58
|
+
|
|
59
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
60
|
+
|
|
61
|
+
severity: Severity = Field(description="How serious the issue is.")
|
|
62
|
+
code: str = Field(description="A short machine code, e.g. 'conflict', 'dangling'.")
|
|
63
|
+
message: str = Field(description="A human-readable description.")
|
|
64
|
+
key: str | None = Field(default=None, description="Model key the issue is about, if any.")
|
|
65
|
+
block: str | None = Field(default=None, description="Block the issue is about, if any.")
|
|
66
|
+
file: str | None = Field(default=None, description="Source file, repo-relative POSIX.")
|
|
67
|
+
line: int | None = Field(default=None, description="1-based source line, if any.")
|
|
68
|
+
sources: tuple[str, ...] = Field(
|
|
69
|
+
default=(), description="The source roles involved (e.g. for a conflict)."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class IngestStats(BaseModel):
|
|
74
|
+
"""Deterministic, JSON-dumpable counts describing an ingested model."""
|
|
75
|
+
|
|
76
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
77
|
+
|
|
78
|
+
inputs: int = Field(default=0, description="Number of input files read.")
|
|
79
|
+
entities: int = Field(default=0, description="Total entities in the merged model.")
|
|
80
|
+
relations: int = Field(default=0, description="Total relations in the merged model.")
|
|
81
|
+
entities_by_kind: dict[str, int] = Field(
|
|
82
|
+
default_factory=dict, description="Entity count per kind."
|
|
83
|
+
)
|
|
84
|
+
entities_by_source: dict[str, int] = Field(
|
|
85
|
+
default_factory=dict, description="Entity count per source role."
|
|
86
|
+
)
|
|
87
|
+
entities_by_block: dict[str, int] = Field(
|
|
88
|
+
default_factory=dict, description="Entity count per owning block key ('' = none)."
|
|
89
|
+
)
|
|
90
|
+
relations_by_kind: dict[str, int] = Field(
|
|
91
|
+
default_factory=dict, description="Relation count per kind."
|
|
92
|
+
)
|
|
93
|
+
diagnostics_by_severity: dict[str, int] = Field(
|
|
94
|
+
default_factory=dict, description="Issue count per severity."
|
|
95
|
+
)
|
|
96
|
+
conflicts: int = Field(default=0, description="Number of content conflicts recorded.")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class IngestResult(BaseModel):
|
|
100
|
+
"""The outcome of `ingest`: the model is returned separately (it is not a pydantic model)."""
|
|
101
|
+
|
|
102
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
103
|
+
|
|
104
|
+
stats: IngestStats
|
|
105
|
+
issues: tuple[IngestIssue, ...]
|
|
106
|
+
build_inputs_hash: str
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class InputFile:
|
|
111
|
+
"""One input file that was read, for the build-inputs hash."""
|
|
112
|
+
|
|
113
|
+
rel_path: str
|
|
114
|
+
sha256: str
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(slots=True)
|
|
118
|
+
class _MergeState:
|
|
119
|
+
entities: dict[str, EntityBase] = field(default_factory=dict)
|
|
120
|
+
entity_source: dict[str, str] = field(default_factory=dict)
|
|
121
|
+
entity_owner_first: dict[str, bool] = field(default_factory=dict)
|
|
122
|
+
entity_origin: dict[str, str] = field(default_factory=dict)
|
|
123
|
+
relations: list[Relation] = field(default_factory=list)
|
|
124
|
+
issues: list[IngestIssue] = field(default_factory=list)
|
|
125
|
+
conflicts: int = 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def ingest(
|
|
129
|
+
parts: Iterable[SourcePart],
|
|
130
|
+
*,
|
|
131
|
+
input_files: Sequence[InputFile] = (),
|
|
132
|
+
profile_digest: str | None = None,
|
|
133
|
+
ip_blocks: Mapping[str, tuple[str, ...]] | None = None,
|
|
134
|
+
) -> tuple[DesignModel, IngestResult]:
|
|
135
|
+
"""Merge `parts` into one model, plus stats, issues and a build-inputs hash.
|
|
136
|
+
|
|
137
|
+
Deterministic: the returned model, stats and issues do not depend on the order of
|
|
138
|
+
`parts`, and the module-ownership rule breaks every tie the same way each time. Never
|
|
139
|
+
raises on conflicting content: a content conflict is kept-one-and-recorded.
|
|
140
|
+
|
|
141
|
+
`ip_blocks` maps an IP block name to the names of its declared memory-map instances
|
|
142
|
+
(D38). When given, ingest ensures a `block:<ip>` entity exists (with `attrs.role =
|
|
143
|
+
"ip"` if it had to create it), links each existing instance to its IP with an
|
|
144
|
+
`instance_of` relation, and reports instances/blocks it cannot map as warnings. Passing
|
|
145
|
+
`None` leaves behaviour unchanged.
|
|
146
|
+
"""
|
|
147
|
+
ordered = _order_parts(parts)
|
|
148
|
+
owners = _assign_ownership(ordered)
|
|
149
|
+
|
|
150
|
+
state = _MergeState()
|
|
151
|
+
for part in ordered:
|
|
152
|
+
_merge_part(state, part, owners)
|
|
153
|
+
|
|
154
|
+
_add_ownership_relations(state, owners)
|
|
155
|
+
if ip_blocks is not None:
|
|
156
|
+
_add_instance_relations(state, ip_blocks)
|
|
157
|
+
model = DesignModel(entities=dict(state.entities), relations=tuple(state.relations))
|
|
158
|
+
|
|
159
|
+
for message in model.validate_relations():
|
|
160
|
+
state.issues.append(IngestIssue(severity="warning", code="dangling", message=message))
|
|
161
|
+
|
|
162
|
+
issues = _sorted_issues(state.issues + [d for part in ordered for d in part.diagnostics])
|
|
163
|
+
stats = _compute_stats(model, state, owners, len(input_files), issues)
|
|
164
|
+
build_inputs_hash = _build_inputs_hash(input_files, profile_digest)
|
|
165
|
+
return model, IngestResult(stats=stats, issues=issues, build_inputs_hash=build_inputs_hash)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _order_parts(parts: Iterable[SourcePart]) -> list[SourcePart]:
|
|
169
|
+
"""A deterministic, order-independent processing order for the parts.
|
|
170
|
+
|
|
171
|
+
Sorted by (source priority, block key, a stable content digest), so shuffling the
|
|
172
|
+
input never changes the merged result.
|
|
173
|
+
"""
|
|
174
|
+
return sorted(parts, key=_part_sort_key)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _part_sort_key(part: SourcePart) -> tuple[int, str, str, str]:
|
|
178
|
+
priority = _SOURCE_PRIORITY.get(part.source, len(_SOURCE_PRIORITY))
|
|
179
|
+
return (priority, part.source, part.block or "", _part_digest(part))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _part_digest(part: SourcePart) -> str:
|
|
183
|
+
hasher = hashlib.sha256()
|
|
184
|
+
hasher.update(part.source.encode("utf-8"))
|
|
185
|
+
hasher.update(b"\x00")
|
|
186
|
+
hasher.update((part.block or "").encode("utf-8"))
|
|
187
|
+
hasher.update(b"\x00")
|
|
188
|
+
for key in sorted(part.model.entities):
|
|
189
|
+
hasher.update(key.encode("utf-8"))
|
|
190
|
+
hasher.update(b"\x00")
|
|
191
|
+
return hasher.hexdigest()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# --- module ownership --------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _assign_ownership(parts: Sequence[SourcePart]) -> dict[str, _Ownership]:
|
|
198
|
+
"""Decide which block owns each module key that appears in a block-tagged part.
|
|
199
|
+
|
|
200
|
+
A module is a candidate for every block whose part contains it. With one candidate,
|
|
201
|
+
that block owns it. With several, ownership goes to (in order): the block whose
|
|
202
|
+
filelist directory contains the module's own file; else the block whose name matches
|
|
203
|
+
the module (a filelist top); else the block whose part has the fewest modules, if that
|
|
204
|
+
block is unique; else no owner. `attrs.used_by` records all candidates when more than
|
|
205
|
+
one.
|
|
206
|
+
"""
|
|
207
|
+
module_files: dict[str, str | None] = {}
|
|
208
|
+
candidates: dict[str, list[str]] = {}
|
|
209
|
+
module_counts: dict[str, int] = {}
|
|
210
|
+
|
|
211
|
+
for part in parts:
|
|
212
|
+
if part.block is None:
|
|
213
|
+
continue
|
|
214
|
+
block_modules = [e for e in part.model.entities.values() if isinstance(e, ModuleEntity)]
|
|
215
|
+
module_counts[part.block] = len(block_modules)
|
|
216
|
+
for module in block_modules:
|
|
217
|
+
candidates.setdefault(module.key, [])
|
|
218
|
+
if part.block not in candidates[module.key]:
|
|
219
|
+
candidates[module.key].append(part.block)
|
|
220
|
+
if module.key not in module_files or module_files[module.key] is None:
|
|
221
|
+
module_files[module.key] = module.file
|
|
222
|
+
|
|
223
|
+
owners: dict[str, _Ownership] = {}
|
|
224
|
+
for module_key, cand in candidates.items():
|
|
225
|
+
cand_sorted = sorted(cand)
|
|
226
|
+
owner = _pick_owner(module_key, cand_sorted, module_files.get(module_key), module_counts)
|
|
227
|
+
used_by = tuple(cand_sorted) if len(cand_sorted) > 1 else ()
|
|
228
|
+
owners[module_key] = _Ownership(owner=owner, used_by=used_by)
|
|
229
|
+
return owners
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _pick_owner(
|
|
233
|
+
module_key: str,
|
|
234
|
+
candidates: Sequence[str],
|
|
235
|
+
module_file: str | None,
|
|
236
|
+
module_counts: Mapping[str, int],
|
|
237
|
+
) -> str | None:
|
|
238
|
+
if len(candidates) == 1:
|
|
239
|
+
return candidates[0]
|
|
240
|
+
if not candidates:
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
# 1. The block whose filelist directory contains the module's file. A block key is
|
|
244
|
+
# "block:<name>"; the directory heuristic matches "/<name>/" in the file path.
|
|
245
|
+
if module_file is not None:
|
|
246
|
+
dir_matches = [b for b in candidates if _block_dir_matches(b, module_file)]
|
|
247
|
+
if len(dir_matches) == 1:
|
|
248
|
+
return dir_matches[0]
|
|
249
|
+
|
|
250
|
+
# 2. The block whose name matches the module (its filelist's own top).
|
|
251
|
+
name_matches = [b for b in candidates if _block_name_matches(b, module_key)]
|
|
252
|
+
if len(name_matches) == 1:
|
|
253
|
+
return name_matches[0]
|
|
254
|
+
|
|
255
|
+
# 3. The block with the fewest modules, if that block is unique.
|
|
256
|
+
fewest = min(module_counts.get(b, 0) for b in candidates)
|
|
257
|
+
smallest = [b for b in candidates if module_counts.get(b, 0) == fewest]
|
|
258
|
+
if len(smallest) == 1:
|
|
259
|
+
return smallest[0]
|
|
260
|
+
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _block_name(block_key: str) -> str:
|
|
265
|
+
_, _, rest = block_key.partition(":")
|
|
266
|
+
return rest
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _block_dir_matches(block_key: str, module_file: str) -> bool:
|
|
270
|
+
name = _block_name(block_key)
|
|
271
|
+
if not name:
|
|
272
|
+
return False
|
|
273
|
+
return f"/{name}/" in f"/{module_file}"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _block_name_matches(block_key: str, module_key: str) -> bool:
|
|
277
|
+
_, _, module_name = module_key.partition(":")
|
|
278
|
+
name = _block_name(block_key)
|
|
279
|
+
if not name or not module_name:
|
|
280
|
+
return False
|
|
281
|
+
# The module is the block's own top when its name is or ends with the block name.
|
|
282
|
+
return module_name == name or module_name.endswith(f"_{name}") or name in module_name.split("_")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@dataclass(frozen=True, slots=True)
|
|
286
|
+
class _Ownership:
|
|
287
|
+
owner: str | None
|
|
288
|
+
used_by: tuple[str, ...]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# --- merging -----------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _merge_part(state: _MergeState, part: SourcePart, owners: Mapping[str, _Ownership]) -> None:
|
|
295
|
+
is_owner_of = _owner_predicate(part, owners)
|
|
296
|
+
for key, entity in part.model.entities.items():
|
|
297
|
+
entity = _apply_ownership(entity, owners)
|
|
298
|
+
origin = f"{part.source}:{part.block}" if part.block else part.source
|
|
299
|
+
owner = is_owner_of(_owning_module(key, entity))
|
|
300
|
+
_merge_entity(state, key, entity, part.source, owner, origin)
|
|
301
|
+
state.relations.extend(part.model.relations)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _owning_module(key: str, entity: EntityBase) -> str:
|
|
305
|
+
"""The module key whose ownership decides a conflict on `entity`.
|
|
306
|
+
|
|
307
|
+
A module decides for itself; a port or parameter decides by the module it belongs to,
|
|
308
|
+
so the owning block's elaboration of a shared module wins for its ports and parameters
|
|
309
|
+
too. Anything else decides by its own key.
|
|
310
|
+
"""
|
|
311
|
+
module = getattr(entity, "module", None)
|
|
312
|
+
if isinstance(module, str) and module:
|
|
313
|
+
return module
|
|
314
|
+
return key
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _owner_predicate(part: SourcePart, owners: Mapping[str, _Ownership]) -> Callable[[str], bool]:
|
|
318
|
+
def predicate(key: str) -> bool:
|
|
319
|
+
ownership = owners.get(key)
|
|
320
|
+
if ownership is None or ownership.owner is None:
|
|
321
|
+
return part.block is None
|
|
322
|
+
return ownership.owner == part.block
|
|
323
|
+
|
|
324
|
+
return predicate
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _apply_ownership(entity: EntityBase, owners: Mapping[str, _Ownership]) -> EntityBase:
|
|
328
|
+
if not isinstance(entity, ModuleEntity):
|
|
329
|
+
return entity
|
|
330
|
+
ownership = owners.get(entity.key)
|
|
331
|
+
if ownership is None:
|
|
332
|
+
return entity
|
|
333
|
+
updates: dict[str, object] = {}
|
|
334
|
+
if ownership.owner is not None:
|
|
335
|
+
updates["block"] = ownership.owner
|
|
336
|
+
if ownership.used_by:
|
|
337
|
+
attrs: dict[str, JSONValue] = dict(entity.attrs)
|
|
338
|
+
attrs["used_by"] = list(ownership.used_by)
|
|
339
|
+
updates["attrs"] = attrs
|
|
340
|
+
if not updates:
|
|
341
|
+
return entity
|
|
342
|
+
return entity.model_copy(update=updates)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _merge_entity(
|
|
346
|
+
state: _MergeState,
|
|
347
|
+
key: str,
|
|
348
|
+
entity: EntityBase,
|
|
349
|
+
source: str,
|
|
350
|
+
is_owner: bool,
|
|
351
|
+
origin: str,
|
|
352
|
+
) -> None:
|
|
353
|
+
existing = state.entities.get(key)
|
|
354
|
+
if existing is None:
|
|
355
|
+
state.entities[key] = entity
|
|
356
|
+
state.entity_source[key] = source
|
|
357
|
+
state.entity_owner_first[key] = is_owner
|
|
358
|
+
state.entity_origin[key] = origin
|
|
359
|
+
return
|
|
360
|
+
if existing == entity:
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
# A content conflict: keep one by a fixed rule and record it.
|
|
364
|
+
keep_existing = _keeps_existing(
|
|
365
|
+
state.entity_owner_first[key], state.entity_source[key], is_owner, source, existing, entity
|
|
366
|
+
)
|
|
367
|
+
kept = existing if keep_existing else entity
|
|
368
|
+
old_origin = state.entity_origin[key]
|
|
369
|
+
kept_origin, dropped_origin = (old_origin, origin) if keep_existing else (origin, old_origin)
|
|
370
|
+
if not keep_existing:
|
|
371
|
+
state.entities[key] = entity
|
|
372
|
+
state.entity_source[key] = source
|
|
373
|
+
state.entity_owner_first[key] = is_owner
|
|
374
|
+
state.entity_origin[key] = origin
|
|
375
|
+
state.conflicts += 1
|
|
376
|
+
state.issues.append(
|
|
377
|
+
IngestIssue(
|
|
378
|
+
severity="warning",
|
|
379
|
+
code="conflict",
|
|
380
|
+
key=key,
|
|
381
|
+
message=(
|
|
382
|
+
f"conflicting content for {key!r}; kept {kept_origin} "
|
|
383
|
+
f"({kept.source.file or '?'}:{kept.source.line or '-'}), dropped {dropped_origin}"
|
|
384
|
+
),
|
|
385
|
+
file=kept.source.file,
|
|
386
|
+
line=kept.source.line,
|
|
387
|
+
sources=tuple(sorted({state.entity_source[key], source})),
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _keeps_existing(
|
|
393
|
+
existing_owner: bool,
|
|
394
|
+
existing_source: str,
|
|
395
|
+
new_owner: bool,
|
|
396
|
+
new_source: str,
|
|
397
|
+
existing: EntityBase,
|
|
398
|
+
new: EntityBase,
|
|
399
|
+
) -> bool:
|
|
400
|
+
"""The fixed conflict rule: owner's part first, then source priority, then key order.
|
|
401
|
+
|
|
402
|
+
Returns True to keep the already-stored entity, False to replace it with `new`.
|
|
403
|
+
"""
|
|
404
|
+
if existing_owner != new_owner:
|
|
405
|
+
return existing_owner
|
|
406
|
+
existing_priority = _SOURCE_PRIORITY.get(existing_source, len(_SOURCE_PRIORITY))
|
|
407
|
+
new_priority = _SOURCE_PRIORITY.get(new_source, len(_SOURCE_PRIORITY))
|
|
408
|
+
if existing_priority != new_priority:
|
|
409
|
+
return existing_priority <= new_priority
|
|
410
|
+
# A final deterministic tie-break on the entity's own dumped content.
|
|
411
|
+
return _content_key(existing) <= _content_key(new)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _content_key(entity: EntityBase) -> str:
|
|
415
|
+
return repr(entity.model_dump(mode="json"))
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _add_ownership_relations(state: _MergeState, owners: Mapping[str, _Ownership]) -> None:
|
|
419
|
+
"""Add a `contains` relation from an owner block to each module it owns.
|
|
420
|
+
|
|
421
|
+
Only added when both the block and the module are present in the merged model, and
|
|
422
|
+
only once per (block, module) pair.
|
|
423
|
+
"""
|
|
424
|
+
seen: set[tuple[str, str]] = {(r.src, r.dst) for r in state.relations if r.kind == "contains"}
|
|
425
|
+
for module_key, ownership in sorted(owners.items()):
|
|
426
|
+
if ownership.owner is None:
|
|
427
|
+
continue
|
|
428
|
+
if ownership.owner not in state.entities or module_key not in state.entities:
|
|
429
|
+
continue
|
|
430
|
+
pair = (ownership.owner, module_key)
|
|
431
|
+
if pair in seen:
|
|
432
|
+
continue
|
|
433
|
+
seen.add(pair)
|
|
434
|
+
state.relations.append(Relation(kind="contains", src=ownership.owner, dst=module_key))
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# --- instances (D38) ---------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _block_key(name: str) -> str:
|
|
441
|
+
return f"block:{name}"
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _add_instance_relations(state: _MergeState, ip_blocks: Mapping[str, tuple[str, ...]]) -> None:
|
|
445
|
+
"""Ensure IP blocks exist and link each memory-map instance to its IP (D38).
|
|
446
|
+
|
|
447
|
+
For each IP its instance set is the declared instances, or `(ip,)` when none is
|
|
448
|
+
declared (the same-name rule). A `block:<ip>` entity is created (with `attrs.role =
|
|
449
|
+
"ip"`) when missing. An `instance_of` relation is added for every instance that exists
|
|
450
|
+
in the merged model and differs from the IP. Instances/blocks that cannot be mapped
|
|
451
|
+
are reported as deterministic warnings.
|
|
452
|
+
"""
|
|
453
|
+
interrupt_names = _assign_interrupt_blocks(state, ip_blocks)
|
|
454
|
+
instance_to_ip: dict[str, str] = {}
|
|
455
|
+
for ip in sorted(ip_blocks):
|
|
456
|
+
declared = ip_blocks[ip]
|
|
457
|
+
instances = declared if declared else (ip,)
|
|
458
|
+
|
|
459
|
+
ip_key = _block_key(ip)
|
|
460
|
+
if ip_key not in state.entities:
|
|
461
|
+
state.entities[ip_key] = BlockEntity(key=ip_key, name=ip, attrs={"role": "ip"})
|
|
462
|
+
|
|
463
|
+
for instance in instances:
|
|
464
|
+
instance_key = _block_key(instance)
|
|
465
|
+
if instance_key not in state.entities:
|
|
466
|
+
# A declared instance with no matching block in the model, unless it names
|
|
467
|
+
# an interrupt source the contract lists without a block (mapped above).
|
|
468
|
+
if instance != ip and instance not in interrupt_names:
|
|
469
|
+
state.issues.append(
|
|
470
|
+
IngestIssue(
|
|
471
|
+
severity="warning",
|
|
472
|
+
code="instance_unknown",
|
|
473
|
+
message=(
|
|
474
|
+
f"block {ip!r} declares instance {instance!r}, but there is "
|
|
475
|
+
f"no {instance_key!r} in the model"
|
|
476
|
+
),
|
|
477
|
+
key=instance_key,
|
|
478
|
+
block=ip_key,
|
|
479
|
+
)
|
|
480
|
+
)
|
|
481
|
+
continue
|
|
482
|
+
instance_to_ip[instance] = ip
|
|
483
|
+
if instance == ip:
|
|
484
|
+
# Same-name rule: the IP is its own instance; do not relate it to itself.
|
|
485
|
+
continue
|
|
486
|
+
state.relations.append(Relation(kind="instance_of", src=instance_key, dst=ip_key))
|
|
487
|
+
|
|
488
|
+
_report_unmapped_instances(state, ip_blocks, instance_to_ip)
|
|
489
|
+
_report_unknown_blocks(state)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _assign_interrupt_blocks(
|
|
493
|
+
state: _MergeState, ip_blocks: Mapping[str, tuple[str, ...]]
|
|
494
|
+
) -> set[str]:
|
|
495
|
+
"""Give a block to each interrupt the chip spec lists without one (D38).
|
|
496
|
+
|
|
497
|
+
A contract may name an interrupt by its peripheral (`dma`, `spi_host`, `wdt_wakeup`)
|
|
498
|
+
rather than by a memory-map block. Such an interrupt belongs to the IP whose name is
|
|
499
|
+
that peripheral, or whose declared `instances` list it; nothing is guessed from the
|
|
500
|
+
name otherwise. The target is `block:<name>` when that block exists, else the IP's
|
|
501
|
+
block. The interrupt records `attrs.block_from = "profile"`. Returns the names that
|
|
502
|
+
matched, so they are not also reported as unknown instances.
|
|
503
|
+
"""
|
|
504
|
+
owner_of: dict[str, str] = {}
|
|
505
|
+
for ip in sorted(ip_blocks):
|
|
506
|
+
owner_of.setdefault(ip, ip)
|
|
507
|
+
for name in ip_blocks[ip]:
|
|
508
|
+
owner_of.setdefault(name, ip)
|
|
509
|
+
|
|
510
|
+
matched: set[str] = set()
|
|
511
|
+
for key in sorted(state.entities):
|
|
512
|
+
entity = state.entities[key]
|
|
513
|
+
if not isinstance(entity, InterruptEntity) or entity.block:
|
|
514
|
+
continue
|
|
515
|
+
peripheral = entity.attrs.get("peripheral")
|
|
516
|
+
name = peripheral if isinstance(peripheral, str) and peripheral else _block_name(key)
|
|
517
|
+
owner = owner_of.get(name)
|
|
518
|
+
if owner is None:
|
|
519
|
+
continue
|
|
520
|
+
target = _block_key(name) if _block_key(name) in state.entities else _block_key(owner)
|
|
521
|
+
attrs: dict[str, JSONValue] = dict(entity.attrs)
|
|
522
|
+
attrs["block_from"] = "profile"
|
|
523
|
+
state.entities[key] = entity.model_copy(update={"block": target, "attrs": attrs})
|
|
524
|
+
matched.add(name)
|
|
525
|
+
return matched
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _report_unmapped_instances(
|
|
529
|
+
state: _MergeState,
|
|
530
|
+
ip_blocks: Mapping[str, tuple[str, ...]],
|
|
531
|
+
instance_to_ip: Mapping[str, str],
|
|
532
|
+
) -> None:
|
|
533
|
+
"""Warn about every block in the model that is neither an IP nor an instance of one."""
|
|
534
|
+
ips = set(ip_blocks)
|
|
535
|
+
mapped = set(instance_to_ip)
|
|
536
|
+
for key in sorted(state.entities):
|
|
537
|
+
entity = state.entities[key]
|
|
538
|
+
if not isinstance(entity, BlockEntity):
|
|
539
|
+
continue
|
|
540
|
+
name = _block_name(key)
|
|
541
|
+
if name in ips or name in mapped:
|
|
542
|
+
continue
|
|
543
|
+
state.issues.append(
|
|
544
|
+
IngestIssue(
|
|
545
|
+
severity="warning",
|
|
546
|
+
code="instance_unmapped",
|
|
547
|
+
message=(
|
|
548
|
+
f"block {key!r} is neither a declared IP nor an instance of one; "
|
|
549
|
+
"declare it under blocks.<ip>.instances"
|
|
550
|
+
),
|
|
551
|
+
key=key,
|
|
552
|
+
)
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _report_unknown_blocks(state: _MergeState) -> None:
|
|
557
|
+
"""Warn once per `block:<x>` referenced but absent from the model.
|
|
558
|
+
|
|
559
|
+
Checks the typed `block` field of modules, registers, interrupts and memory regions,
|
|
560
|
+
an `attrs.block` on any entity, and every relation endpoint. Each distinct missing
|
|
561
|
+
block is reported once, with a count and one example key.
|
|
562
|
+
"""
|
|
563
|
+
referrers: dict[str, list[str]] = {}
|
|
564
|
+
|
|
565
|
+
def record(block_ref: str, referrer: str) -> None:
|
|
566
|
+
if not block_ref.startswith("block:"):
|
|
567
|
+
return
|
|
568
|
+
if block_ref in state.entities:
|
|
569
|
+
return
|
|
570
|
+
referrers.setdefault(block_ref, []).append(referrer)
|
|
571
|
+
|
|
572
|
+
for key in sorted(state.entities):
|
|
573
|
+
entity = state.entities[key]
|
|
574
|
+
typed_block = getattr(entity, "block", None)
|
|
575
|
+
if isinstance(typed_block, str) and typed_block:
|
|
576
|
+
record(typed_block, key)
|
|
577
|
+
attr_block = entity.attrs.get("block")
|
|
578
|
+
if isinstance(attr_block, str) and attr_block:
|
|
579
|
+
record(attr_block, key)
|
|
580
|
+
|
|
581
|
+
for index, relation in enumerate(state.relations):
|
|
582
|
+
for endpoint in (relation.src, relation.dst):
|
|
583
|
+
record(endpoint, f"relation[{index}]:{relation.kind}")
|
|
584
|
+
|
|
585
|
+
for block_ref in sorted(referrers):
|
|
586
|
+
examples = sorted(referrers[block_ref])
|
|
587
|
+
state.issues.append(
|
|
588
|
+
IngestIssue(
|
|
589
|
+
severity="warning",
|
|
590
|
+
code="block_unknown",
|
|
591
|
+
message=(
|
|
592
|
+
f"{block_ref!r} is referenced by {len(examples)} entity/relation(s) "
|
|
593
|
+
f"but is not in the model (e.g. {examples[0]!r})"
|
|
594
|
+
),
|
|
595
|
+
key=block_ref,
|
|
596
|
+
)
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# --- stats and hashing -------------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _compute_stats(
|
|
604
|
+
model: DesignModel,
|
|
605
|
+
state: _MergeState,
|
|
606
|
+
owners: Mapping[str, _Ownership],
|
|
607
|
+
input_count: int,
|
|
608
|
+
issues: Sequence[IngestIssue],
|
|
609
|
+
) -> IngestStats:
|
|
610
|
+
by_kind: dict[str, int] = {}
|
|
611
|
+
by_source: dict[str, int] = {}
|
|
612
|
+
by_block: dict[str, int] = {}
|
|
613
|
+
for key, entity in model.entities.items():
|
|
614
|
+
by_kind[entity.kind] = by_kind.get(entity.kind, 0) + 1
|
|
615
|
+
source = state.entity_source.get(key, "")
|
|
616
|
+
by_source[source] = by_source.get(source, 0) + 1
|
|
617
|
+
block = _entity_block(entity)
|
|
618
|
+
by_block[block] = by_block.get(block, 0) + 1
|
|
619
|
+
|
|
620
|
+
by_relation_kind: dict[str, int] = {}
|
|
621
|
+
for relation in model.relations:
|
|
622
|
+
by_relation_kind[relation.kind] = by_relation_kind.get(relation.kind, 0) + 1
|
|
623
|
+
|
|
624
|
+
by_severity: dict[str, int] = {}
|
|
625
|
+
for issue in issues:
|
|
626
|
+
by_severity[issue.severity] = by_severity.get(issue.severity, 0) + 1
|
|
627
|
+
|
|
628
|
+
return IngestStats(
|
|
629
|
+
inputs=input_count,
|
|
630
|
+
entities=len(model.entities),
|
|
631
|
+
relations=len(model.relations),
|
|
632
|
+
entities_by_kind=dict(sorted(by_kind.items())),
|
|
633
|
+
entities_by_source=dict(sorted(by_source.items())),
|
|
634
|
+
entities_by_block=dict(sorted(by_block.items())),
|
|
635
|
+
relations_by_kind=dict(sorted(by_relation_kind.items())),
|
|
636
|
+
diagnostics_by_severity=dict(sorted(by_severity.items())),
|
|
637
|
+
conflicts=state.conflicts,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _entity_block(entity: EntityBase) -> str:
|
|
642
|
+
block = getattr(entity, "block", None)
|
|
643
|
+
if isinstance(block, str):
|
|
644
|
+
return block
|
|
645
|
+
attr_block = entity.attrs.get("block")
|
|
646
|
+
if isinstance(attr_block, str):
|
|
647
|
+
return attr_block
|
|
648
|
+
return ""
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _sorted_issues(issues: Iterable[IngestIssue]) -> tuple[IngestIssue, ...]:
|
|
652
|
+
_severity_order = {"error": 0, "warning": 1, "info": 2}
|
|
653
|
+
return tuple(
|
|
654
|
+
sorted(
|
|
655
|
+
issues,
|
|
656
|
+
key=lambda i: (
|
|
657
|
+
_severity_order.get(i.severity, 3),
|
|
658
|
+
i.code,
|
|
659
|
+
i.key or "",
|
|
660
|
+
i.file or "",
|
|
661
|
+
i.line or 0,
|
|
662
|
+
i.message,
|
|
663
|
+
),
|
|
664
|
+
)
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _build_inputs_hash(input_files: Sequence[InputFile], profile_digest: str | None) -> str:
|
|
669
|
+
"""sha256 over sorted (relative path, file sha256) plus the profile digest.
|
|
670
|
+
|
|
671
|
+
Changing any listed input's content, or an ingest-relevant profile section, changes
|
|
672
|
+
the hash; reordering the inputs does not.
|
|
673
|
+
"""
|
|
674
|
+
hasher = hashlib.sha256()
|
|
675
|
+
for input_file in sorted(input_files, key=lambda f: f.rel_path):
|
|
676
|
+
hasher.update(input_file.rel_path.encode("utf-8"))
|
|
677
|
+
hasher.update(b"\x00")
|
|
678
|
+
hasher.update(input_file.sha256.encode("utf-8"))
|
|
679
|
+
hasher.update(b"\x00")
|
|
680
|
+
if profile_digest is not None:
|
|
681
|
+
hasher.update(b"profile\x00")
|
|
682
|
+
hasher.update(profile_digest.encode("utf-8"))
|
|
683
|
+
return hasher.hexdigest()
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
__all__ = [
|
|
687
|
+
"IngestIssue",
|
|
688
|
+
"IngestResult",
|
|
689
|
+
"IngestStats",
|
|
690
|
+
"InputFile",
|
|
691
|
+
"SourcePart",
|
|
692
|
+
"ingest",
|
|
693
|
+
]
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
"""A recursive JSON-value type alias shared by the Design Model's ``attrs`` fields."""
|
|
2
|
+
|
|
3
|
+
type JSONValue = bool | int | float | str | list[JSONValue] | dict[str, JSONValue] | None
|
|
4
|
+
"""Any value that round-trips through JSON: used for open-ended entity/relation ``attrs``."""
|