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
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