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.
Files changed (55) hide show
  1. codemap/__init__.py +10 -0
  2. codemap/apidiff.py +208 -0
  3. codemap/arch.py +190 -0
  4. codemap/cli.py +718 -0
  5. codemap/diagnostics.py +256 -0
  6. codemap/extract/__init__.py +10 -0
  7. codemap/extract/attrflow.py +230 -0
  8. codemap/extract/behavior.py +771 -0
  9. codemap/extract/dataflow.py +97 -0
  10. codemap/extract/dispatch.py +248 -0
  11. codemap/extract/griffe_extractor.py +496 -0
  12. codemap/extract/gsource.py +83 -0
  13. codemap/extract/roots.py +427 -0
  14. codemap/freshness.py +94 -0
  15. codemap/incremental.py +195 -0
  16. codemap/integrations/__init__.py +51 -0
  17. codemap/integrations/base.py +196 -0
  18. codemap/integrations/cocoindex.py +78 -0
  19. codemap/integrations/gate.py +58 -0
  20. codemap/integrations/gitnexus.py +93 -0
  21. codemap/integrations/registry.py +69 -0
  22. codemap/integrations/transport.py +46 -0
  23. codemap/model.py +178 -0
  24. codemap/provenance.py +248 -0
  25. codemap/query.py +1164 -0
  26. codemap/scope.py +212 -0
  27. codemap/serve/__init__.py +26 -0
  28. codemap/serve/_scip_pb2.py +100 -0
  29. codemap/serve/api_surface.py +60 -0
  30. codemap/serve/apidiff.py +83 -0
  31. codemap/serve/architecture.py +101 -0
  32. codemap/serve/audit.py +176 -0
  33. codemap/serve/check.py +80 -0
  34. codemap/serve/ctags.py +203 -0
  35. codemap/serve/impact.py +84 -0
  36. codemap/serve/livingdocs.py +174 -0
  37. codemap/serve/mcp_server.py +278 -0
  38. codemap/serve/mermaid.py +120 -0
  39. codemap/serve/pack.py +93 -0
  40. codemap/serve/rag.py +142 -0
  41. codemap/serve/review.py +197 -0
  42. codemap/serve/scip.py +183 -0
  43. codemap/serve/semantic.py +71 -0
  44. codemap/serve/server.py +43 -0
  45. codemap/serve/session.py +482 -0
  46. codemap/serve/subsystems.py +85 -0
  47. codemap/serve/vault.py +156 -0
  48. codemap/store.py +28 -0
  49. codemap/tomlio.py +59 -0
  50. codmap-0.0.3.dist-info/METADATA +245 -0
  51. codmap-0.0.3.dist-info/RECORD +55 -0
  52. codmap-0.0.3.dist-info/WHEEL +5 -0
  53. codmap-0.0.3.dist-info/entry_points.txt +2 -0
  54. codmap-0.0.3.dist-info/licenses/LICENSE +21 -0
  55. codmap-0.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,69 @@
