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
|
@@ -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
|
+
}
|