codmap 0.0.3__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.
- codemap/__init__.py +10 -0
- codemap/apidiff.py +208 -0
- codemap/arch.py +190 -0
- codemap/cli.py +718 -0
- codemap/diagnostics.py +256 -0
- codemap/extract/__init__.py +10 -0
- codemap/extract/attrflow.py +230 -0
- codemap/extract/behavior.py +771 -0
- codemap/extract/dataflow.py +97 -0
- codemap/extract/dispatch.py +248 -0
- codemap/extract/griffe_extractor.py +496 -0
- codemap/extract/gsource.py +83 -0
- codemap/extract/roots.py +427 -0
- codemap/freshness.py +94 -0
- codemap/incremental.py +195 -0
- codemap/integrations/__init__.py +51 -0
- codemap/integrations/base.py +196 -0
- codemap/integrations/cocoindex.py +78 -0
- codemap/integrations/gate.py +58 -0
- codemap/integrations/gitnexus.py +93 -0
- codemap/integrations/registry.py +69 -0
- codemap/integrations/transport.py +46 -0
- codemap/model.py +178 -0
- codemap/provenance.py +248 -0
- codemap/query.py +1164 -0
- codemap/scope.py +212 -0
- codemap/serve/__init__.py +26 -0
- codemap/serve/_scip_pb2.py +100 -0
- codemap/serve/api_surface.py +60 -0
- codemap/serve/apidiff.py +83 -0
- codemap/serve/architecture.py +101 -0
- codemap/serve/audit.py +176 -0
- codemap/serve/check.py +80 -0
- codemap/serve/ctags.py +203 -0
- codemap/serve/impact.py +84 -0
- codemap/serve/livingdocs.py +174 -0
- codemap/serve/mcp_server.py +278 -0
- codemap/serve/mermaid.py +120 -0
- codemap/serve/pack.py +93 -0
- codemap/serve/rag.py +142 -0
- codemap/serve/review.py +197 -0
- codemap/serve/scip.py +183 -0
- codemap/serve/semantic.py +71 -0
- codemap/serve/server.py +43 -0
- codemap/serve/session.py +482 -0
- codemap/serve/subsystems.py +85 -0
- codemap/serve/vault.py +156 -0
- codemap/store.py +28 -0
- codemap/tomlio.py +59 -0
- codmap-0.0.3.dist-info/METADATA +245 -0
- codmap-0.0.3.dist-info/RECORD +55 -0
- codmap-0.0.3.dist-info/WHEEL +5 -0
- codmap-0.0.3.dist-info/entry_points.txt +2 -0
- codmap-0.0.3.dist-info/licenses/LICENSE +21 -0
- codmap-0.0.3.dist-info/top_level.txt +1 -0
codemap/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""codemap — static code-graph builder.
|
|
2
|
+
|
|
3
|
+
Pipeline: Extract (griffe) -> Build (neutral model) -> Store (JSON) -> Serve (reports).
|
|
4
|
+
See DESIGN.md.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from codemap.model import Edge, Graph, Node
|
|
8
|
+
|
|
9
|
+
__all__ = ["Graph", "Node", "Edge"]
|
|
10
|
+
__version__ = "0.0.2"
|
codemap/apidiff.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Two-graph API diff + breaking-change detection (R1-C5).
|
|
2
|
+
|
|
3
|
+
Compare two ``graph.json`` snapshots (before/after a change) and report what moved
|
|
4
|
+
on the **public API surface**: symbols added / removed, and — for symbols present in
|
|
5
|
+
both — the signature-level changes, each classified as *breaking*, *warning*, or
|
|
6
|
+
*info*. "What broke between these two commits", at the API level.
|
|
7
|
+
|
|
8
|
+
The rules follow the griffe API-diff spirit (a removed parameter, a newly-required
|
|
9
|
+
parameter, a removed public symbol are breaking for callers). Signatures are parsed
|
|
10
|
+
with the stdlib ``ast`` — each stored signature string is wrapped as ``def <sig>: ...``
|
|
11
|
+
and its arguments read out — so the analysis is exact, not string-diff heuristics.
|
|
12
|
+
If a signature cannot be parsed, the change degrades to a conservative
|
|
13
|
+
``signature-changed`` note rather than a false "breaking".
|
|
14
|
+
|
|
15
|
+
Scope: only ``public`` symbols (``visibility == "public"``) participate — private
|
|
16
|
+
churn is not an API change. A public→private flip *is* an API removal.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
|
|
24
|
+
from codemap.model import Graph, Node
|
|
25
|
+
|
|
26
|
+
# severities, most severe first
|
|
27
|
+
BREAKING = "breaking"
|
|
28
|
+
WARNING = "warning"
|
|
29
|
+
INFO = "info"
|
|
30
|
+
_ORDER = {BREAKING: 0, WARNING: 1, INFO: 2}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Change:
|
|
35
|
+
"""One classified difference on a symbol."""
|
|
36
|
+
|
|
37
|
+
symbol: str
|
|
38
|
+
kind: str # param-removed | param-added-required | … (see _classify)
|
|
39
|
+
severity: str # breaking | warning | info
|
|
40
|
+
detail: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ApiDiff:
|
|
45
|
+
added: list[str] = field(default_factory=list) # new public symbols
|
|
46
|
+
removed: list[str] = field(default_factory=list) # deleted public symbols (each breaking)
|
|
47
|
+
changes: list[Change] = field(default_factory=list) # per-symbol classified changes
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def breaking(self) -> list[Change]:
|
|
51
|
+
return [c for c in self.changes if c.severity == BREAKING]
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict:
|
|
54
|
+
return {
|
|
55
|
+
"added": sorted(self.added),
|
|
56
|
+
"removed": sorted(self.removed),
|
|
57
|
+
"changes": [
|
|
58
|
+
{"symbol": c.symbol, "kind": c.kind, "severity": c.severity, "detail": c.detail}
|
|
59
|
+
for c in sorted(self.changes, key=lambda c: (_ORDER[c.severity], c.symbol, c.kind))
|
|
60
|
+
],
|
|
61
|
+
"summary": {
|
|
62
|
+
"added": len(self.added),
|
|
63
|
+
"removed": len(self.removed),
|
|
64
|
+
"breaking": len(self.breaking),
|
|
65
|
+
"changed_symbols": len({c.symbol for c in self.changes}),
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# -- signature parsing -------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class _Param:
|
|
74
|
+
name: str
|
|
75
|
+
required: bool
|
|
76
|
+
annotation: str | None
|
|
77
|
+
keyword_only: bool
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class _Sig:
|
|
82
|
+
params: dict[str, _Param]
|
|
83
|
+
has_vararg: bool # *args present
|
|
84
|
+
has_kwarg: bool # **kwargs present
|
|
85
|
+
returns: str | None
|
|
86
|
+
parsed: bool # False → signature string could not be parsed
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _ann(node) -> str | None:
|
|
90
|
+
return ast.unparse(node) if node is not None else None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_signature(signature: str | None) -> _Sig:
|
|
94
|
+
if not signature:
|
|
95
|
+
return _Sig({}, False, False, None, parsed=False)
|
|
96
|
+
try:
|
|
97
|
+
tree = ast.parse(f"def {signature}: ...")
|
|
98
|
+
fn = tree.body[0]
|
|
99
|
+
assert isinstance(fn, ast.FunctionDef)
|
|
100
|
+
except (SyntaxError, AssertionError, ValueError):
|
|
101
|
+
return _Sig({}, False, False, None, parsed=False)
|
|
102
|
+
a = fn.args
|
|
103
|
+
params: dict[str, _Param] = {}
|
|
104
|
+
positional = a.posonlyargs + a.args
|
|
105
|
+
n_defaults = len(a.defaults)
|
|
106
|
+
n_required = len(positional) - n_defaults
|
|
107
|
+
for i, arg in enumerate(positional):
|
|
108
|
+
params[arg.arg] = _Param(arg.arg, required=i < n_required,
|
|
109
|
+
annotation=_ann(arg.annotation), keyword_only=False)
|
|
110
|
+
for arg, default in zip(a.kwonlyargs, a.kw_defaults):
|
|
111
|
+
params[arg.arg] = _Param(arg.arg, required=default is None,
|
|
112
|
+
annotation=_ann(arg.annotation), keyword_only=True)
|
|
113
|
+
return _Sig(params, has_vararg=a.vararg is not None,
|
|
114
|
+
has_kwarg=a.kwarg is not None, returns=_ann(fn.returns), parsed=True)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# -- classification ----------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def _classify_signature(sid: str, old: Node, new: Node) -> list[Change]:
|
|
120
|
+
"""Breaking/warning/info changes between two versions of the same function."""
|
|
121
|
+
so, sn = _parse_signature(old.signature), _parse_signature(new.signature)
|
|
122
|
+
if not (so.parsed and sn.parsed):
|
|
123
|
+
if (old.signature or "") != (new.signature or ""):
|
|
124
|
+
return [Change(sid, "signature-changed", WARNING,
|
|
125
|
+
f"signature changed (unparsed): {old.signature!r} → {new.signature!r}")]
|
|
126
|
+
return []
|
|
127
|
+
out: list[Change] = []
|
|
128
|
+
|
|
129
|
+
# removed parameters — callers passing them break (unless **kwargs can absorb a
|
|
130
|
+
# keyword one, which still loses the parameter's meaning → keep it breaking).
|
|
131
|
+
for name, p in so.params.items():
|
|
132
|
+
if name not in sn.params:
|
|
133
|
+
out.append(Change(sid, "param-removed", BREAKING, f"parameter `{name}` removed"))
|
|
134
|
+
# added required parameters — existing calls omit them.
|
|
135
|
+
for name, p in sn.params.items():
|
|
136
|
+
if name not in so.params and p.required:
|
|
137
|
+
out.append(Change(sid, "param-added-required", BREAKING,
|
|
138
|
+
f"required parameter `{name}` added"))
|
|
139
|
+
# parameters that went from optional to required.
|
|
140
|
+
for name, p in sn.params.items():
|
|
141
|
+
old_p = so.params.get(name)
|
|
142
|
+
if old_p is not None and p.required and not old_p.required:
|
|
143
|
+
out.append(Change(sid, "param-made-required", BREAKING,
|
|
144
|
+
f"parameter `{name}` is now required"))
|
|
145
|
+
# variadic removed (*args / **kwargs that existed).
|
|
146
|
+
if so.has_vararg and not sn.has_vararg:
|
|
147
|
+
out.append(Change(sid, "variadic-removed", BREAKING, "`*args` removed"))
|
|
148
|
+
if so.has_kwarg and not sn.has_kwarg:
|
|
149
|
+
out.append(Change(sid, "variadic-removed", BREAKING, "`**kwargs` removed"))
|
|
150
|
+
# type annotation changes — not provably breaking (needs subtype reasoning), but
|
|
151
|
+
# worth a reviewer's eye. Reported as warnings.
|
|
152
|
+
for name, p in sn.params.items():
|
|
153
|
+
old_p = so.params.get(name)
|
|
154
|
+
if old_p is not None and old_p.annotation != p.annotation:
|
|
155
|
+
out.append(Change(sid, "param-type-changed", WARNING,
|
|
156
|
+
f"`{name}` type {old_p.annotation} → {p.annotation}"))
|
|
157
|
+
if so.returns != sn.returns:
|
|
158
|
+
out.append(Change(sid, "return-type-changed", WARNING,
|
|
159
|
+
f"return type {so.returns} → {sn.returns}"))
|
|
160
|
+
# new optional parameters are backward-compatible — info only.
|
|
161
|
+
for name, p in sn.params.items():
|
|
162
|
+
if name not in so.params and not p.required:
|
|
163
|
+
out.append(Change(sid, "param-added-optional", INFO,
|
|
164
|
+
f"optional parameter `{name}` added"))
|
|
165
|
+
return out
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _is_public(node: Node) -> bool:
|
|
169
|
+
return node.visibility == "public"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def diff_api(old: Graph, new: Graph) -> ApiDiff:
|
|
173
|
+
"""Diff the public API surface of two graphs (old → new)."""
|
|
174
|
+
diff = ApiDiff()
|
|
175
|
+
old_nodes = {i: n for i, n in old.nodes.items() if n.kind in ("function", "class", "attribute")}
|
|
176
|
+
new_nodes = {i: n for i, n in new.nodes.items() if n.kind in ("function", "class", "attribute")}
|
|
177
|
+
|
|
178
|
+
for sid, n in new_nodes.items():
|
|
179
|
+
if sid not in old_nodes and _is_public(n):
|
|
180
|
+
diff.added.append(sid)
|
|
181
|
+
|
|
182
|
+
for sid, o in old_nodes.items():
|
|
183
|
+
n = new_nodes.get(sid)
|
|
184
|
+
if n is None:
|
|
185
|
+
if _is_public(o):
|
|
186
|
+
diff.removed.append(sid) # public symbol gone → breaking (see render)
|
|
187
|
+
continue
|
|
188
|
+
# present in both — classify the change
|
|
189
|
+
if _is_public(o) and not _is_public(n):
|
|
190
|
+
diff.changes.append(Change(sid, "made-private", BREAKING,
|
|
191
|
+
"public symbol is now private"))
|
|
192
|
+
continue
|
|
193
|
+
if not _is_public(o) and not _is_public(n):
|
|
194
|
+
continue # private both sides — not an API change
|
|
195
|
+
if not _is_public(o) and _is_public(n):
|
|
196
|
+
diff.added.append(sid) # newly public = new API
|
|
197
|
+
continue
|
|
198
|
+
# public both sides
|
|
199
|
+
if o.kind != n.kind:
|
|
200
|
+
diff.changes.append(Change(sid, "kind-changed", BREAKING,
|
|
201
|
+
f"kind {o.kind} → {n.kind}"))
|
|
202
|
+
continue
|
|
203
|
+
if not o.is_deprecated and n.is_deprecated:
|
|
204
|
+
diff.changes.append(Change(sid, "deprecated", WARNING,
|
|
205
|
+
"symbol newly marked deprecated"))
|
|
206
|
+
if n.kind == "function":
|
|
207
|
+
diff.changes.extend(_classify_signature(sid, o, n))
|
|
208
|
+
return diff
|
codemap/arch.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Architecture contracts + enforcement gate (R1-C3).
|
|
2
|
+
|
|
3
|
+
`architecture` (M16) *describes* the system shape — layers, cycles, coupling. This
|
|
4
|
+
turns description into a **declarative contract that fails in CI**, the import-linter
|
|
5
|
+
/ ArchUnit move: you write the intended architecture down once, and any import that
|
|
6
|
+
breaks it is a non-zero exit with the offending edges named.
|
|
7
|
+
|
|
8
|
+
The contract lives in ``codemap.toml`` under ``[architecture]`` (same file the
|
|
9
|
+
integration gate reads). All rules operate on the **core** module import graph
|
|
10
|
+
(consumer roots — tests/examples/… — are never subject to layering):
|
|
11
|
+
|
|
12
|
+
[architecture]
|
|
13
|
+
# ordered top → bottom; a layer may import only layers *below* it.
|
|
14
|
+
layers = ["visualization", "analysis", "indicators", "data", "core"]
|
|
15
|
+
# groups whose members must not import one another (either direction).
|
|
16
|
+
independent = [["indicators", "data"]]
|
|
17
|
+
# hard bans regardless of layering: `from` must not import `to`.
|
|
18
|
+
forbidden = [{ from = "core", to = "analysis" }]
|
|
19
|
+
# the import graph must be acyclic.
|
|
20
|
+
no_cycles = true
|
|
21
|
+
# every core module's layer must appear in `layers` (catches a new,
|
|
22
|
+
# undeclared top-level package slipping in).
|
|
23
|
+
exhaustive = false
|
|
24
|
+
|
|
25
|
+
A layer is the component just under the package root (``pkg.<layer>...``) — the same
|
|
26
|
+
notion `Query.layers()` uses. Rules that reference a layer not present in the graph
|
|
27
|
+
are simply inert (no module → no edge to break), so a contract can be written ahead
|
|
28
|
+
of the code.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from codemap.tomlio import read_toml
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ArchitectureContract:
|
|
41
|
+
"""Parsed ``[architecture]`` rules. Empty contract = nothing to enforce.
|
|
42
|
+
|
|
43
|
+
``error`` carries the reason ``codemap.toml`` could not be read, if it could not be
|
|
44
|
+
(R1-C27). It is *not* folded into ``is_empty()``: "there are no rules" and "there may
|
|
45
|
+
be rules and I could not read them" are different answers, and a caller that treats
|
|
46
|
+
them the same is the bug this field exists to prevent. Ask ``error`` first.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
layers: tuple[str, ...] = ()
|
|
50
|
+
independent: tuple[tuple[str, ...], ...] = ()
|
|
51
|
+
forbidden: tuple[tuple[str, str], ...] = ()
|
|
52
|
+
no_cycles: bool = False
|
|
53
|
+
exhaustive: bool = False
|
|
54
|
+
error: str | None = None
|
|
55
|
+
|
|
56
|
+
def is_empty(self) -> bool:
|
|
57
|
+
return not (self.layers or self.independent or self.forbidden
|
|
58
|
+
or self.no_cycles or self.exhaustive)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Violation:
|
|
63
|
+
"""One broken rule, with the concrete import edges (or cycle) that break it."""
|
|
64
|
+
|
|
65
|
+
rule: str # layered | independent | forbidden | no_cycles | exhaustive
|
|
66
|
+
summary: str # human one-liner
|
|
67
|
+
edges: tuple[tuple[str, str], ...] = field(default=()) # offending (importer, imported)
|
|
68
|
+
modules: tuple[str, ...] = field(default=()) # for exhaustive / cycles
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_contract(root: str | Path = ".") -> ArchitectureContract:
|
|
72
|
+
"""Read ``[architecture]`` from ``codemap.toml`` under ``root`` (empty if absent).
|
|
73
|
+
|
|
74
|
+
Tolerant by design — a broken toml must not wedge ``check``, so nothing raises. But a
|
|
75
|
+
file that will not parse is reported through ``error`` rather than being returned as an
|
|
76
|
+
absent contract: a typo used to turn a failing gate green (R1-C27).
|
|
77
|
+
"""
|
|
78
|
+
data, error = read_toml(Path(root) / "codemap.toml")
|
|
79
|
+
if error:
|
|
80
|
+
return ArchitectureContract(error=error)
|
|
81
|
+
return parse_contract(data.get("architecture", {}))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_contract(section: dict) -> ArchitectureContract:
|
|
85
|
+
"""Build a contract from the parsed ``[architecture]`` table (validated leniently)."""
|
|
86
|
+
forbidden = []
|
|
87
|
+
for item in section.get("forbidden", []) or []:
|
|
88
|
+
if isinstance(item, dict) and "from" in item and "to" in item:
|
|
89
|
+
forbidden.append((str(item["from"]), str(item["to"])))
|
|
90
|
+
independent = tuple(
|
|
91
|
+
tuple(str(x) for x in grp)
|
|
92
|
+
for grp in (section.get("independent", []) or [])
|
|
93
|
+
if isinstance(grp, (list, tuple)) and len(grp) >= 2
|
|
94
|
+
)
|
|
95
|
+
return ArchitectureContract(
|
|
96
|
+
layers=tuple(str(x) for x in (section.get("layers", []) or [])),
|
|
97
|
+
independent=independent,
|
|
98
|
+
forbidden=tuple(forbidden),
|
|
99
|
+
no_cycles=bool(section.get("no_cycles", False)),
|
|
100
|
+
exhaustive=bool(section.get("exhaustive", False)),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _core_layer_edges(query) -> list[tuple[str, str, str, str]]:
|
|
105
|
+
"""Cross-layer core→core import edges as (importer, imported, layer_i, layer_j)."""
|
|
106
|
+
ig = query.import_graph
|
|
107
|
+
out = []
|
|
108
|
+
for u, v in ig.edges():
|
|
109
|
+
if query.root_of(u) != "core" or query.root_of(v) != "core":
|
|
110
|
+
continue
|
|
111
|
+
lu, lv = query._layer_of(u), query._layer_of(v)
|
|
112
|
+
if lu != lv:
|
|
113
|
+
out.append((u, v, lu, lv))
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
|
|
118
|
+
"""Evaluate every rule against the graph; return the violations (empty = clean)."""
|
|
119
|
+
if contract.is_empty():
|
|
120
|
+
return []
|
|
121
|
+
violations: list[Violation] = []
|
|
122
|
+
edges = _core_layer_edges(query)
|
|
123
|
+
|
|
124
|
+
# -- layered: a layer may import only layers below it in the ordered list ----
|
|
125
|
+
if contract.layers:
|
|
126
|
+
rank = {name: i for i, name in enumerate(contract.layers)}
|
|
127
|
+
bad = tuple(
|
|
128
|
+
(u, v) for (u, v, lu, lv) in edges
|
|
129
|
+
if lu in rank and lv in rank and rank[lv] < rank[lu]
|
|
130
|
+
)
|
|
131
|
+
if bad:
|
|
132
|
+
violations.append(Violation(
|
|
133
|
+
"layered",
|
|
134
|
+
f"{len(bad)} import(s) point up the layer stack "
|
|
135
|
+
f"({' → '.join(contract.layers)})",
|
|
136
|
+
edges=tuple(sorted(bad)),
|
|
137
|
+
))
|
|
138
|
+
|
|
139
|
+
# -- independent: members of a group must not import one another -------------
|
|
140
|
+
for grp in contract.independent:
|
|
141
|
+
gset = set(grp)
|
|
142
|
+
bad = tuple(
|
|
143
|
+
(u, v) for (u, v, lu, lv) in edges
|
|
144
|
+
if lu in gset and lv in gset and lu != lv
|
|
145
|
+
)
|
|
146
|
+
if bad:
|
|
147
|
+
violations.append(Violation(
|
|
148
|
+
"independent",
|
|
149
|
+
f"layers {{{', '.join(grp)}}} must be independent but import each other",
|
|
150
|
+
edges=tuple(sorted(bad)),
|
|
151
|
+
))
|
|
152
|
+
|
|
153
|
+
# -- forbidden: explicit from→to bans ---------------------------------------
|
|
154
|
+
for frm, to in contract.forbidden:
|
|
155
|
+
bad = tuple((u, v) for (u, v, lu, lv) in edges if lu == frm and lv == to)
|
|
156
|
+
if bad:
|
|
157
|
+
violations.append(Violation(
|
|
158
|
+
"forbidden",
|
|
159
|
+
f"`{frm}` must not import `{to}`",
|
|
160
|
+
edges=tuple(sorted(bad)),
|
|
161
|
+
))
|
|
162
|
+
|
|
163
|
+
# -- no_cycles: import graph must be acyclic --------------------------------
|
|
164
|
+
if contract.no_cycles:
|
|
165
|
+
cycles = query.import_cycles()
|
|
166
|
+
if cycles:
|
|
167
|
+
worst = sorted(cycles, key=lambda c: (len(c), c))
|
|
168
|
+
violations.append(Violation(
|
|
169
|
+
"no_cycles",
|
|
170
|
+
f"{len(cycles)} import cycle(s)",
|
|
171
|
+
modules=tuple(" → ".join(c) + " → " + c[0] for c in worst),
|
|
172
|
+
))
|
|
173
|
+
|
|
174
|
+
# -- exhaustive: every core module's layer must be declared -----------------
|
|
175
|
+
if contract.exhaustive and contract.layers:
|
|
176
|
+
declared = set(contract.layers)
|
|
177
|
+
undeclared = sorted({
|
|
178
|
+
query._layer_of(m) for m in query.import_graph.nodes
|
|
179
|
+
if query.root_of(m) == "core"
|
|
180
|
+
and query._layer_of(m) not in declared
|
|
181
|
+
and query._layer_of(m) != "(root)"
|
|
182
|
+
})
|
|
183
|
+
if undeclared:
|
|
184
|
+
violations.append(Violation(
|
|
185
|
+
"exhaustive",
|
|
186
|
+
f"{len(undeclared)} undeclared layer(s) not in the contract",
|
|
187
|
+
modules=tuple(undeclared),
|
|
188
|
+
))
|
|
189
|
+
|
|
190
|
+
return violations
|