codegraph-brain 0.6.0__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 (78) hide show
  1. cgis/__init__.py +10 -0
  2. cgis/__main__.py +16 -0
  3. cgis/api/.gitkeep +0 -0
  4. cgis/api/__init__.py +1 -0
  5. cgis/api/mcp_server.py +550 -0
  6. cgis/cli.py +1516 -0
  7. cgis/core/.gitkeep +0 -0
  8. cgis/core/models.py +140 -0
  9. cgis/extractors/.gitkeep +0 -0
  10. cgis/extractors/_python_ast.py +194 -0
  11. cgis/extractors/_python_classes.py +126 -0
  12. cgis/extractors/_python_functions.py +312 -0
  13. cgis/extractors/_python_imports.py +188 -0
  14. cgis/extractors/_python_types.py +84 -0
  15. cgis/extractors/base.py +42 -0
  16. cgis/extractors/python_extractor.py +235 -0
  17. cgis/extractors/typescript_extractor.py +310 -0
  18. cgis/guardian/__init__.py +1 -0
  19. cgis/guardian/bench.py +199 -0
  20. cgis/guardian/chunked.py +240 -0
  21. cgis/guardian/chunker.py +148 -0
  22. cgis/guardian/collector.py +309 -0
  23. cgis/guardian/core.py +120 -0
  24. cgis/guardian/diff_index.py +151 -0
  25. cgis/guardian/findings.py +70 -0
  26. cgis/guardian/github_poster.py +133 -0
  27. cgis/guardian/metrics.py +108 -0
  28. cgis/guardian/prompts.py +217 -0
  29. cgis/guardian/providers/__init__.py +1 -0
  30. cgis/guardian/providers/base.py +112 -0
  31. cgis/guardian/providers/gemini.py +83 -0
  32. cgis/guardian/providers/mistral.py +83 -0
  33. cgis/guardian/providers/ollama.py +99 -0
  34. cgis/guardian/recording.py +82 -0
  35. cgis/guardian/render.py +107 -0
  36. cgis/guardian/runner.py +295 -0
  37. cgis/guardian/skeptic.py +208 -0
  38. cgis/pipeline.py +252 -0
  39. cgis/py.typed +0 -0
  40. cgis/query/analysis/__init__.py +0 -0
  41. cgis/query/analysis/analyzer.py +241 -0
  42. cgis/query/analysis/anomaly.py +34 -0
  43. cgis/query/analysis/cohesion.py +277 -0
  44. cgis/query/analysis/health.py +128 -0
  45. cgis/query/analysis/suggest_service.py +221 -0
  46. cgis/query/context/__init__.py +0 -0
  47. cgis/query/context/audit.py +134 -0
  48. cgis/query/context/context_service.py +129 -0
  49. cgis/query/context/prompt.py +184 -0
  50. cgis/query/context/snippet.py +96 -0
  51. cgis/query/drift/__init__.py +0 -0
  52. cgis/query/drift/_scc.py +90 -0
  53. cgis/query/drift/drift.py +867 -0
  54. cgis/query/drift/drift_service.py +217 -0
  55. cgis/query/drift/fingerprint.py +255 -0
  56. cgis/query/drift/fractal.py +292 -0
  57. cgis/query/drift/ontology_init.py +470 -0
  58. cgis/query/drift/quotient.py +75 -0
  59. cgis/query/drift/triads.py +234 -0
  60. cgis/query/engine.py +170 -0
  61. cgis/query/fqn.py +65 -0
  62. cgis/query/render/__init__.py +0 -0
  63. cgis/query/render/graph_json.py +42 -0
  64. cgis/query/render/mermaid.py +235 -0
  65. cgis/query/render/metrics.py +346 -0
  66. cgis/resolver/.gitkeep +0 -0
  67. cgis/resolver/__init__.py +1 -0
  68. cgis/resolver/engine.py +145 -0
  69. cgis/resolver/indices.py +184 -0
  70. cgis/resolver/symbols.py +179 -0
  71. cgis/resolver/uplift.py +263 -0
  72. cgis/storage/.gitkeep +0 -0
  73. cgis/storage/sqlite_store.py +589 -0
  74. codegraph_brain-0.6.0.dist-info/METADATA +240 -0
  75. codegraph_brain-0.6.0.dist-info/RECORD +78 -0
  76. codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
  77. codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
  78. codegraph_brain-0.6.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,221 @@
