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.
- cgis/__init__.py +10 -0
- cgis/__main__.py +16 -0
- cgis/api/.gitkeep +0 -0
- cgis/api/__init__.py +1 -0
- cgis/api/mcp_server.py +550 -0
- cgis/cli.py +1516 -0
- cgis/core/.gitkeep +0 -0
- cgis/core/models.py +140 -0
- cgis/extractors/.gitkeep +0 -0
- cgis/extractors/_python_ast.py +194 -0
- cgis/extractors/_python_classes.py +126 -0
- cgis/extractors/_python_functions.py +312 -0
- cgis/extractors/_python_imports.py +188 -0
- cgis/extractors/_python_types.py +84 -0
- cgis/extractors/base.py +42 -0
- cgis/extractors/python_extractor.py +235 -0
- cgis/extractors/typescript_extractor.py +310 -0
- cgis/guardian/__init__.py +1 -0
- cgis/guardian/bench.py +199 -0
- cgis/guardian/chunked.py +240 -0
- cgis/guardian/chunker.py +148 -0
- cgis/guardian/collector.py +309 -0
- cgis/guardian/core.py +120 -0
- cgis/guardian/diff_index.py +151 -0
- cgis/guardian/findings.py +70 -0
- cgis/guardian/github_poster.py +133 -0
- cgis/guardian/metrics.py +108 -0
- cgis/guardian/prompts.py +217 -0
- cgis/guardian/providers/__init__.py +1 -0
- cgis/guardian/providers/base.py +112 -0
- cgis/guardian/providers/gemini.py +83 -0
- cgis/guardian/providers/mistral.py +83 -0
- cgis/guardian/providers/ollama.py +99 -0
- cgis/guardian/recording.py +82 -0
- cgis/guardian/render.py +107 -0
- cgis/guardian/runner.py +295 -0
- cgis/guardian/skeptic.py +208 -0
- cgis/pipeline.py +252 -0
- cgis/py.typed +0 -0
- cgis/query/analysis/__init__.py +0 -0
- cgis/query/analysis/analyzer.py +241 -0
- cgis/query/analysis/anomaly.py +34 -0
- cgis/query/analysis/cohesion.py +277 -0
- cgis/query/analysis/health.py +128 -0
- cgis/query/analysis/suggest_service.py +221 -0
- cgis/query/context/__init__.py +0 -0
- cgis/query/context/audit.py +134 -0
- cgis/query/context/context_service.py +129 -0
- cgis/query/context/prompt.py +184 -0
- cgis/query/context/snippet.py +96 -0
- cgis/query/drift/__init__.py +0 -0
- cgis/query/drift/_scc.py +90 -0
- cgis/query/drift/drift.py +867 -0
- cgis/query/drift/drift_service.py +217 -0
- cgis/query/drift/fingerprint.py +255 -0
- cgis/query/drift/fractal.py +292 -0
- cgis/query/drift/ontology_init.py +470 -0
- cgis/query/drift/quotient.py +75 -0
- cgis/query/drift/triads.py +234 -0
- cgis/query/engine.py +170 -0
- cgis/query/fqn.py +65 -0
- cgis/query/render/__init__.py +0 -0
- cgis/query/render/graph_json.py +42 -0
- cgis/query/render/mermaid.py +235 -0
- cgis/query/render/metrics.py +346 -0
- cgis/resolver/.gitkeep +0 -0
- cgis/resolver/__init__.py +1 -0
- cgis/resolver/engine.py +145 -0
- cgis/resolver/indices.py +184 -0
- cgis/resolver/symbols.py +179 -0
- cgis/resolver/uplift.py +263 -0
- cgis/storage/.gitkeep +0 -0
- cgis/storage/sqlite_store.py +589 -0
- codegraph_brain-0.6.0.dist-info/METADATA +240 -0
- codegraph_brain-0.6.0.dist-info/RECORD +78 -0
- codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
- codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
- 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("&", "&").replace('"', """).replace("<", "<").replace(">", ">")
|
|
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"</\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>"
|