1
+ """Integration registry — register tools, resolve a capability to a live one.
2
+
3
+ DESIGN §13.1. The registry is where the licensing policy is machine-enforced: an
4
+ **adapter** (which absorbs the tool's output into our artifact) must be permissive-
5
+ licensed; a **router** (which only forwards) may be any license. Registration of a
6
+ non-permissive adapter is a programming error and raises.
7
+
8
+ Resolution is **capability-first** (the chosen UX, DESIGN §13.1 decision): a caller
9
+ asks for a capability ("semantic-search", "resolve-into-deps"), and the registry
10
+ returns an integration that (a) provides it, (b) is opted-in via config, and (c) is
11
+ actually installed. The concrete tool stays hidden behind the capability.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .base import Integration, IntegrationMode, is_permissive
17
+ from .gate import IntegrationConfig, load_config
18
+
19
+ _REGISTRY: dict[str, Integration] = {}
20
+
21
+
22
+ def register(integration: Integration) -> Integration:
23
+ """Register an integration, enforcing the DESIGN §13.1 licensing policy.
24
+
25
+ Raises ``ValueError`` if an adapter is not permissive-licensed (adapters absorb
26
+ output into our artifact → MIT/Apache only; a non-commercial tool can only be a
27
+ router). Idempotent-friendly: re-registering the same name overwrites.
28
+ """
29
+ if (integration.mode is IntegrationMode.ADAPTER
30
+ and not is_permissive(integration.license)):
31
+ raise ValueError(
32
+ f"adapter '{integration.name}' has non-permissive license "
33
+ f"{integration.license!r}: a tool whose output we absorb must be "
34
+ f"MIT/Apache-class (DESIGN §13.1); use router mode instead."
35
+ )
36
+ _REGISTRY[integration.name] = integration
37
+ return integration
38
+
39
+
40
+ def unregister(name: str) -> None:
41
+ _REGISTRY.pop(name, None)
42
+
43
+
44
+ def get(name: str) -> Integration | None:
45
+ return _REGISTRY.get(name)
46
+
47
+
48
+ def all_integrations() -> list[Integration]:
49
+ return [_REGISTRY[n] for n in sorted(_REGISTRY)]
50
+
51
+
52
+ def resolve(capability: str, *, config: IntegrationConfig | None = None,
53
+ root: str = ".", mode: IntegrationMode | None = None) -> Integration | None:
54
+ """Return an enabled + installed integration providing ``capability``, or None.
55
+
56
+ Capability-first dispatch: the tool is picked for the caller. Requires all three
57
+ gates — provides the capability, opted-in (``config.enabled``), and
58
+ ``is_available()`` — so the core degrades cleanly when nothing satisfies them.
59
+ ``mode`` narrows to one coupling mode (e.g. ADAPTER when the caller must enrich
60
+ the output, not just forward it). Deterministic tie-break by name.
61
+ """
62
+ cfg = config if config is not None else load_config(root)
63
+ for integ in all_integrations(): # sorted by name → deterministic pick
64
+ if (capability in integ.capabilities
65
+ and (mode is None or integ.mode is mode)
66
+ and cfg.is_enabled(integ.name)
67
+ and integ.is_available()):
68
+ return integ
69
+ return None
@@ -0,0 +1,46 @@
1
+ """Transport helpers for adapters/routers that call an external tool (DESIGN §13).
2
+
3
+ Modes 3–4 call a **user-installed** tool as a subprocess (or MCP client) — codemap
4
+ bundles nothing (DESIGN §13.1 п.1: calling ≠ distributing). These helpers keep that
5
+ call uniform: locate the binary, run it, parse JSON. Failures return None rather
6
+ than raising, so a flaky external tool degrades to "capability unavailable", never a
7
+ crash of the core.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import shutil
14
+ import subprocess
15
+ from typing import Any
16
+
17
+
18
+ def which(binary: str) -> str | None:
19
+ """Absolute path to a user-installed binary on PATH, or None."""
20
+ return shutil.which(binary)
21
+
22
+
23
+ def run_json(cmd: list[str], *, timeout: float = 120.0,
24
+ input_text: str | None = None, cwd: str | None = None) -> Any | None:
25
+ """Run ``cmd``, parse stdout as JSON, return it (or None on any failure).
26
+
27
+ A non-zero exit, timeout, missing binary, or non-JSON stdout all yield None —
28
+ the caller treats that as "the external tool couldn't answer" and falls back to
29
+ the deterministic core. ``cwd`` runs the tool in a specific directory (some
30
+ tools, e.g. a per-repo index, key off the working dir).
31
+ """
32
+ if not cmd or which(cmd[0]) is None:
33
+ return None
34
+ try:
35
+ proc = subprocess.run(
36
+ cmd, capture_output=True, text=True, timeout=timeout,
37
+ input=input_text, cwd=cwd,
38
+ )
39
+ except (OSError, subprocess.TimeoutExpired):
40
+ return None
41
+ if proc.returncode != 0:
42
+ return None
43
+ try:
44
+ return json.loads(proc.stdout)
45
+ except (ValueError, TypeError):
46
+ return None
codemap/model.py ADDED
@@ -0,0 +1,178 @@
1
+ """Neutral code-graph model (DESIGN §2).
2
+
3
+ Language-neutral core: a node is ``kind + attrs``; edges are typed. Python-isms
4
+ live in ``extras`` provided by the extractor, never baked into the core. The JSON
5
+ form (DESIGN §2.2) is the canonical store: deterministic (sorted, no timestamps)
6
+ so it diffs cleanly.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field, asdict
12
+ from typing import Any
13
+
14
+ # Bump on any change to the JSON schema (invariant, like bquant's CACHE_SCHEMA_VERSION).
15
+ # 0.2: M1.5 — inherits / decorated_by edges; attribute annotations, is_dataclass and
16
+ # dynamic-registration keys in node extras (closes gap-doc CM-01/02/06/07/08).
17
+ # 0.3: M4 — best-effort `calls` edges (resolution-labeled); per-function extras
18
+ # `calls` coverage, `control` skeleton, structured `params`/`returns` for
19
+ # type-flow (partially closes CM-03/09/10/11/12).
20
+ # 0.4: M6 — repo scope / impact (multi-root). Nodes carry provenance
21
+ # (`extras.root`: core | tests | examples | research | scripts | docs);
22
+ # `doc` node kind + `references` edge (consumer/doc → core symbol) let
23
+ # blast-radius reach beyond the package (closes gap-doc F1).
24
+ # 0.5: M7 — registry-aware call bridging. `calls` edges gain resolution
25
+ # `registry` (literal key → exact impl) and `registry-candidate` (factory/
26
+ # getter → all family impls, honest over-approximation) so the call chain
27
+ # reconnects at factory/registry dispatch seams (closes gap-doc F5).
28
+ # 0.6: M9 — registry-family Protocol links. `implements` edge (concrete impl →
29
+ # the Protocol it structurally satisfies, matched via the registry family)
30
+ # makes the family queryable and diagrammable though it's never inherited
31
+ # (closes gap-doc F4).
32
+ # 0.7: M11 — call-site argument contract. `calls` edges carry `callsites`
33
+ # (how many call expressions collapsed into this edge) and the observed
34
+ # argument shape (`posargs` / `kwargs` / `splat`) so signature-change
35
+ # reasoning is possible (closes gap-doc F7).
36
+ # 0.8: M12 — string-key dataflow. `column` node per string subscript key
37
+ # (`column:macd_hist`) with `writes`/`reads` edges (function → column) so
38
+ # "who produces/consumes this DataFrame column" is queryable (closes
39
+ # gap-doc F6). Over-set of columns (dict keys land here too — honest).
40
+ # 0.9: M14 — dataflow access-form (soundness/F15). `column` node carries
41
+ # `extras.subscripted` (bool — key was ever accessed as `x['k']`, not only a
42
+ # dict-literal payload key); `reads`/`writes` edges carry `extras.access`
43
+ # (`subscript` | `dict-literal`). B1 dogfood found 71% of column nodes were
44
+ # dict-literal-only payload keys (result dicts, config, rcParams) — the
45
+ # `subscripted` flag lets aggregates surface the ~29% real column-like set
46
+ # while per-key queries and the F6 producer edge stay intact.
47
+ # 0.10: R1-C4 — per-function complexity metrics. `extras.complexity` on function
48
+ # nodes carries `cc` (McCabe cyclomatic), `volume` (Halstead), `sloc` (physical
49
+ # span) and `mi` (Maintainability Index, 0–100). Computed in the behavioral AST
50
+ # pass (source-only, stdlib-only, deterministic); Query.hotspots blends them with
51
+ # structural coupling so "complex by McCabe" ranks alongside "big by connectivity".
52
+ # 0.11: R1-C20 — attribute-access edges. `accesses` edge (function → the `attribute`
53
+ # node it reads/writes), `extras.access` (`read` | `write`) + `extras.resolution`
54
+ # (`self` | `class` | `construct` | `deep`). Emitted by the `extract/attrflow.py`
55
+ # pass (fast `ast` for self./ClassName./construction-kwargs, deep `jedi` for typed
56
+ # `obj.field`). Wired into impact/references_to so a field's blast-radius is real;
57
+ # an attribute with no modelled accessor reports risk `unknown` (lower bound), never
58
+ # `none` (closes the issue #1 honesty gap — see gaps/attribute_impact_gap_2026-08-22).
59
+ # 0.12: R1-C25 — build provenance. New top-level ``provenance`` block: tool identity
60
+ # (name/version/commit — commit absent, never guessed, when installed from a
61
+ # wheel), ``tier`` (fast|deep), input ``scope_id`` (M19.A) and the source vcs
62
+ # commit + dirty flag. **Timestamp-free and path-free by construction** — the
63
+ # clock and the absolute `cwd` stay in the `*.meta.json` sidecar, so the graph
64
+ # remains byte-identical across two builds of a frozen tree and safe to publish.
65
+ # ``codemap_schema`` is now *read* on load: a mismatch raises a diagnostic instead
66
+ # of being silently accepted (gaps/graph_provenance_2026-08-25 — the same tree
67
+ # built four commits apart gave 30 vs 38 edges under one declared schema).
68
+ SCHEMA_VERSION = "0.12"
69
+
70
+ # Closed vocabulary of edge types (R1-C7). Node ``kind`` is deliberately an OPEN set
71
+ # (DESIGN §2 — new entity kinds may appear), but edges are TYPED: every relationship
72
+ # codemap emits is one of these, each with a fixed meaning. This is the machine-
73
+ # checkable contract — a new relationship must be added here (and documented) rather
74
+ # than emitted silently; ``tests/test_r1c7_edge_vocab.py`` fails if a graph carries a
75
+ # type not in this set (or if a declared type stops appearing on the dogfood target).
76
+ EDGE_TYPES = frozenset({
77
+ "contains", # parent → member (module→class/func, class→method)
78
+ "imports", # module → module it imports (internal, resolved to canonical)
79
+ "export", # package → symbol it re-exports (extras.public marks __all__)
80
+ "inherits", # class → base class (extras.external for out-of-package bases)
81
+ "decorated_by", # symbol → the decorator applied to it
82
+ "calls", # caller function → callee (extras.resolution: how it resolved)
83
+ "references", # consumer/doc/dispatch site → the core symbol it names
84
+ "implements", # concrete class → the Protocol it structurally satisfies (M9)
85
+ "reads", # function → string-keyed column it reads (extras.access)
86
+ "writes", # function → string-keyed column it writes (extras.access)
87
+ "accesses", # function → attribute node it reads/writes (extras.access, R1-C20)
88
+ })
89
+
90
+
91
+ @dataclass
92
+ class Node:
93
+ """A code entity. ``id`` is its canonical definition path (DESIGN §2.1)."""
94
+
95
+ id: str
96
+ kind: str # module | class | function | attribute | doc | column (open set — DESIGN §2)
97
+ file: str | None = None
98
+ lineno: int | None = None
99
+ endlineno: int | None = None
100
+ signature: str | None = None
101
+ docstring: str | None = None
102
+ visibility: str = "public" # public | private
103
+ decorators: list[str] = field(default_factory=list)
104
+ is_deprecated: bool = False
105
+ extras: dict[str, Any] = field(default_factory=dict)
106
+
107
+
108
+ @dataclass
109
+ class Edge:
110
+ """A typed relationship between two nodes (by id)."""
111
+
112
+ type: str # one of EDGE_TYPES (closed vocabulary, R1-C7): contains | imports |
113
+ # export | inherits | decorated_by | calls | references | implements |
114
+ # reads | writes | accesses (§2)
115
+ source: str
116
+ target: str
117
+ extras: dict[str, Any] = field(default_factory=dict)
118
+
119
+
120
+ @dataclass
121
+ class Graph:
122
+ """The code graph: nodes + edges over a single target package."""
123
+
124
+ target: str
125
+ nodes: dict[str, Node] = field(default_factory=dict)
126
+ edges: list[Edge] = field(default_factory=list)
127
+ #: R1-C25 — what produced this graph (see ``codemap/provenance.py``). Serialized.
128
+ provenance: dict[str, Any] = field(default_factory=dict)
129
+ #: The ``codemap_schema`` this graph was *loaded* from, or None for a fresh build.
130
+ #: Not serialized and not part of equality — it describes the file, not the graph.
131
+ loaded_schema: str | None = field(default=None, compare=False, repr=False)
132
+
133
+ def add_node(self, node: Node) -> None:
134
+ self.nodes[node.id] = node
135
+
136
+ def add_edge(self, edge: Edge) -> None:
137
+ self.edges.append(edge)
138
+
139
+ # -- canonical serialization (deterministic — DESIGN §2.2) --------------
140
+
141
+ def to_dict(self) -> dict[str, Any]:
142
+ import json as _json
143
+ nodes = [asdict(self.nodes[nid]) for nid in sorted(self.nodes)]
144
+ # Sort by the full edge content, not just (type, source, target): two edges
145
+ # can share that triple but differ in `extras` (e.g. a behavioral `calls` and
146
+ # a registry-dispatch `calls`). Including a stable render of `extras` in the
147
+ # key makes the order **insertion-independent**, so an incremental rebuild
148
+ # (R1-C9), which splices edges in a different order, serializes identically to
149
+ # a full build.
150
+ edges = sorted(
151
+ (asdict(e) for e in self.edges),
152
+ key=lambda e: (e["type"], e["source"], e["target"],
153
+ _json.dumps(e["extras"], sort_keys=True, ensure_ascii=False)),
154
+ )
155
+ return {
156
+ "codemap_schema": SCHEMA_VERSION,
157
+ "target": self.target,
158
+ # R1-C25: always emitted, even empty — a stable shape diffs cleanly, and an
159
+ # empty block is itself the honest statement "this build recorded nothing".
160
+ "provenance": self.provenance,
161
+ "nodes": nodes,
162
+ "edges": edges,
163
+ }
164
+
165
+ @classmethod
166
+ def from_dict(cls, data: dict[str, Any]) -> "Graph":
167
+ g = cls(target=data["target"])
168
+ g.provenance = data.get("provenance") or {}
169
+ # Read the schema the file declares (R1-C25/D3). It used to be written and never
170
+ # read, so a graph predating an extraction change was consumed without a word.
171
+ # "" (not None) when the file declared nothing: None must keep meaning
172
+ # "this graph was never loaded from a file", so a fresh build stays quiet.
173
+ g.loaded_schema = data.get("codemap_schema", "")
174
+ for n in data["nodes"]:
175
+ g.add_node(Node(**n))
176
+ for e in data["edges"]:
177
+ g.add_edge(Edge(**e))
178
+ return g
codemap/provenance.py ADDED
@@ -0,0 +1,248 @@
1
+ """Build provenance — what produced this graph, and from what (R1-C25).
2
+
3
+ ``graph.json`` is a claim: *this is the shape of that source tree, as read by this
4
+ tool*. Until now it recorded the claim and dropped both qualifiers — four top-level
5
+ keys, no tool identity, no input identity, and a ``codemap_schema`` that was written
6
+ and never read. One frozen tree built by two codemap versions four commits apart gave
7
+ 30 edges vs 38 and 12 vs 7 ``high`` dead-code verdicts, with **both files declaring
8
+ schema 0.11** — correctly, because only open ``extras`` had changed. Provenance is not
9
+ schema (gaps/graph_provenance_2026-08-25.md).
10
+
11
+ Two rules shape everything here:
12
+
13
+ - **No clock.** The canonical graph is timestamp-free so two builds of a frozen tree
14
+ are byte-identical; a timestamp would destroy exactly the property this block exists
15
+ to make checkable. Wall-clock stays in the ``*.meta.json`` sidecar.
16
+ - **No absolute paths.** The graph is the half that travels — into a ticket, a sibling
17
+ repo, an agent's context. A personal path in it is a leak (AGENTS.md), so paths here
18
+ are repo-relative or a bare name, never a location.
19
+
20
+ Design: ``docs/design/graph_provenance.md`` (D1–D7).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import subprocess
26
+ from functools import lru_cache
27
+ from pathlib import Path
28
+
29
+ #: What the tool calls itself — the command, the import, the repository. This is the
30
+ #: identity recorded in every graph, so it stays put: changing it would make every
31
+ #: existing 0.12 graph incomparable with a new one over a packaging detail.
32
+ TOOL_NAME = "codemap"
33
+
34
+ #: What the tool is *distributed* as. Different from `TOOL_NAME` because `codemap` was
35
+ #: already taken on PyPI. Only the version lookup uses it; if these are ever confused,
36
+ #: `version()` raises PackageNotFoundError and the version silently vanishes from
37
+ #: provenance — the failure this split exists to prevent (the wheel CI job asserts it).
38
+ DIST_NAME = "codmap"
39
+
40
+ #: Schema comparison outcomes (D3).
41
+ MATCH, OLDER, NEWER, UNKNOWN = "match", "older", "newer", "unknown"
42
+
43
+
44
+ # -- tool identity (D2) -------------------------------------------------------
45
+
46
+ @lru_cache(maxsize=1)
47
+ def _tool_version() -> str | None:
48
+ from importlib.metadata import PackageNotFoundError, version
49
+ try:
50
+ return version(DIST_NAME)
51
+ except (PackageNotFoundError, ImportError):
52
+ return None
53
+
54
+
55
+ @lru_cache(maxsize=8)
56
+ def _commit_of(root: Path) -> str | None:
57
+ """Short HEAD of the checkout at ``root``, or None when it is not one."""
58
+ if not (Path(root) / ".git").exists():
59
+ return None
60
+ try:
61
+ out = subprocess.run(("git", "-C", str(root), "rev-parse", "--short", "HEAD"),
62
+ capture_output=True, text=True, check=True)
63
+ except (OSError, subprocess.CalledProcessError):
64
+ return None
65
+ return out.stdout.strip() or None
66
+
67
+
68
+ def _tool_commit() -> str | None:
69
+ """Short commit of codemap's **own** checkout, when it runs from a source tree.
70
+
71
+ Absent — not ``"unknown"``, not a guess — when installed from a wheel. The package
72
+ version alone is not an identity: every graph in the R1-C20…R1-C22 series was built
73
+ by version ``0.0.2``, which is why this field exists at all.
74
+ """
75
+ return _commit_of(Path(__file__).resolve().parent.parent)
76
+
77
+
78
+ @lru_cache(maxsize=8)
79
+ def _is_dirty(root: Path) -> bool | None:
80
+ """Does the checkout at ``root`` have uncommitted changes? None when not a checkout."""
81
+ if not (Path(root) / ".git").exists():
82
+ return None
83
+ try:
84
+ out = subprocess.run(("git", "-C", str(root), "status", "--porcelain"),
85
+ capture_output=True, text=True, check=True)
86
+ except (OSError, subprocess.CalledProcessError):
87
+ return None
88
+ return bool(out.stdout.strip())
89
+
90
+
91
+ def tool_identity() -> dict:
92
+ """``{name, version?, commit?, dirty?}`` — only the fields we actually know.
93
+
94
+ ``dirty`` mirrors what ``source`` records about the target, and for the same reason:
95
+ two builds from the same commit with different working trees are two different tools.
96
+ Measured the hard way — comparing a worktree at HEAD against a dirty checkout of the
97
+ same HEAD, both reported the identical commit and were plainly not the same builder.
98
+ """
99
+ root = Path(__file__).resolve().parent.parent
100
+ ident: dict = {"name": TOOL_NAME}
101
+ version, commit, dirty = _tool_version(), _tool_commit(), _is_dirty(root)
102
+ if version:
103
+ ident["version"] = version
104
+ if commit:
105
+ ident["commit"] = commit
106
+ if dirty is not None:
107
+ ident["dirty"] = dirty
108
+ return ident
109
+
110
+
111
+ # -- the block ----------------------------------------------------------------
112
+
113
+ def canonicalize(value):
114
+ """Sort every dict key, recursively — the block must serialize identically twice."""
115
+ if isinstance(value, dict):
116
+ return {k: canonicalize(value[k]) for k in sorted(value)}
117
+ if isinstance(value, list):
118
+ return [canonicalize(v) for v in value]
119
+ return value
120
+
121
+
122
+ def relative_root(root: str | Path | None, path: str | Path) -> str:
123
+ """``path`` relative to ``root``, or its bare name — never an absolute path (D5)."""
124
+ p = Path(path)
125
+ if root is not None:
126
+ try:
127
+ return Path(p).resolve().relative_to(Path(root).resolve()).as_posix() or "."
128
+ except (ValueError, OSError):
129
+ pass
130
+ return p.name or "."
131
+
132
+
133
+ def build_provenance(*, tier: str, scope: dict | None = None,
134
+ roots: dict | None = None, inputs: dict | None = None) -> dict:
135
+ """Assemble the ``provenance`` block. Deterministic; no clock, no absolute path."""
136
+ prov: dict = {"tool": tool_identity(), "tier": tier}
137
+ if inputs:
138
+ # R1-C23/D2: what the extractor read, and what it could not. Belongs with the
139
+ # identity rather than in the sidecar — a consumer holding only the graph is
140
+ # exactly the one who must be told the tree was read incompletely.
141
+ prov["inputs"] = inputs
142
+ if scope:
143
+ if scope.get("scope_id"):
144
+ prov["scope_id"] = scope["scope_id"]
145
+ git = scope.get("git") or {}
146
+ if git.get("mode") == "git" and git.get("commit"):
147
+ source = {"vcs": "git", "commit": git["commit"][:12],
148
+ "dirty": bool(git.get("dirty"))}
149
+ if git.get("ref"):
150
+ source["ref"] = git["ref"]
151
+ prov["source"] = source
152
+ if roots:
153
+ prov["roots"] = roots
154
+ block = canonicalize(prov)
155
+ leaked = absolute_paths(block)
156
+ if leaked:
157
+ raise ValueError(f"provenance must stay path-free, got {leaked[0]!r}")
158
+ return block
159
+
160
+
161
+ def absolute_paths(value, _path=()) -> list[str]:
162
+ """Every string in ``value`` that looks like an absolute filesystem path (D5)."""
163
+ found: list[str] = []
164
+ if isinstance(value, dict):
165
+ for k in sorted(value):
166
+ found += absolute_paths(value[k], _path + (k,))
167
+ elif isinstance(value, list):
168
+ for i, v in enumerate(value):
169
+ found += absolute_paths(v, _path + (str(i),))
170
+ elif isinstance(value, str):
171
+ if value.startswith("/") or value.startswith("\\\\") or (
172
+ len(value) > 2 and value[1] == ":" and value[2] in "\\/"):
173
+ found.append(value)
174
+ return found
175
+
176
+
177
+ # -- schema comparison (D3) ---------------------------------------------------
178
+
179
+ def _parts(v: str) -> tuple[int, ...] | None:
180
+ try:
181
+ return tuple(int(p) for p in str(v).split("."))
182
+ except (TypeError, ValueError):
183
+ return None
184
+
185
+
186
+ def schema_status(loaded: str | None, running: str) -> str:
187
+ """``match`` | ``older`` | ``newer`` | ``unknown`` — how a stored graph compares."""
188
+ if loaded is None:
189
+ return UNKNOWN
190
+ if loaded == running:
191
+ return MATCH
192
+ a, b = _parts(loaded), _parts(running)
193
+ if a is None or b is None:
194
+ return UNKNOWN
195
+ return OLDER if a < b else NEWER
196
+
197
+
198
+ # -- comparability of two graphs (D4) -----------------------------------------
199
+
200
+ def _tool_str(prov: dict) -> str:
201
+ t = (prov or {}).get("tool") or {}
202
+ bits = [t.get("name") or "?"]
203
+ if t.get("version"):
204
+ bits.append(t["version"])
205
+ if t.get("commit"):
206
+ bits.append(f"@{t['commit']}")
207
+ return " ".join(bits)
208
+
209
+
210
+ def describe(prov: dict | None) -> str:
211
+ """One-line human rendering of a provenance block (``unrecorded`` when absent)."""
212
+ if not prov:
213
+ return "unrecorded (built before schema 0.12)"
214
+ bits = [_tool_str(prov), f"tier={prov.get('tier', '?')}"]
215
+ src = prov.get("source") or {}
216
+ if src.get("commit"):
217
+ bits.append(f"source={src['commit']}{'+dirty' if src.get('dirty') else ''}")
218
+ if prov.get("scope_id"):
219
+ bits.append(f"scope={prov['scope_id'][7:19]}")
220
+ return ", ".join(bits)
221
+
222
+
223
+ def comparability(old: dict | None, new: dict | None) -> dict:
224
+ """Are two graphs a before/after of the *code*, or of the *tool*? (D4)
225
+
226
+ Never a refusal — comparing across an upgrade is a legitimate thing to want. What
227
+ was missing is being told: on the R1-C25 evidence pair, ``diff`` said "no breaking
228
+ changes" (true, and useless) about two graphs that disagreed on which functions
229
+ were dead.
230
+ """
231
+ differences: list[str] = []
232
+ if not old or not new:
233
+ differences.append("one of the graphs records no provenance "
234
+ "(built before schema 0.12) — the pair cannot be verified")
235
+ else:
236
+ if _tool_str(old) != _tool_str(new):
237
+ differences.append(f"different tool: {_tool_str(old)} → {_tool_str(new)}")
238
+ if old.get("tier") != new.get("tier"):
239
+ differences.append(f"different tier: {old.get('tier')} → {new.get('tier')}")
240
+ old_roots, new_roots = old.get("roots"), new.get("roots")
241
+ if old_roots != new_roots:
242
+ differences.append(f"different scope roots: {old_roots} → {new_roots}")
243
+ return {
244
+ "comparable": not differences,
245
+ "differences": differences,
246
+ "old": describe(old),
247
+ "new": describe(new),
248
+ }