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/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())