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/incremental.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Incremental graph rebuild — recompute only what changed (R1-C9).
|
|
2
|
+
|
|
3
|
+
A full deep extract of bquant is ~97s, and **~93s of that is the jedi type-inference
|
|
4
|
+
tier** (the two passes ``add_behavior(deep=True)`` and ``add_attrflow(deep=True)``);
|
|
5
|
+
everything else — griffe load, structural nodes/edges, dispatch, family links,
|
|
6
|
+
string-key dataflow — is ~4s together. So the incremental win is entirely in *not
|
|
7
|
+
re-running jedi on modules that didn't change*.
|
|
8
|
+
|
|
9
|
+
The strategy, given that split:
|
|
10
|
+
|
|
11
|
+
1. Rebuild the **cheap, deterministic** part whole and fresh every time (structural
|
|
12
|
+
base + dispatch + family + dataflow). This is always identical to a full build, so
|
|
13
|
+
there is zero splice risk there.
|
|
14
|
+
2. Run the **expensive** jedi passes only on the *affected* modules.
|
|
15
|
+
3. **Splice** the two jedi-produced contributions (behavioral ``calls`` edges +
|
|
16
|
+
``accesses`` edges, and the per-function ``calls``/``control``/``complexity``/
|
|
17
|
+
``attr_access`` node extras) for the unaffected modules straight from the old graph.
|
|
18
|
+
|
|
19
|
+
**Affected set** = changed/added/removed modules, plus any module that (a) freshly
|
|
20
|
+
imports a changed/added module, or (b) had an old behavioral edge into a changed or
|
|
21
|
+
removed module. That covers both fast-tier (import/module resolution to a renamed
|
|
22
|
+
symbol) and deep-tier (jedi reaching a changed target) staleness. When the affected
|
|
23
|
+
set is large relative to the package, a full rebuild is cheaper and certainly correct,
|
|
24
|
+
so we fall back to it.
|
|
25
|
+
|
|
26
|
+
The acceptance bar (BACKLOG R1-C9) is **byte-identical to a full rebuild**; the test
|
|
27
|
+
suite pins exactly that across edit / add / remove scenarios on both tiers.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import copy
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from codemap.extract.griffe_extractor import (
|
|
36
|
+
add_behavioral_layer, build_structural, extract,
|
|
37
|
+
)
|
|
38
|
+
from codemap.model import Graph
|
|
39
|
+
from codemap.provenance import build_provenance, tool_identity
|
|
40
|
+
from codemap.scope import diff_scopes
|
|
41
|
+
|
|
42
|
+
# Behavioral `calls` resolutions produced by add_behavior (vs registry-dispatch,
|
|
43
|
+
# which add_dispatch produces whole & fresh — those must NOT be spliced from old).
|
|
44
|
+
_BEHAVIOR_CALL_RES = frozenset({"module", "self", "imported", "deep"})
|
|
45
|
+
# R1-C22: `references` resolutions the behavioral pass owns (a name used as a value, or
|
|
46
|
+
# as a type annotation). The consumer/doc references carry other resolutions and belong
|
|
47
|
+
# to the repo-scope pass, which the incremental path does not touch.
|
|
48
|
+
_BEHAVIOR_REF_RES = frozenset({"name", "annotation"})
|
|
49
|
+
# Node-extras keys owned by the two jedi-sensitive passes (spliced for unaffected).
|
|
50
|
+
_BEHAVIORAL_EXTRAS = ("calls", "control", "complexity", "attr_access")
|
|
51
|
+
# Old edge types whose target landing in a changed/removed module makes the source
|
|
52
|
+
# module stale (it must re-resolve). Only the spliced passes matter here.
|
|
53
|
+
_DEP_EDGE_TYPES = frozenset({"calls", "accesses", "references"})
|
|
54
|
+
|
|
55
|
+
# Above this fraction of modules affected, a full rebuild is cheaper (and trivially
|
|
56
|
+
# correct) — no point splicing most of the graph.
|
|
57
|
+
_FULL_FALLBACK_FRACTION = 0.5
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _path_to_module(rel_path: str, target_pkg: str) -> str | None:
|
|
61
|
+
"""Map a scope file path to a package module id, or None if it's not package code.
|
|
62
|
+
|
|
63
|
+
``bquant/analysis/pipeline.py`` → ``bquant.analysis.pipeline``;
|
|
64
|
+
``bquant/analysis/__init__.py`` → ``bquant.analysis``; ``bquant/__init__.py`` →
|
|
65
|
+
``bquant``. Non-``.py`` files and files outside the package return None (they
|
|
66
|
+
don't produce module nodes in a single-package extract).
|
|
67
|
+
"""
|
|
68
|
+
p = rel_path.replace("\\", "/")
|
|
69
|
+
if not p.endswith(".py"):
|
|
70
|
+
return None
|
|
71
|
+
parts = p.split("/")
|
|
72
|
+
if not parts or parts[0] != target_pkg:
|
|
73
|
+
return None
|
|
74
|
+
if parts[-1] == "__init__.py":
|
|
75
|
+
parts = parts[:-1]
|
|
76
|
+
else:
|
|
77
|
+
parts[-1] = parts[-1][:-3] # strip .py
|
|
78
|
+
return ".".join(parts)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _module_indexer(module_ids):
|
|
82
|
+
"""Return ``module_of(node_id)`` — the longest module id that owns the node."""
|
|
83
|
+
ordered = sorted(module_ids, key=len, reverse=True)
|
|
84
|
+
|
|
85
|
+
def module_of(node_id: str) -> str | None:
|
|
86
|
+
for m in ordered:
|
|
87
|
+
if node_id == m or node_id.startswith(m + "."):
|
|
88
|
+
return m
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
return module_of
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _affected_modules(old_graph, new_graph, base_mods, changed_removed, module_of):
|
|
95
|
+
"""Modules whose jedi passes must re-run (see module docstring for the rules)."""
|
|
96
|
+
affected = set(base_mods)
|
|
97
|
+
# rule (a): a module that freshly imports a changed/added module.
|
|
98
|
+
for e in new_graph.edges:
|
|
99
|
+
if e.type == "imports" and e.target in base_mods:
|
|
100
|
+
affected.add(e.source)
|
|
101
|
+
# rule (b): a module whose old behavioral edge targeted a changed/removed module.
|
|
102
|
+
for e in old_graph.edges:
|
|
103
|
+
if e.type in _DEP_EDGE_TYPES:
|
|
104
|
+
tgt_mod = module_of(e.target)
|
|
105
|
+
if tgt_mod in changed_removed:
|
|
106
|
+
src_mod = module_of(e.source)
|
|
107
|
+
if src_mod is not None:
|
|
108
|
+
affected.add(src_mod)
|
|
109
|
+
return affected
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _splice_unaffected(new_graph, old_graph, unaffected, module_of) -> None:
|
|
113
|
+
"""Copy the two jedi passes' output for unaffected modules from the old graph."""
|
|
114
|
+
for e in old_graph.edges:
|
|
115
|
+
keep = False
|
|
116
|
+
if e.type == "calls" and e.extras.get("resolution") in _BEHAVIOR_CALL_RES:
|
|
117
|
+
keep = module_of(e.source) in unaffected
|
|
118
|
+
elif e.type == "accesses":
|
|
119
|
+
keep = module_of(e.source) in unaffected
|
|
120
|
+
elif e.type == "references" and e.extras.get("resolution") in _BEHAVIOR_REF_RES:
|
|
121
|
+
keep = module_of(e.source) in unaffected # R1-C22: name/annotation refs
|
|
122
|
+
if keep:
|
|
123
|
+
new_graph.add_edge(copy.deepcopy(e))
|
|
124
|
+
for nid, node in new_graph.nodes.items():
|
|
125
|
+
if module_of(nid) in unaffected and nid in old_graph.nodes:
|
|
126
|
+
old_extras = old_graph.nodes[nid].extras
|
|
127
|
+
for k in _BEHAVIORAL_EXTRAS:
|
|
128
|
+
if k in old_extras:
|
|
129
|
+
node.extras[k] = copy.deepcopy(old_extras[k])
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _same_builder(provenance: dict, tier: str) -> bool:
|
|
133
|
+
"""Was the old graph produced by *this* codemap, on this tier? (R1-C25)
|
|
134
|
+
|
|
135
|
+
A graph with no provenance (pre-0.12) cannot answer, so it is treated as a
|
|
136
|
+
different builder — the conservative direction: a needless full rebuild costs a
|
|
137
|
+
minute, a silently stale graph costs a wrong answer.
|
|
138
|
+
"""
|
|
139
|
+
if not provenance:
|
|
140
|
+
return False
|
|
141
|
+
return (provenance.get("tool") == tool_identity()
|
|
142
|
+
and provenance.get("tier") == tier)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def update_graph(old_graph: Graph, package_path, old_scope: dict, new_scope: dict,
|
|
146
|
+
*, deep: bool = False) -> tuple[Graph, dict]:
|
|
147
|
+
"""Incrementally rebuild ``old_graph`` for the current source tree.
|
|
148
|
+
|
|
149
|
+
Returns ``(graph, info)`` where ``info`` records the decision (``mode``:
|
|
150
|
+
``unchanged`` | ``incremental`` | ``full`` and the affected module list). The
|
|
151
|
+
result is byte-identical to ``extract(package_path, deep=deep)`` — the cheap
|
|
152
|
+
layers are rebuilt whole and the expensive jedi passes are recomputed for the
|
|
153
|
+
affected modules and spliced from the old graph for the rest.
|
|
154
|
+
"""
|
|
155
|
+
target_pkg = old_graph.target
|
|
156
|
+
tier = "deep" if deep else "fast"
|
|
157
|
+
# R1-C25: the input is not the only thing that can change. `unchanged` below decides
|
|
158
|
+
# from the source tree alone, so an upgraded codemap over an untouched tree used to
|
|
159
|
+
# return yesterday's graph built by yesterday's extractor — the exact confusion the
|
|
160
|
+
# provenance block exists to name. A different tool or tier is a full rebuild.
|
|
161
|
+
if not _same_builder(old_graph.provenance, tier):
|
|
162
|
+
graph = extract(package_path, deep=deep)
|
|
163
|
+
return graph, {"mode": "full", "affected": [], "reason": "builder-changed"}
|
|
164
|
+
|
|
165
|
+
d = diff_scopes(old_scope, new_scope)
|
|
166
|
+
changed = {m for p in d["changed"] if (m := _path_to_module(p, target_pkg))}
|
|
167
|
+
added = {m for p in d["added"] if (m := _path_to_module(p, target_pkg))}
|
|
168
|
+
removed = {m for p in d["removed"] if (m := _path_to_module(p, target_pkg))}
|
|
169
|
+
|
|
170
|
+
# No package .py file changed → a single-package graph is unaffected (doc/consumer
|
|
171
|
+
# edits don't touch it). Return the old graph untouched.
|
|
172
|
+
if not (changed or added or removed):
|
|
173
|
+
return old_graph, {"mode": "unchanged", "affected": []}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
graph, root, module_name, search_path = build_structural(package_path)
|
|
177
|
+
module_ids = {n.id for n in graph.nodes.values() if n.kind == "module"}
|
|
178
|
+
module_of = _module_indexer(module_ids)
|
|
179
|
+
|
|
180
|
+
base_mods = (changed | added | removed) & module_ids
|
|
181
|
+
changed_removed = (changed | removed)
|
|
182
|
+
affected = _affected_modules(old_graph, graph, base_mods, changed_removed,
|
|
183
|
+
module_of) & module_ids
|
|
184
|
+
|
|
185
|
+
if not module_ids or len(affected) >= _FULL_FALLBACK_FRACTION * len(module_ids):
|
|
186
|
+
add_behavioral_layer(graph, root, module_name, search_path, deep=deep)
|
|
187
|
+
graph.provenance = build_provenance(tier=tier, inputs=graph.provenance.get("inputs"))
|
|
188
|
+
return graph, {"mode": "full", "affected": sorted(affected)}
|
|
189
|
+
|
|
190
|
+
add_behavioral_layer(graph, root, module_name, search_path, deep=deep,
|
|
191
|
+
behavior_only=affected, attr_only=affected)
|
|
192
|
+
unaffected = module_ids - affected
|
|
193
|
+
_splice_unaffected(graph, old_graph, unaffected, module_of)
|
|
194
|
+
graph.provenance = build_provenance(tier=tier, inputs=graph.provenance.get("inputs"))
|
|
195
|
+
return graph, {"mode": "incremental", "affected": sorted(affected)}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""External-tool integration layer (DESIGN §13 / §13.1).
|
|
2
|
+
|
|
3
|
+
The opt-in adapter/router framework over third-party code-analysis tools. The core
|
|
4
|
+
never depends on it — an integration is only reached when a user opts in
|
|
5
|
+
(``codemap.toml``) and the tool is installed. See :mod:`codemap.integrations.base`
|
|
6
|
+
for the five-mode model and the two output contracts.
|
|
7
|
+
|
|
8
|
+
Concrete integrations register themselves on import (gitnexus router — PolyForm-NC;
|
|
9
|
+
cocoindex adapter — Apache-2.0); importing this package must have no external side
|
|
10
|
+
effects (dict inserts only; subprocess/availability calls are lazy).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .base import (
|
|
16
|
+
GraphFragment,
|
|
17
|
+
Integration,
|
|
18
|
+
IntegrationMode,
|
|
19
|
+
RawAnswer,
|
|
20
|
+
SemanticHit,
|
|
21
|
+
is_permissive,
|
|
22
|
+
)
|
|
23
|
+
from .gate import IntegrationConfig, load_config
|
|
24
|
+
from .registry import (
|
|
25
|
+
all_integrations,
|
|
26
|
+
get,
|
|
27
|
+
register,
|
|
28
|
+
resolve,
|
|
29
|
+
unregister,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Import concrete integrations so they self-register (dict inserts only — no I/O;
|
|
33
|
+
# availability/subprocess calls happen lazily at resolve/route time). DESIGN §13.1.
|
|
34
|
+
from . import gitnexus as _gitnexus # noqa: E402,F401 (router, PolyForm-NC)
|
|
35
|
+
from . import cocoindex as _cocoindex # noqa: E402,F401 (adapter, Apache-2.0)
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"GraphFragment",
|
|
39
|
+
"Integration",
|
|
40
|
+
"IntegrationMode",
|
|
41
|
+
"RawAnswer",
|
|
42
|
+
"SemanticHit",
|
|
43
|
+
"is_permissive",
|
|
44
|
+
"IntegrationConfig",
|
|
45
|
+
"load_config",
|
|
46
|
+
"all_integrations",
|
|
47
|
+
"get",
|
|
48
|
+
"register",
|
|
49
|
+
"resolve",
|
|
50
|
+
"unregister",
|
|
51
|
+
]
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Integration contracts — the two deep-coupling modes that need shared infra.
|
|
2
|
+
|
|
3
|
+
DESIGN §13 / §13.1. Five integration modes exist on a depth×license gradient:
|
|
4
|
+
|
|
5
|
+
0. reimplement (learn-and-build) — our own code, their idea; no tool, no infra
|
|
6
|
+
1. vendoring (copy source in) — like `_scip_pb2.py`; permissive license only
|
|
7
|
+
2. library embed (pip dependency) — an extractor plugin; permissive license only
|
|
8
|
+
3. adapter — call user-installed tool, TRANSLATE its output
|
|
9
|
+
into our neutral graph; permissive only
|
|
10
|
+
4. router / passthrough — call user-installed tool, FORWARD its answer
|
|
11
|
+
as-is; even non-commercial OK (opt-in + notice)
|
|
12
|
+
|
|
13
|
+
Modes 0–2 reuse existing patterns (native code, vendored subpackage, `extract/`
|
|
14
|
+
plugin). Only **adapter** and **router** need this package: they call an external
|
|
15
|
+
tool the user installed and either absorb its output (adapter) or forward it
|
|
16
|
+
(router). The invariant (DESIGN §13): the core never depends on an external tool —
|
|
17
|
+
every integration is opt-in and the baseline works without it.
|
|
18
|
+
|
|
19
|
+
Two output contracts, because the modes return fundamentally different things:
|
|
20
|
+
|
|
21
|
+
* an **adapter** yields a :class:`GraphFragment` — nodes/edges in our schema,
|
|
22
|
+
tagged ``provenance: external`` + resolver name, written to a **non-canonical
|
|
23
|
+
sidecar** (the canonical core graph stays deterministic — many external tools
|
|
24
|
+
are not);
|
|
25
|
+
* a **router** yields a :class:`RawAnswer` — the tool's answer untouched, which
|
|
26
|
+
never enters the graph.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import abc
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from enum import Enum
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class IntegrationMode(str, Enum):
|
|
38
|
+
"""The coupling mode of an integration (the two that need this infra)."""
|
|
39
|
+
|
|
40
|
+
ADAPTER = "adapter" # translate the tool's output into our graph (permissive only)
|
|
41
|
+
ROUTER = "router" # forward the tool's answer as-is (any license, opt-in)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Licenses under which we may **absorb** a tool's output into our artifact (adapter)
|
|
45
|
+
# or depend on it. Anything else is router-only: we forward, never incorporate.
|
|
46
|
+
# This is the machine-checkable half of the DESIGN §13.1 licensing policy.
|
|
47
|
+
_PERMISSIVE = frozenset({
|
|
48
|
+
"mit", "apache-2.0", "apache 2.0", "apache", "bsd", "bsd-2-clause",
|
|
49
|
+
"bsd-3-clause", "isc", "unlicense", "0bsd", "psf",
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_permissive(license_id: str) -> bool:
|
|
54
|
+
"""True if ``license_id`` permits absorbing the tool's output (adapter-eligible).
|
|
55
|
+
|
|
56
|
+
Normalizes case/spacing; unknown or non-commercial licenses (e.g. PolyForm
|
|
57
|
+
Noncommercial) return False → such a tool may only be a :class:`IntegrationMode.ROUTER`.
|
|
58
|
+
"""
|
|
59
|
+
return license_id.strip().lower() in _PERMISSIVE
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class GraphFragment:
|
|
64
|
+
"""Adapter output — graph nodes/edges to be merged into a **non-canonical sidecar**.
|
|
65
|
+
|
|
66
|
+
Every node/edge is stamped with provenance so it never masquerades as part of
|
|
67
|
+
the deterministic core graph: ``extras.provenance = 'external'`` and
|
|
68
|
+
``extras.resolver = <tool name>``. ``deterministic`` records whether the source
|
|
69
|
+
tool produces stable output — False (the common case, e.g. graphlens' SQLite)
|
|
70
|
+
is why fragments live in a sidecar, not the canonical graph.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
resolver: str
|
|
74
|
+
nodes: list[dict[str, Any]] = field(default_factory=list)
|
|
75
|
+
edges: list[dict[str, Any]] = field(default_factory=list)
|
|
76
|
+
deterministic: bool = False
|
|
77
|
+
|
|
78
|
+
def stamped(self) -> "GraphFragment":
|
|
79
|
+
"""Return a copy with every node/edge tagged external + resolver."""
|
|
80
|
+
tag = {"provenance": "external", "resolver": self.resolver}
|
|
81
|
+
nodes = [{**n, "extras": {**n.get("extras", {}), **tag}} for n in self.nodes]
|
|
82
|
+
edges = [{**e, "extras": {**e.get("extras", {}), **tag}} for e in self.edges]
|
|
83
|
+
return GraphFragment(self.resolver, nodes, edges, self.deterministic)
|
|
84
|
+
|
|
85
|
+
def to_sidecar_dict(self) -> dict[str, Any]:
|
|
86
|
+
"""Serialize for the ``external_edges.json`` sidecar (non-canonical)."""
|
|
87
|
+
f = self.stamped()
|
|
88
|
+
return {
|
|
89
|
+
"resolver": f.resolver,
|
|
90
|
+
"deterministic": f.deterministic,
|
|
91
|
+
"canonical": False, # explicit: never part of the deterministic core graph
|
|
92
|
+
"nodes": f.nodes,
|
|
93
|
+
"edges": f.edges,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class RawAnswer:
|
|
99
|
+
"""Router output — the external tool's answer, forwarded untouched.
|
|
100
|
+
|
|
101
|
+
It is never parsed into our schema. ``source`` names the tool; ``disclaimer`` is
|
|
102
|
+
the licensing notice shown to the user; ``payload`` is whatever the tool returned.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
source: str
|
|
106
|
+
payload: Any
|
|
107
|
+
disclaimer: str | None = None
|
|
108
|
+
|
|
109
|
+
def to_dict(self) -> dict[str, Any]:
|
|
110
|
+
return {"source": self.source, "passthrough": True,
|
|
111
|
+
"disclaimer": self.disclaimer, "payload": self.payload}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class SemanticHit:
|
|
116
|
+
"""Retrieval-adapter output — one hit, **enriched to a codemap symbol**.
|
|
117
|
+
|
|
118
|
+
A retrieval adapter (a semantic-search tool) returns fuzzy locations (``file`` +
|
|
119
|
+
line range + ``score``). codemap's own graph then resolves each to the exact
|
|
120
|
+
symbol at that location (``Query.symbol_at``), so the answer is codemap-native —
|
|
121
|
+
the "fuzzy retrieval → exact structure" composition neither tool gives alone.
|
|
122
|
+
``symbol`` is the resolved node id (None when the location isn't in the graph);
|
|
123
|
+
``resolution`` records how it resolved.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
file: str
|
|
127
|
+
start_line: int
|
|
128
|
+
score: float
|
|
129
|
+
end_line: int | None = None
|
|
130
|
+
symbol: str | None = None # codemap node id at (file, start_line)
|
|
131
|
+
resolution: str = "unresolved" # "symbol" | "unresolved"
|
|
132
|
+
|
|
133
|
+
def to_dict(self) -> dict[str, Any]:
|
|
134
|
+
return {
|
|
135
|
+
"symbol": self.symbol,
|
|
136
|
+
"resolution": self.resolution,
|
|
137
|
+
"score": self.score,
|
|
138
|
+
"file": self.file,
|
|
139
|
+
"lines": [self.start_line, self.end_line],
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Integration(abc.ABC):
|
|
144
|
+
"""One external tool, wired in adapter or router mode.
|
|
145
|
+
|
|
146
|
+
Subclasses declare identity + the capabilities they provide and implement
|
|
147
|
+
availability detection. Adapters implement :meth:`extract_fragments`; routers
|
|
148
|
+
implement :meth:`route`. The registry enforces the licensing policy
|
|
149
|
+
(adapters must be permissive-licensed) at registration time.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
#: short, stable identifier (e.g. "graphlens", "gitnexus")
|
|
153
|
+
name: str
|
|
154
|
+
#: :class:`IntegrationMode`
|
|
155
|
+
mode: IntegrationMode
|
|
156
|
+
#: SPDX-ish license id (checked by :func:`is_permissive`)
|
|
157
|
+
license: str
|
|
158
|
+
#: capability keys this tool provides (e.g. ("resolve-into-deps",))
|
|
159
|
+
capabilities: tuple[str, ...] = ()
|
|
160
|
+
|
|
161
|
+
@abc.abstractmethod
|
|
162
|
+
def is_available(self) -> bool:
|
|
163
|
+
"""True if the user has the tool installed and it is usable."""
|
|
164
|
+
|
|
165
|
+
def disclaimer(self) -> str | None:
|
|
166
|
+
"""One-time licensing notice, or None for a permissive tool.
|
|
167
|
+
|
|
168
|
+
For a non-commercial tool the notice is worded on **use**, not reselling
|
|
169
|
+
(DESIGN §13.1 п.3): routing to it is only for non-commercial use.
|
|
170
|
+
"""
|
|
171
|
+
if is_permissive(self.license):
|
|
172
|
+
return None
|
|
173
|
+
return (
|
|
174
|
+
f"Tool '{self.name}' is licensed {self.license}. This route is for "
|
|
175
|
+
f"non-commercial use only; for commercial use of codemap, do not enable "
|
|
176
|
+
f"it, or obtain a commercial license from the tool's author."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# -- mode-specific surfaces (implement the one for your mode) -------------
|
|
180
|
+
|
|
181
|
+
def extract_fragments(self, target: str, **kw: Any) -> GraphFragment: # adapter
|
|
182
|
+
raise NotImplementedError(f"{self.name} is not an adapter")
|
|
183
|
+
|
|
184
|
+
def search(self, capability: str, query: str, **kw: Any) -> list[dict[str, Any]]:
|
|
185
|
+
"""Retrieval-adapter surface: run the tool, return raw hits.
|
|
186
|
+
|
|
187
|
+
Each hit is a plain dict ``{file, start_line, end_line, score}`` (locations,
|
|
188
|
+
not yet codemap symbols). codemap's ``serve.semantic.semantic_search``
|
|
189
|
+
enriches these into :class:`SemanticHit`\\s against the graph — the adapter
|
|
190
|
+
itself never needs the graph (which keeps ``integrations`` a near-leaf layer,
|
|
191
|
+
below ``query``). Implement this for a retrieval-class adapter.
|
|
192
|
+
"""
|
|
193
|
+
raise NotImplementedError(f"{self.name} is not a search adapter")
|
|
194
|
+
|
|
195
|
+
def route(self, capability: str, question: str, **kw: Any) -> RawAnswer: # router
|
|
196
|
+
raise NotImplementedError(f"{self.name} is not a router")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""cocoindex-code adapter (DESIGN §13.1, mode 3 — adapter / retrieval).
|
|
2
|
+
|
|
3
|
+
cocoindex-code (`ccc`, github.com/cocoindex-io/cocoindex-code) is an **Apache-2.0**
|
|
4
|
+
semantic code-search CLI: tree-sitter chunking + local embeddings, an embedded
|
|
5
|
+
index (no DB, no API key). codemap has no fuzzy/semantic layer by design, so this
|
|
6
|
+
is the first tool that fills that gap — and because its license is permissive, it
|
|
7
|
+
can be an **adapter** (we translate its output into codemap's own contract), not a
|
|
8
|
+
router. See ``research/tools/cocoindex-code.md``.
|
|
9
|
+
|
|
10
|
+
The "translation" is the whole point: `ccc` returns fuzzy locations
|
|
11
|
+
(``file`` + line range + score); codemap resolves each to the **exact symbol** at
|
|
12
|
+
that location via its graph (:func:`codemap.integrations.semantic.semantic_search`).
|
|
13
|
+
So a concept query comes back as ranked codemap symbols — the "fuzzy retrieval →
|
|
14
|
+
exact structure" composition neither tool gives alone.
|
|
15
|
+
|
|
16
|
+
This module only builds the argv and returns raw hits; enrichment against the graph
|
|
17
|
+
lives in the semantic module (the adapter never needs the graph). Opt-in (the
|
|
18
|
+
registry gates on ``codemap.toml``); reached only when `ccc` is installed. Anything
|
|
19
|
+
wrong — missing binary, non-JSON — degrades to "no hits" (``run_json`` → None).
|
|
20
|
+
|
|
21
|
+
`ccc` keys off its per-repo index in the working directory, so the caller passes the
|
|
22
|
+
repo ``root`` as the subprocess cwd (that's where ``ccc index`` was run).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from .base import Integration, IntegrationMode
|
|
30
|
+
from .registry import register
|
|
31
|
+
from .transport import run_json, which
|
|
32
|
+
|
|
33
|
+
_BINARY = "ccc"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CocoIndexAdapter(Integration):
|
|
37
|
+
name = "cocoindex"
|
|
38
|
+
mode = IntegrationMode.ADAPTER
|
|
39
|
+
license = "Apache-2.0"
|
|
40
|
+
capabilities = ("semantic-search",)
|
|
41
|
+
|
|
42
|
+
def is_available(self) -> bool:
|
|
43
|
+
return which(_BINARY) is not None
|
|
44
|
+
|
|
45
|
+
def _argv(self, query: str, limit: int) -> list[str]:
|
|
46
|
+
"""`ccc search <query> --limit N --json` (version-tolerant; drift → no hits)."""
|
|
47
|
+
return [_BINARY, "search", query, "--limit", str(limit), "--json"]
|
|
48
|
+
|
|
49
|
+
def search(self, capability: str, query: str, **kw: Any) -> list[dict[str, Any]]:
|
|
50
|
+
"""Run `ccc search` in the repo ``root`` and return raw location hits.
|
|
51
|
+
|
|
52
|
+
Returns ``[{file, start_line, end_line, score}, …]`` (already sorted by score
|
|
53
|
+
by `ccc`); enrichment to codemap symbols happens in the semantic module.
|
|
54
|
+
"""
|
|
55
|
+
if capability not in self.capabilities:
|
|
56
|
+
raise ValueError(f"cocoindex does not provide {capability!r}")
|
|
57
|
+
root = kw.get("root")
|
|
58
|
+
limit = int(kw.get("limit", 10))
|
|
59
|
+
payload = run_json(self._argv(query, limit),
|
|
60
|
+
timeout=float(kw.get("timeout", 120.0)), cwd=root)
|
|
61
|
+
if not isinstance(payload, dict) or not payload.get("success"):
|
|
62
|
+
return []
|
|
63
|
+
hits = []
|
|
64
|
+
for r in payload.get("results", []):
|
|
65
|
+
fp = r.get("file_path")
|
|
66
|
+
sl = r.get("start_line")
|
|
67
|
+
if fp is None or sl is None:
|
|
68
|
+
continue # a hit we can't anchor is useless for enrichment
|
|
69
|
+
hits.append({
|
|
70
|
+
"file": fp,
|
|
71
|
+
"start_line": int(sl),
|
|
72
|
+
"end_line": r.get("end_line"),
|
|
73
|
+
"score": float(r.get("score", 0.0)),
|
|
74
|
+
})
|
|
75
|
+
return hits
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
register(CocoIndexAdapter())
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Opt-in gate + licensing disclaimer for external integrations (DESIGN §13.1).
|
|
2
|
+
|
|
3
|
+
Every integration is **off by default** (invariant: the core works with no external
|
|
4
|
+
tool). A user enables one explicitly in ``codemap.toml``::
|
|
5
|
+
|
|
6
|
+
[integrations]
|
|
7
|
+
enabled = ["graphlens", "gitnexus"] # opt-in, not default
|
|
8
|
+
acknowledged = ["gitnexus"] # licensing notice already accepted
|
|
9
|
+
|
|
10
|
+
``enabled`` is the opt-in list (DESIGN §13.1 п.2). ``acknowledged`` records which
|
|
11
|
+
non-commercial notices the user has already accepted, so the disclaimer (п.3) is
|
|
12
|
+
shown once rather than every call. Absent config → nothing enabled.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from codemap.tomlio import read_toml
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class IntegrationConfig:
|
|
25
|
+
"""Resolved ``[integrations]`` config — which tools are opted in / acknowledged.
|
|
26
|
+
|
|
27
|
+
``error`` carries the reason ``codemap.toml`` could not be read, if it could not be
|
|
28
|
+
(R1-C27). Nothing is enabled either way — the opt-in invariant is not weakened by a
|
|
29
|
+
read failure — but a user who wrote an opt-in list and got a typo deserves to hear
|
|
30
|
+
that, rather than watch the integration quietly stay off.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
enabled: frozenset[str] = frozenset()
|
|
34
|
+
acknowledged: frozenset[str] = frozenset()
|
|
35
|
+
error: str | None = None
|
|
36
|
+
|
|
37
|
+
def is_enabled(self, name: str) -> bool:
|
|
38
|
+
return name in self.enabled
|
|
39
|
+
|
|
40
|
+
def is_acknowledged(self, name: str) -> bool:
|
|
41
|
+
return name in self.acknowledged
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_config(root: str | Path = ".") -> IntegrationConfig:
|
|
45
|
+
"""Read ``[integrations]`` from ``codemap.toml`` under ``root`` (empty if absent).
|
|
46
|
+
|
|
47
|
+
A malformed file yields an empty config rather than raising — a bad opt-in list must
|
|
48
|
+
never break a plain build — but the reason travels back in ``error`` instead of being
|
|
49
|
+
indistinguishable from "no config" (R1-C27).
|
|
50
|
+
"""
|
|
51
|
+
data, error = read_toml(Path(root) / "codemap.toml")
|
|
52
|
+
if error:
|
|
53
|
+
return IntegrationConfig(error=error)
|
|
54
|
+
section = data.get("integrations", {})
|
|
55
|
+
return IntegrationConfig(
|
|
56
|
+
enabled=frozenset(section.get("enabled", []) or []),
|
|
57
|
+
acknowledged=frozenset(section.get("acknowledged", []) or []),
|
|
58
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""GitNexus router (DESIGN §13.1, mode 4 — passthrough).
|
|
2
|
+
|
|
3
|
+
GitNexus (github.com/…/gitnexus) is a TS/Node code-graph tool with capabilities
|
|
4
|
+
codemap lacks: **BM25 + local-embedding semantic search (RRF)**, Leiden community
|
|
5
|
+
clusters, and process/flow tracing. It is licensed **PolyForm Noncommercial 1.0.0**,
|
|
6
|
+
so per DESIGN §13.1 it can only be a **router**: we forward the question and return
|
|
7
|
+
its answer untouched — never absorb it into our graph (that would require a
|
|
8
|
+
permissive license), never bundle it (calling ≠ distributing, п.1).
|
|
9
|
+
|
|
10
|
+
This module only builds the argv and wraps the reply; it is opt-in (the registry
|
|
11
|
+
gates on ``codemap.toml``) and reached only when the user has ``gitnexus`` installed.
|
|
12
|
+
Anything wrong — missing binary, bad flag, non-JSON — degrades to "unavailable"
|
|
13
|
+
(``transport.run_json`` returns None), never wrong data.
|
|
14
|
+
|
|
15
|
+
GitNexus ships as an npm package; a plain ``npm install gitnexus`` puts the binary
|
|
16
|
+
in a local ``node_modules/.bin`` (1.7 GB of native modules), **not** on the global
|
|
17
|
+
PATH — so ``which('gitnexus')`` alone would report "unavailable" even right after a
|
|
18
|
+
successful install. ``_launcher`` therefore prefers a global binary but falls back
|
|
19
|
+
to ``npx --no-install gitnexus``, which runs a locally-installed package **without**
|
|
20
|
+
triggering a surprise network download (if it isn't installed, ``npx --no-install``
|
|
21
|
+
fails and we degrade to None — on-brand: no egress, no bundling).
|
|
22
|
+
|
|
23
|
+
CLI flag details are version-specific (measured against v1.6.9, see
|
|
24
|
+
``research/tools/gitnexus.md``); the graceful-degrade contract means a flag drift
|
|
25
|
+
surfaces as "no answer", not a crash.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from .base import Integration, IntegrationMode, RawAnswer
|
|
33
|
+
from .registry import register
|
|
34
|
+
from .transport import run_json, which
|
|
35
|
+
|
|
36
|
+
_BINARY = "gitnexus"
|
|
37
|
+
|
|
38
|
+
# capability → the GitNexus verb that serves it (documented verbs, v1.6.9).
|
|
39
|
+
_VERB = {
|
|
40
|
+
"semantic-search": "search", # BM25 + local embeddings + RRF
|
|
41
|
+
"flow-narrative": "trace", # process/flow tracing from entry points
|
|
42
|
+
"community-clusters": "check", # Leiden clusters (with --clusters)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class GitNexusRouter(Integration):
|
|
47
|
+
name = "gitnexus"
|
|
48
|
+
mode = IntegrationMode.ROUTER
|
|
49
|
+
license = "PolyForm-Noncommercial-1.0.0"
|
|
50
|
+
capabilities = tuple(_VERB)
|
|
51
|
+
|
|
52
|
+
def _launcher(self) -> list[str] | None:
|
|
53
|
+
"""How to invoke gitnexus, or None if it can't be reached.
|
|
54
|
+
|
|
55
|
+
Global binary on PATH wins; else ``npx --no-install gitnexus`` (serves a
|
|
56
|
+
local ``npm install gitnexus`` without a global install and without a
|
|
57
|
+
surprise download); else None → the capability is unavailable.
|
|
58
|
+
"""
|
|
59
|
+
if which(_BINARY) is not None:
|
|
60
|
+
return [_BINARY]
|
|
61
|
+
if which("npx") is not None:
|
|
62
|
+
return ["npx", "--no-install", _BINARY]
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
def is_available(self) -> bool:
|
|
66
|
+
return self._launcher() is not None
|
|
67
|
+
|
|
68
|
+
def _verb_argv(self, capability: str, question: str) -> list[str]:
|
|
69
|
+
"""The gitnexus-specific argv tail (verb + args), launcher-independent."""
|
|
70
|
+
verb = _VERB[capability]
|
|
71
|
+
argv = [verb]
|
|
72
|
+
if capability == "community-clusters":
|
|
73
|
+
argv += ["--clusters"]
|
|
74
|
+
else:
|
|
75
|
+
argv += [question]
|
|
76
|
+
return argv + ["--json"]
|
|
77
|
+
|
|
78
|
+
def _argv(self, capability: str, question: str, launcher: list[str]) -> list[str]:
|
|
79
|
+
"""Full argv = how-to-invoke + what-to-run (best-effort, version-specific)."""
|
|
80
|
+
return launcher + self._verb_argv(capability, question)
|
|
81
|
+
|
|
82
|
+
def route(self, capability: str, question: str, **kw: Any) -> RawAnswer:
|
|
83
|
+
if capability not in self.capabilities:
|
|
84
|
+
raise ValueError(f"gitnexus does not provide {capability!r}")
|
|
85
|
+
launcher = self._launcher()
|
|
86
|
+
payload = None if launcher is None else run_json(
|
|
87
|
+
self._argv(capability, question, launcher),
|
|
88
|
+
timeout=float(kw.get("timeout", 120.0)))
|
|
89
|
+
return RawAnswer(source=self.name, payload=payload,
|
|
90
|
+
disclaimer=self.disclaimer())
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
register(GitNexusRouter())
|