1
+ """Package-cohesion orchestration shared by the CLI and the MCP server (#242)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from cgis.core.models import EdgeType
10
+
11
+ if TYPE_CHECKING:
12
+ from cgis.core.models import Edge, Node
13
+
14
+ from cgis.query.analysis.cohesion import (
15
+ THRESHOLDS,
16
+ build_file_graph,
17
+ classify_verdict,
18
+ greedy_modularity,
19
+ layout_direction,
20
+ partition_divergence,
21
+ )
22
+ from cgis.storage.sqlite_store import SQLiteStore
23
+
24
+ _ROOT_GROUP = "<root>"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Community:
29
+ """One detected community: an id and its member files (last FQN segment)."""
30
+
31
+ id: int
32
+ files: list[str]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Bridge:
37
+ """A cross-community edge — the cost of splitting (file names, last segment)."""
38
+
39
+ source: str
40
+ target: str
41
+ weight: float
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class SuggestReport:
46
+ """Full suggest-packages result; serialized verbatim to CLI-json and MCP."""
47
+
48
+ package: str
49
+ layer: str
50
+ file_count: int
51
+ edge_count: int
52
+ modularity_q: float
53
+ divergence: float
54
+ direction: str
55
+ verdict: str
56
+ communities: list[Community]
57
+ bridges: list[Bridge]
58
+ thresholds: dict[str, float]
59
+ # Fraction of files with at least one intra-package edge. A split is only
60
+ # trusted above thresholds["min_connected"] — below it, a high Q is a
61
+ # sparse-graph artifact (most files are independent). 0.0 for no_signal.
62
+ connected_fraction: float = 0.0
63
+ note: str | None = None
64
+
65
+
66
+ def _leaf(fqn: str) -> str:
67
+ """Return the last FQN segment (the module name) for readable output."""
68
+ return fqn.rsplit(".", 1)[-1]
69
+
70
+
71
+ def _dir_group(fqn: str, prefix: str) -> str:
72
+ """Return the file's directory group relative to the package root.
73
+
74
+ A single remaining segment after the prefix maps to the shared ``"<root>"``
75
+ group; two or more map to the first remaining segment (a real sub-directory).
76
+ The package node itself (``fqn == prefix``, e.g. an ``__init__``) is ``<root>``.
77
+ """
78
+ if fqn == prefix:
79
+ return _ROOT_GROUP
80
+ remainder = fqn[len(prefix) + 1 :] if fqn.startswith(prefix + ".") else fqn
81
+ parts = remainder.split(".")
82
+ return _ROOT_GROUP if len(parts) <= 1 else parts[0]
83
+
84
+
85
+ def _empty_report(package: str, layer: str, note: str, file_count: int = 0) -> SuggestReport:
86
+ """Return a no_signal report carrying a diagnostic note (never a silent green).
87
+
88
+ ``file_count`` is passed through for the mis-rooted / flat-leaf-bag cases —
89
+ files WERE found, there were just no intra-package edges to score, so a JSON
90
+ consumer should see the real count, not a misleading 0.
91
+ """
92
+ return SuggestReport(
93
+ package=package,
94
+ layer=layer,
95
+ file_count=file_count,
96
+ edge_count=0,
97
+ modularity_q=0.0,
98
+ divergence=0.0,
99
+ direction="matched",
100
+ verdict="no_signal",
101
+ communities=[],
102
+ bridges=[],
103
+ thresholds=dict(THRESHOLDS),
104
+ note=note,
105
+ )
106
+
107
+
108
+ def suggest_packages(
109
+ db_path: str, prefix: str | None, with_calls: bool = False, min_q: float = 0.35
110
+ ) -> SuggestReport:
111
+ """Detect a package's communities and score layout divergence (#242).
112
+
113
+ ``prefix`` is normalized once here: a ``None`` or blank value (a CLI/MCP
114
+ client may send either) collapses to a ``no_signal`` report rather than a
115
+ crash, so every downstream helper receives a clean non-empty string.
116
+
117
+ Raises:
118
+ FileNotFoundError: if ``db_path`` is not an existing file (run ingest first).
119
+ """
120
+ if not Path(db_path).is_file():
121
+ msg = f"Graph database not found: {db_path}"
122
+ raise FileNotFoundError(msg)
123
+
124
+ layer = "imports+calls" if with_calls else "imports"
125
+ package = (prefix or "").strip()
126
+ if not package:
127
+ return _empty_report("", layer, "no fqn_prefix given")
128
+
129
+ with SQLiteStore(db_path) as store:
130
+ nodes: list[Node] = store.get_all_nodes()
131
+ edges: list[Edge] = store.get_all_edges()
132
+
133
+ graph = build_file_graph(nodes, edges, package, with_calls)
134
+ if not graph.files:
135
+ return _empty_report(package, layer, f"fqn_prefix '{package}' matched 0 nodes")
136
+
137
+ if len(graph.files) < 2:
138
+ # A single matched file is a module (or a one-file package), not something
139
+ # to split. Guard BEFORE the no-internal-edges check below — otherwise the
140
+ # lone module's outbound imports trip the 'mis-rooted' diagnostic falsely
141
+ # (it has import edges, none of which can resolve inside a 1-file set).
142
+ return _empty_report(
143
+ package,
144
+ layer,
145
+ f"'{package}' matched a single module, not a multi-file package — nothing to split",
146
+ file_count=len(graph.files),
147
+ )
148
+
149
+ internal_edges = sum(len(v) for v in graph.adj.values()) // 2
150
+ if internal_edges == 0:
151
+ had_import_attempts = any(
152
+ e.source.startswith(package + ".") or e.source == package
153
+ for e in edges
154
+ if e.type == EdgeType.IMPORTS
155
+ )
156
+ note = (
157
+ f"{package}: files found but no import resolves inside the package — the "
158
+ "graph looks mis-rooted or imports are unresolved; try ingesting the "
159
+ "package's parent directory"
160
+ if had_import_attempts
161
+ else f"{package}: no intra-package imports (a flat leaf bag)"
162
+ )
163
+ return _empty_report(package, layer, note, file_count=len(graph.files))
164
+
165
+ communities, q = greedy_modularity(graph)
166
+ comm_of = {f: i for i, c in enumerate(communities) for f in c}
167
+ dir_of = {f: _dir_group(f, package) for f in graph.files}
168
+ # Clamp to [0, 1]: NMI is mathematically in range, but float error can leak
169
+ # a tiny negative / >1 value that looks odd in JSON.
170
+ divergence = max(0.0, min(1.0, partition_divergence(comm_of, dir_of)))
171
+ direction = layout_direction(comm_of, dir_of)
172
+ thresholds = {**THRESHOLDS, "split": min_q}
173
+ verdict = classify_verdict(q=q, d=divergence, direction=direction, thresholds=thresholds)
174
+
175
+ # Sparse-graph guard: modularity Q is unreliable when most files have no
176
+ # intra-package edge — a single tiny cluster then inflates Q on a package of
177
+ # otherwise-independent helpers (owner-api/utils: 3 edges over 12 files, Q=0.44,
178
+ # 58% isolated → a false 'split'). Only act on it when enough files are coupled.
179
+ # build_file_graph only adds non-empty adjacency entries (keyed by files under
180
+ # the prefix), so adj keys ARE exactly the files with an intra-package edge.
181
+ connected_fraction = len(graph.adj) / len(graph.files)
182
+ sparse_note: str | None = None
183
+ if verdict in ("split", "borderline") and connected_fraction < thresholds["min_connected"]:
184
+ sparse_note = (
185
+ f"only {len(graph.adj)}/{len(graph.files)} ({connected_fraction:.0%}) of files "
186
+ f"are coupled to a sibling — mostly independent helpers, so the Q={q:.2f} is a "
187
+ "sparse-graph artifact, not real community structure; nothing to split"
188
+ )
189
+ verdict = "leave"
190
+
191
+ bridges = sorted(
192
+ (
193
+ Bridge(source=_leaf(a), target=_leaf(b), weight=w)
194
+ for a in graph.adj
195
+ for b, w in graph.adj[a].items()
196
+ if a < b and comm_of[a] != comm_of[b]
197
+ ),
198
+ key=lambda br: (-br.weight, br.source, br.target),
199
+ )
200
+ return SuggestReport(
201
+ package=package,
202
+ layer=layer,
203
+ file_count=len(graph.files),
204
+ edge_count=internal_edges,
205
+ modularity_q=round(q, 4),
206
+ divergence=round(divergence, 4),
207
+ direction=direction,
208
+ verdict=verdict,
209
+ communities=[
210
+ Community(id=i, files=[_leaf(f) for f in c]) for i, c in enumerate(communities)
211
+ ],
212
+ bridges=bridges,
213
+ thresholds=thresholds,
214
+ connected_fraction=round(connected_fraction, 4),
215
+ note=sparse_note,
216
+ )
217
+
218
+
219
+ def report_to_dict(report: SuggestReport) -> dict[str, object]:
220
+ """Return a plain-dict view for JSON (CLI --format json and MCP share this)."""
221
+ return asdict(report)
File without changes
@@ -0,0 +1,134 @@
1
+ """Reachability/coverage audit — does every source reach a required checkpoint? (#172).
2
+
3
+ The headline use is **authorization coverage**: of all the route handlers, which
4
+ ones never transitively reach the ownership check (``verify_resource_ownership``)?
5
+ That's the IDOR-class gap that previously took manual ``impact`` diffing to find.
6
+
7
+ The shape generalizes — handlers that touch storage but never reach a validator,
8
+ mutations that never reach event tracking, routes that bypass the service layer.
9
+
10
+ Reachability follows **enforcement** edges by default — invocation (CALLS) and
11
+ runtime wiring (DEPENDS_ON, AUTHORIZES) — *not* every behavioral edge. This is
12
+ deliberate and load-bearing for a security primitive: merely *importing* or
13
+ *referencing* the guard (IMPORTS/IMPORTS_SYMBOL/REFERENCES) is not enforcing it,
14
+ so counting those as coverage would hide real IDOR gaps (a false "covered" is the
15
+ dangerous direction). Callers can pass a wider ``allowed_edge_types`` explicitly.
16
+ A guard wired via FastAPI ``Depends()`` (a DEPENDS_ON edge, #161) counts — *when
17
+ the resolver uplifts that wiring to the guard node*; an unresolved dynamic
18
+ provider (``raw_dep:``/``raw_call:`` at confidence 0.1) can't be proven and shows
19
+ as a gap.
20
+ """
21
+
22
+ from dataclasses import dataclass
23
+
24
+ from cgis.core.models import EdgeType, Node, NodeNamespace, NodeType
25
+ from cgis.query.engine import QueryEngine
26
+ from cgis.storage.sqlite_store import SQLiteStore
27
+
28
+ # Edges that mean the source *enforces* (invokes / wires) the checkpoint, as
29
+ # opposed to merely importing or naming it. The default for an authz audit:
30
+ # false coverage (import-only) is worse than a false gap for a security linter.
31
+ _ENFORCEMENT_EDGE_TYPES: frozenset[EdgeType] = frozenset(
32
+ {EdgeType.CALLS, EdgeType.DEPENDS_ON, EdgeType.AUTHORIZES}
33
+ )
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class NodeRef:
38
+ """A located reference to a graph node — enough to jump to it in an editor."""
39
+
40
+ fqn: str
41
+ file: str
42
+ line: int
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ReachabilityAudit:
47
+ """Audit outcome: which sources reach ``target`` and which don't."""
48
+
49
+ target: str
50
+ covered: list[NodeRef]
51
+ gaps: list[NodeRef]
52
+
53
+
54
+ def _prefix_matches(fqn: str, prefix: str) -> bool:
55
+ """Dot-boundary prefix match — ``app.routes`` matches ``app.routes.x``, not ``app.routesX``."""
56
+ return fqn == prefix or fqn.startswith(f"{prefix}.")
57
+
58
+
59
+ def _select_sources(
60
+ store: SQLiteStore, from_type: NodeType | None, from_prefix: str | None
61
+ ) -> list[Node]:
62
+ """Select INTERNAL source nodes by node type and/or dot-boundary FQN prefix (AND-combined).
63
+
64
+ Restricted to INTERNAL nodes so the audit never flags stdlib/third-party
65
+ code, and the prefix is boundary-aware so a name overlap (``app.routesX``)
66
+ can't sneak into an ``app.routes`` selection.
67
+ """
68
+ return [
69
+ node
70
+ for node in store.get_all_nodes()
71
+ if node.namespace == NodeNamespace.INTERNAL
72
+ and (from_type is None or node.type == from_type)
73
+ and (from_prefix is None or _prefix_matches(node.id, from_prefix))
74
+ ]
75
+
76
+
77
+ def _ref(node: Node) -> NodeRef:
78
+ """Build a located reference from a node."""
79
+ return NodeRef(fqn=node.id, file=node.file_path, line=node.start_line)
80
+
81
+
82
+ def audit_reachability(
83
+ store: SQLiteStore,
84
+ *,
85
+ target_fqn: str,
86
+ from_type: NodeType | None = None,
87
+ from_prefix: str | None = None,
88
+ max_depth: int = 5,
89
+ allowed_edge_types: frozenset[EdgeType] | None = None,
90
+ ) -> ReachabilityAudit:
91
+ """Split the selected sources into those that reach ``target_fqn`` and those that don't.
92
+
93
+ Sources are chosen by ``from_type`` and/or ``from_prefix`` (at least one is
94
+ required; an empty/whitespace ``from_prefix`` is treated as unset). Coverage
95
+ is decided by a **single upstream traversal** from the checkpoint
96
+ (``get_impact_graph``) up to ``max_depth`` over ``allowed_edge_types``
97
+ (**enforcement** edges — CALLS/DEPENDS_ON/AUTHORIZES — by default, so an
98
+ import-only or reference-only link never counts as coverage): every node that
99
+ reaches the checkpoint within the depth is in that set, so a source is
100
+ *covered* iff it appears there, otherwise a *gap*. One BFS for the whole
101
+ audit, not one per source.
102
+
103
+ ``max_depth`` bounds the proof: a source that only reaches the checkpoint via
104
+ a chain longer than ``max_depth`` is reported as a gap even though it is
105
+ covered at runtime — raise it for deep route→service→crud→…→guard stacks. The
106
+ checkpoint node itself is never audited as its own source.
107
+
108
+ Note on ``target_fqn`` granularity: reaching a node means a behavioral edge
109
+ *to that node*. For a checkpoint that callers invoke (a FUNCTION/METHOD like
110
+ ``verify_resource_ownership``) this is exactly right. Pointing it at a CLASS
111
+ measures *instantiation* (a call to the constructor), NOT method use on an
112
+ already-injected instance — so a dependency-injected collaborator looks like
113
+ a gap. Target the specific method (or the constructor) when auditing "does X
114
+ use this collaborator".
115
+ """
116
+ from_prefix = from_prefix.strip() or None if from_prefix is not None else None
117
+ if from_type is None and from_prefix is None:
118
+ msg = "audit_reachability requires from_type or a non-empty from_prefix to select sources."
119
+ raise ValueError(msg)
120
+ engine = QueryEngine(store)
121
+ # `is not None`, not `or`: an explicit empty frozenset (no-traversal, target-only)
122
+ # is a valid intent and must not be overridden by the enforcement default.
123
+ edge_types = allowed_edge_types if allowed_edge_types is not None else _ENFORCEMENT_EDGE_TYPES
124
+ upstream_nodes, _ = engine.get_impact_graph(
125
+ target_fqn, max_depth=max_depth, allowed_edge_types=edge_types
126
+ )
127
+ reaching = {node.id for node in upstream_nodes}
128
+ covered: list[NodeRef] = []
129
+ gaps: list[NodeRef] = []
130
+ for source in sorted(_select_sources(store, from_type, from_prefix), key=lambda n: n.id):
131
+ if source.id == target_fqn:
132
+ continue
133
+ (covered if source.id in reaching else gaps).append(_ref(source))
134
+ return ReachabilityAudit(target=target_fqn, covered=covered, gaps=gaps)
@@ -0,0 +1,129 @@
1
+ """Orchestrate the GraphRAG context package for a focal FQN (#19).
2
+
3
+ This is the impure seam between the graph (``QueryEngine``/``SQLiteStore``), the
4
+ filesystem (``snippet.py``) and the pure renderer (``prompt.py``) — mirroring the
5
+ ``drift_service`` split. It fetches the focal node's direct CALLS neighbourhood
6
+ and enclosing class, reads its source, and hands everything to
7
+ ``compile_context``.
8
+
9
+ Callers/callees are restricted to CALLS edges so structural noise
10
+ (CONTAINS/DECLARES) stays out of the "who calls / what it calls" lists; the
11
+ enclosing class is recovered separately from the structural layer.
12
+ """
13
+
14
+ from cgis.core.models import EdgeType, Node, NodeNamespace, NodeType
15
+ from cgis.query.context.prompt import compile_context
16
+ from cgis.query.context.snippet import extract_snippet, resolve_source_path
17
+ from cgis.query.engine import QueryEngine
18
+ from cgis.storage.sqlite_store import RAW_CALL_PREFIX, SQLiteStore
19
+
20
+ _CALLS: frozenset[EdgeType] = frozenset({EdgeType.CALLS})
21
+ _STRUCTURAL: frozenset[EdgeType] = frozenset({EdgeType.CONTAINS, EdgeType.DECLARES})
22
+
23
+
24
+ def _sorted_neighbours(nodes: list[Node], focus_fqn: str) -> list[Node]:
25
+ """Keep only INTERNAL neighbours (drop self/builtins/third-party), ordered by location.
26
+
27
+ Resolved EXTERNAL/STDLIB calls (``len``, ``pathlib.Path``, ``ValueError`` …)
28
+ are pure noise in an agent context — the model already knows them — so the
29
+ lists carry only repo-internal nodes. Truly unknown calls still surface via
30
+ the unresolved ``raw_call:`` list.
31
+ """
32
+ return sorted(
33
+ (n for n in nodes if n.id != focus_fqn and n.namespace == NodeNamespace.INTERNAL),
34
+ key=lambda n: (n.file_path, n.start_line, n.id),
35
+ )
36
+
37
+
38
+ def _collect_callers(engine: QueryEngine, focus_fqn: str, depth: int) -> list[Node]:
39
+ """Upstream CALLS neighbours within ``depth`` hops — who reaches the focal node."""
40
+ nodes, _ = engine.get_impact_graph(focus_fqn, max_depth=depth, allowed_edge_types=_CALLS)
41
+ return _sorted_neighbours(nodes, focus_fqn)
42
+
43
+
44
+ def _collect_callees(
45
+ engine: QueryEngine, focus_fqn: str, depth: int
46
+ ) -> tuple[list[Node], list[str]]:
47
+ """Downstream CALLS neighbours within ``depth`` hops, split into resolved + unresolved.
48
+
49
+ Unresolved ``raw_call:`` targets are collected across the whole traversal
50
+ (not just the focal node's own edges) so that at depth>1 they stay symmetric
51
+ with the resolved set — a transitive callee's unknown call surfaces too.
52
+ """
53
+ nodes, edges = engine.get_flow_graph(focus_fqn, max_depth=depth, allowed_edge_types=_CALLS)
54
+ resolved = _sorted_neighbours(nodes, focus_fqn)
55
+ unresolved = sorted(
56
+ {
57
+ edge.target[len(RAW_CALL_PREFIX) :]
58
+ for edge in edges
59
+ if edge.target.startswith(RAW_CALL_PREFIX)
60
+ }
61
+ )
62
+ return resolved, unresolved
63
+
64
+
65
+ def _structural_parent(store: SQLiteStore, focus_fqn: str) -> Node | None:
66
+ """Return the focal node's structural parent, preferring a CLASS over a FILE/MODULE.
67
+
68
+ A node may have several incoming structural edges (a method is DECLARED by a
69
+ class which is itself CONTAINED by a file). We collect every resolvable
70
+ parent — skipping any whose node is missing from the store — then prefer the
71
+ enclosing CLASS so a method's class context is never lost to an earlier
72
+ file-level edge.
73
+ """
74
+ candidates = (
75
+ store.get_node(edge.source)
76
+ for edge in store.get_incoming_edges(focus_fqn)
77
+ if edge.type in _STRUCTURAL
78
+ )
79
+ parents = [node for node in candidates if node is not None]
80
+ for parent in parents:
81
+ if parent.type == NodeType.CLASS:
82
+ return parent
83
+ return parents[0] if parents else None
84
+
85
+
86
+ def _class_context(store: SQLiteStore, focus: Node) -> tuple[Node | None, list[Node]]:
87
+ """Recover the enclosing class and its sibling members (empty for free functions)."""
88
+ parent = _structural_parent(store, focus.id)
89
+ if parent is None or parent.type != NodeType.CLASS:
90
+ return None, []
91
+ members, _ = store.get_structural_subgraph(parent.id, 1)
92
+ siblings = _sorted_neighbours([n for n in members if n.id != parent.id], focus.id)
93
+ return parent, siblings
94
+
95
+
96
+ def build_context(store: SQLiteStore, focus_fqn: str, depth: int = 1, source_root: str = "") -> str:
97
+ """Compile the agent-facing context package for an already-resolved ``focus_fqn``.
98
+
99
+ ``depth`` controls how far the CALLS traversal reaches. The default of 1
100
+ lists only *direct* callers/callees — the honest, high-signal neighbourhood
101
+ for a focused edit. Higher values pull in transitive neighbours; the
102
+ rendered notes state the hop bound rather than calling them "direct" (a
103
+ future adaptive strategy, #220, will scale depth by the node's out-degree).
104
+ ``source_root`` locates the file on disk when the graph was ingested from a
105
+ sub-directory (e.g. ``cgis ingest ./src`` stores ``cgis/...`` paths); see
106
+ ``resolve_source_path`` for the candidate order that also covers a stored
107
+ path already carrying the root segment (#228). Raises ``ValueError`` if the
108
+ FQN is absent.
109
+ """
110
+ focus = store.get_node(focus_fqn)
111
+ if focus is None:
112
+ msg = f"FQN not found in graph: {focus_fqn}"
113
+ raise ValueError(msg)
114
+ engine = QueryEngine(store)
115
+ file_path = resolve_source_path(focus.file_path, source_root)
116
+ source = extract_snippet(file_path, focus.start_line, focus.end_line)
117
+ callers = _collect_callers(engine, focus_fqn, depth)
118
+ callees, unresolved = _collect_callees(engine, focus_fqn, depth)
119
+ class_node, siblings = _class_context(store, focus)
120
+ return compile_context(
121
+ focus=focus,
122
+ source=source,
123
+ class_node=class_node,
124
+ siblings=siblings,
125
+ callers=callers,
126
+ callees=callees,
127
+ unresolved_callees=unresolved,
128
+ depth=depth,
129
+ )
@@ -0,0 +1,184 @@
1
+ """Compile a focal node's subgraph into an agent-facing context package (#19).
2
+
3
+ This is the machine-facing sibling of ``mermaid.py`` (human diagrams) and
4
+ ``graph_json.py`` (joinable JSON): ``cgis context <fqn>`` feeds an LLM coding
5
+ agent a compact, XML-tagged prompt about the node it is about to edit — the
6
+ exact source, the enclosing class, who calls it (upstream ripple) and what it
7
+ calls (downstream dependencies).
8
+
9
+ The shape is deliberately token-lean. Adjacency is rendered as FQN + location
10
+ bullet lists rather than Mermaid (a visual format an LLM parses at several times
11
+ the token cost), and only the focal node's source is inlined — never whole
12
+ files. XML tags give the model unambiguous section boundaries, which mitigates
13
+ the "lost in the middle" failure mode on larger contexts.
14
+
15
+ This module is **pure**: it takes already-fetched nodes/edges and a pre-read
16
+ source string, so it can be unit-tested in isolation. Graph traversal and file
17
+ I/O live in ``context_service.py`` and ``snippet.py`` respectively.
18
+ """
19
+
20
+ import re
21
+
22
+ from cgis.core.models import Node
23
+
24
+ _BACKTICK_RUN = re.compile(r"`+")
25
+ # Only the closing tags this module itself emits — so neutralising them in source
26
+ # defeats prompt-injection without corrupting unrelated markup (e.g. TS/JSX `</div>`).
27
+ _OWN_TAGS = ("source", "context", "class", "domain", "callers", "callees")
28
+ # ``\s*`` before ``>`` matches the whitespace XML allows in an end tag (``</source >``).
29
+ _OWN_CLOSING_TAG = re.compile(r"</(" + "|".join(_OWN_TAGS) + r")\s*>")
30
+
31
+
32
+ def _escape_xml_attr(value: str) -> str:
33
+ """Escape a value for safe inclusion in an XML attribute (``&`` first)."""
34
+ return (
35
+ value.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;")
36
+ )
37
+
38
+
39
+ def _callers_note(depth: int) -> str:
40
+ """Honest <callers> note: 'direct' only at depth 1, hop-bounded above it."""
41
+ if depth <= 1:
42
+ return "direct callers — who calls this (changes here ripple from here)"
43
+ return f"callers within {depth} hops upstream (transitive ripple set)"
44
+
45
+
46
+ def _callees_note(depth: int) -> str:
47
+ """Honest <callees> note: 'direct' only at depth 1, hop-bounded above it."""
48
+ if depth <= 1:
49
+ return "direct callees — what this calls"
50
+ return f"callees within {depth} hops downstream (transitive execution flow)"
51
+
52
+
53
+ def _basename(file_path: str) -> str:
54
+ """Return the file name portion of a (possibly slash-separated) path."""
55
+ return file_path.replace("\\", "/").rsplit("/", maxsplit=1)[-1]
56
+
57
+
58
+ def _loc(node: Node) -> str:
59
+ """Render a compact ``basename:line`` location for a node."""
60
+ return f"{_basename(node.file_path)}:{node.start_line}"
61
+
62
+
63
+ def _fence(code: str) -> str:
64
+ """Wrap ``code`` in a python code fence wide enough to survive inner backticks.
65
+
66
+ Per CommonMark, a fenced block ends only on a backtick run at least as long
67
+ as its opener. Choosing one more backtick than the longest run inside the
68
+ source keeps embedded triple-backticks (or ``</source>``-style strings) from
69
+ prematurely closing the block — the prompt-injection watch-out in #19.
70
+ """
71
+ longest = max((len(run) for run in _BACKTICK_RUN.findall(code)), default=0)
72
+ fence = "`" * max(3, longest + 1)
73
+ body = code if code.endswith("\n") else code + "\n"
74
+ return f"{fence}python\n{body}{fence}"
75
+
76
+
77
+ def _caller_line(node: Node) -> str:
78
+ """Bullet for a caller/callee: full FQN + type + location (joinable)."""
79
+ return f"- {node.id} ({node.type.value}, {_loc(node)})"
80
+
81
+
82
+ def _sibling_line(node: Node) -> str:
83
+ """Bullet for a class sibling: short name + type + location (compact)."""
84
+ return f"- {node.name} ({node.type.value}, {_loc(node)})"
85
+
86
+
87
+ def _neutralize_closing_tags(code: str) -> str:
88
+ """Escape only *this module's* closing tags in source so it can't close the prompt.
89
+
90
+ The adaptive backtick fence stops a code block from closing early, but a file
91
+ literally containing ``</source>``/``</context>`` could otherwise close the
92
+ surrounding XML tag and inject instructions (#19 watch-out #2). We neutralise
93
+ exactly the closing tags this module emits — leaving unrelated markup such as
94
+ TS/JSX ``</div>``, return arrows (``->``) and generics verbatim for the agent.
95
+ """
96
+ return _OWN_CLOSING_TAG.sub(r"&lt;/\1>", code)
97
+
98
+
99
+ def _source_section(source: str) -> str:
100
+ """Render the <source> tag, fencing real code (closing-tag-safe) or noting its absence."""
101
+ if not source.strip():
102
+ return "<source>(source unavailable)</source>"
103
+ return f"<source>\n{_fence(_neutralize_closing_tags(source))}\n</source>"
104
+
105
+
106
+ def _class_section(class_node: Node | None, siblings: list[Node]) -> str:
107
+ """Render the <class> tag with sibling members, or a standalone-function note."""
108
+ if class_node is None:
109
+ return "<class>none — module-level function</class>"
110
+ members = "\n".join(_sibling_line(s) for s in siblings) or "(no other members)"
111
+ name = _escape_xml_attr(class_node.id)
112
+ file_attr = _escape_xml_attr(f"{class_node.file_path}:{class_node.start_line}")
113
+ header = f'<class name="{name}" file="{file_attr}">'
114
+ return f"{header}\n{members}\n</class>"
115
+
116
+
117
+ def _domain_section(focus: Node) -> str | None:
118
+ """Render the L3 <domain> boundary block, or ``None`` when the node has no domain tags.
119
+
120
+ Surfacing the focal node's architectural domain lets an agent respect
121
+ boundary rules during refactoring (the two-layer-context insight from #19).
122
+ Gated on ``focus.domains`` — the real semantic signal — so the structural
123
+ ``ontology_class`` fallback (``"Function"``/``"Method"``/``"Class"``) that
124
+ every node carries does not emit a meaningless boundary block.
125
+ """
126
+ if not focus.domains:
127
+ return None
128
+ ontology = _escape_xml_attr(focus.ontology_class or "-")
129
+ domains = _escape_xml_attr(", ".join(focus.domains))
130
+ return (
131
+ f'<domain ontology_class="{ontology}" domains="{domains}">'
132
+ "respect these architectural boundaries when refactoring"
133
+ "</domain>"
134
+ )
135
+
136
+
137
+ def _callers_section(callers: list[Node], depth: int) -> str:
138
+ """Render the <callers> tag, with an explicit note when there are none."""
139
+ body = "\n".join(_caller_line(c) for c in callers) or "none — no upstream callers"
140
+ return f'<callers note="{_callers_note(depth)}">\n{body}\n</callers>'
141
+
142
+
143
+ def _callees_section(callees: list[Node], unresolved_callees: list[str], depth: int) -> str:
144
+ """Render the <callees> tag: resolved deps plus flagged unresolved targets."""
145
+ lines = [_caller_line(c) for c in callees]
146
+ lines += [f"- {name} (unresolved)" for name in unresolved_callees]
147
+ body = "\n".join(lines) or "none"
148
+ return f'<callees note="{_callees_note(depth)}">\n{body}\n</callees>'
149
+
150
+
151
+ def compile_context(
152
+ focus: Node,
153
+ source: str,
154
+ class_node: Node | None,
155
+ siblings: list[Node],
156
+ callers: list[Node],
157
+ callees: list[Node],
158
+ unresolved_callees: list[str],
159
+ depth: int = 1,
160
+ ) -> str:
161
+ """Assemble the XML-tagged context package for ``focus``.
162
+
163
+ ``source`` is the focal node's raw code (already read from disk, may be
164
+ empty). ``class_node``/``siblings`` describe the enclosing class (``None``
165
+ for a module-level function). ``callers``/``callees`` are the CALLS
166
+ neighbours reached within ``depth`` hops; ``unresolved_callees`` are
167
+ raw_call target names that never resolved to a node, listed so the agent
168
+ knows the dependency exists but is external/dynamic. ``depth`` is carried
169
+ only to label the caller/callee notes honestly — at depth 1 they are
170
+ "direct", above it the note states the hop bound rather than claiming
171
+ directness.
172
+ """
173
+ focal = _escape_xml_attr(focus.id)
174
+ file_attr = _escape_xml_attr(f"{focus.file_path}:{focus.start_line}-{focus.end_line}")
175
+ header = f'<context focal="{focal}" type="{focus.type.value}" file="{file_attr}">'
176
+ sections = [
177
+ _source_section(source),
178
+ _class_section(class_node, siblings),
179
+ _domain_section(focus),
180
+ _callers_section(callers, depth),
181
+ _callees_section(callees, unresolved_callees, depth),
182
+ ]
183
+ body = "\n\n".join(section for section in sections if section)
184
+ return f"{header}\n\n{body}\n\n</context>"