codegraph-brain 0.25.4__py3-none-any.whl → 0.26.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/api/mcp_server.py +24 -6
- cgis/cli.py +34 -1
- cgis/extractors/_python_functions.py +92 -0
- cgis/extractors/registry.py +1 -1
- cgis/extractors/typescript_extractor.py +5 -0
- cgis/guardian/axes.py +1 -1
- cgis/guardian/chunked.py +6 -6
- cgis/guardian/collector.py +123 -68
- cgis/guardian/core.py +1 -1
- cgis/guardian/evidence.py +2 -2
- cgis/guardian/martian.py +48 -0
- cgis/guardian/runner.py +2 -2
- cgis/import_names.py +73 -0
- cgis/pipeline.py +16 -16
- cgis/query/drift/catalog.py +313 -0
- cgis/query/drift/drift.py +66 -758
- cgis/query/drift/drift_service.py +8 -4
- cgis/query/drift/fingerprint.py +37 -4
- cgis/query/drift/ontology_init.py +2 -1
- cgis/query/drift/quotient.py +48 -12
- cgis/query/drift/scoring.py +414 -0
- cgis/query/render/mermaid.py +9 -4
- cgis/query/render/metrics.py +303 -1
- cgis/resolver/engine.py +5 -2
- cgis/resolver/indices.py +111 -14
- cgis/resolver/symbols.py +2 -1
- cgis/storage/sqlite_store.py +43 -14
- cgis/tsconfig_paths.py +268 -0
- cgis/workspaces.py +32 -11
- {codegraph_brain-0.25.4.dist-info → codegraph_brain-0.26.0.dist-info}/METADATA +1 -1
- {codegraph_brain-0.25.4.dist-info → codegraph_brain-0.26.0.dist-info}/RECORD +34 -30
- {codegraph_brain-0.25.4.dist-info → codegraph_brain-0.26.0.dist-info}/WHEEL +0 -0
- {codegraph_brain-0.25.4.dist-info → codegraph_brain-0.26.0.dist-info}/entry_points.txt +0 -0
- {codegraph_brain-0.25.4.dist-info → codegraph_brain-0.26.0.dist-info}/licenses/LICENSE +0 -0
cgis/api/mcp_server.py
CHANGED
|
@@ -952,12 +952,28 @@ def cgis_metrics(
|
|
|
952
952
|
) -> str:
|
|
953
953
|
"""Whole-graph architectural metrics — coupling bottlenecks, God classes, PageRank.
|
|
954
954
|
|
|
955
|
-
Returns JSON ``{bottlenecks, god_classes, critical
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
955
|
+
Returns JSON ``{bottlenecks, god_classes, critical, file_coupling, class_cohesion,
|
|
956
|
+
resolution}``
|
|
957
|
+
computed with vectorized DuckDB aggregations over the whole graph (fan-in/fan-out
|
|
958
|
+
coupling, declared-member counts, PageRank, per-file Ca/Ce/instability) — the
|
|
959
|
+
global "what are the hotspots?" view that complements the node-local
|
|
960
|
+
trace/impact/context tools. Requires the optional ``duckdb`` extra; an
|
|
961
|
+
unavailable dependency is reported as a normal ❌ message.
|
|
962
|
+
|
|
963
|
+
``file_coupling`` counts *files*, not calls: Ca is how many other files
|
|
964
|
+
depend on a file (by IMPORTS or CALLS), Ce how many it depends on, and
|
|
965
|
+
instability ``I = Ce / (Ca + Ce)`` runs from 0 (stable, expensive to change)
|
|
966
|
+
to 1 (volatile); it is null for a file linked to no other.
|
|
967
|
+
|
|
968
|
+
``class_cohesion`` ranks classes by LCOM4: the number of groups their instance
|
|
969
|
+
methods fall into when linked by a shared ``self`` attribute or a call. 1 is
|
|
970
|
+
cohesive; 2+ is a class doing unrelated jobs. Dunders (``__init__`` above
|
|
971
|
+
all), abstract, static and class methods are not counted.
|
|
972
|
+
|
|
973
|
+
``resolution`` is the share of edges the resolver could not place, by the
|
|
974
|
+
same rule as ``cgis validate``. Read the rankings through it: a node whose
|
|
975
|
+
calls are mostly unresolved looks uncoupled because its edges point nowhere.
|
|
976
|
+
Under ``scope``/``exclude`` it counts the edges the selected code *emits*.
|
|
961
977
|
|
|
962
978
|
``exclude`` drops any node whose FQN contains one of the given dot-segments
|
|
963
979
|
(e.g. ``["tests"]`` removes both ``tests.*`` and ``domains.*.tests.*``) so
|
|
@@ -981,6 +997,8 @@ def cgis_metrics(
|
|
|
981
997
|
bottleneck_limit=limit,
|
|
982
998
|
god_limit=limit,
|
|
983
999
|
critical_limit=limit,
|
|
1000
|
+
file_limit=limit,
|
|
1001
|
+
cohesion_limit=limit,
|
|
984
1002
|
exclude=exclude or [],
|
|
985
1003
|
scope=scope or [],
|
|
986
1004
|
)
|
cgis/cli.py
CHANGED
|
@@ -1365,7 +1365,7 @@ def context(
|
|
|
1365
1365
|
|
|
1366
1366
|
|
|
1367
1367
|
def _render_metrics(report: ArchitectureReport) -> None:
|
|
1368
|
-
"""Print the architecture report
|
|
1368
|
+
"""Print the architecture report: five Rich tables, then the unresolved share."""
|
|
1369
1369
|
bottlenecks = Table(title="🔌 Coupling bottlenecks (top by fan-in + fan-out)")
|
|
1370
1370
|
bottlenecks.add_column("Node", style="cyan")
|
|
1371
1371
|
bottlenecks.add_column("Type", style="magenta")
|
|
@@ -1402,6 +1402,37 @@ def _render_metrics(report: ArchitectureReport) -> None:
|
|
|
1402
1402
|
)
|
|
1403
1403
|
console.print(critical)
|
|
1404
1404
|
|
|
1405
|
+
files = Table(title="📄 File coupling (top by Ca + Ce)")
|
|
1406
|
+
files.add_column("Module", style="cyan")
|
|
1407
|
+
files.add_column("Ca", justify="right", style="green")
|
|
1408
|
+
files.add_column("Ce", justify="right", style="yellow")
|
|
1409
|
+
files.add_column("I", justify="right", style="magenta")
|
|
1410
|
+
for f in report.file_coupling:
|
|
1411
|
+
# I is undefined, not zero, for a file linked to no other.
|
|
1412
|
+
files.add_row(
|
|
1413
|
+
escape(f.module),
|
|
1414
|
+
str(f.afferent),
|
|
1415
|
+
str(f.efferent),
|
|
1416
|
+
"—" if f.instability is None else f"{f.instability:.2f}",
|
|
1417
|
+
)
|
|
1418
|
+
console.print(files)
|
|
1419
|
+
|
|
1420
|
+
cohesion = Table(title="🧩 Class cohesion (top by LCOM4 — groups that share nothing)")
|
|
1421
|
+
cohesion.add_column("Class", style="cyan")
|
|
1422
|
+
cohesion.add_column("LCOM4", justify="right", style="red")
|
|
1423
|
+
cohesion.add_column("Methods", justify="right", style="dim")
|
|
1424
|
+
for c in report.class_cohesion:
|
|
1425
|
+
cohesion.add_row(escape(c.class_id), str(c.lcom4), str(c.methods))
|
|
1426
|
+
console.print(cohesion)
|
|
1427
|
+
|
|
1428
|
+
# Under the rankings, because it qualifies them: a module whose calls are
|
|
1429
|
+
# mostly unresolved looks uncoupled only because its edges point nowhere (#451).
|
|
1430
|
+
res = report.resolution
|
|
1431
|
+
console.print(
|
|
1432
|
+
f"🧩 Unresolved edges: {res.unresolved_edges} of {res.total_edges} "
|
|
1433
|
+
f"({res.unresolved_ratio * 100:.1f}%) — `cgis validate` lists them"
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1405
1436
|
|
|
1406
1437
|
@app.command()
|
|
1407
1438
|
def metrics(
|
|
@@ -1456,6 +1487,8 @@ def metrics(
|
|
|
1456
1487
|
bottleneck_limit=limit,
|
|
1457
1488
|
god_limit=limit,
|
|
1458
1489
|
critical_limit=limit,
|
|
1490
|
+
file_limit=limit,
|
|
1491
|
+
cohesion_limit=limit,
|
|
1459
1492
|
exclude=exclude,
|
|
1460
1493
|
scope=scope,
|
|
1461
1494
|
)
|
|
@@ -91,6 +91,11 @@ class FunctionHandler:
|
|
|
91
91
|
d == "abstractmethod" or d.endswith(".abstractmethod") for d in decorators
|
|
92
92
|
):
|
|
93
93
|
metadata["is_abstract"] = True
|
|
94
|
+
if node_type == NodeType.METHOD:
|
|
95
|
+
# Always written for a method, even empty: its absence then means "a
|
|
96
|
+
# graph built before this existed", which LCOM4 must skip rather
|
|
97
|
+
# than read as a method touching no state (#451).
|
|
98
|
+
metadata["self_attrs"] = receiver_attribute_names(node, code_bytes, decorators)
|
|
94
99
|
|
|
95
100
|
func_node = Node(
|
|
96
101
|
id=node_id,
|
|
@@ -402,6 +407,93 @@ class FunctionHandler:
|
|
|
402
407
|
acc.setdefault(class_fqn, {})[attr_name] = resolved
|
|
403
408
|
|
|
404
409
|
|
|
410
|
+
#: Decorators after which a method's first parameter is not the instance.
|
|
411
|
+
_NO_RECEIVER_DECORATORS = frozenset({"staticmethod"})
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _receiver_name(node: BaseNode, code_bytes: bytes, decorators: list[str] | None) -> str | None:
|
|
415
|
+
"""The name a method's first parameter binds the receiver to, or None if it has none.
|
|
416
|
+
|
|
417
|
+
None for a staticmethod, whose first parameter is an ordinary argument, and
|
|
418
|
+
for a method declared with no parameters at all.
|
|
419
|
+
"""
|
|
420
|
+
if decorators and any(d.rsplit(".", 1)[-1] in _NO_RECEIVER_DECORATORS for d in decorators):
|
|
421
|
+
return None
|
|
422
|
+
names = _parameter_names(node, code_bytes)
|
|
423
|
+
return names[0] if names else None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _parameter_names(node: BaseNode, code_bytes: bytes) -> list[str]:
|
|
427
|
+
"""The names a function or lambda binds as parameters, in order.
|
|
428
|
+
|
|
429
|
+
Covers plain, typed and defaulted parameters (`self`, `self: A`,
|
|
430
|
+
`n: int = 1`); `*args`/`**kw` splats are skipped, being no receiver.
|
|
431
|
+
"""
|
|
432
|
+
params = node.child_by_field_name("parameters")
|
|
433
|
+
names: list[str] = []
|
|
434
|
+
for param in params.named_children if params is not None else []:
|
|
435
|
+
if param.type in _SPLAT_PARAMETERS:
|
|
436
|
+
continue
|
|
437
|
+
ident = param if param.type == "identifier" else param.child_by_field_name("name")
|
|
438
|
+
if ident is None and param.named_children:
|
|
439
|
+
ident = param.named_children[0]
|
|
440
|
+
if ident is not None and ident.type == "identifier":
|
|
441
|
+
names.append(get_identifier(ident, code_bytes))
|
|
442
|
+
return names
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
#: `*args` / `**kw`: they bind a tuple or dict, never the receiver (#521 review).
|
|
446
|
+
_SPLAT_PARAMETERS = frozenset({"list_splat_pattern", "dictionary_splat_pattern"})
|
|
447
|
+
|
|
448
|
+
#: Scopes whose parameters can rebind the receiver's name inside a method body.
|
|
449
|
+
_REBINDING_SCOPES = frozenset({"function_definition", "lambda"})
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _receiver_attribute(node: BaseNode, code_bytes: bytes, receiver: str) -> str | None:
|
|
453
|
+
"""The attribute name when `node` is `<receiver>.<attribute>`, else None."""
|
|
454
|
+
if node.type != "attribute":
|
|
455
|
+
return None
|
|
456
|
+
obj = node.child_by_field_name("object")
|
|
457
|
+
attr = node.child_by_field_name("attribute")
|
|
458
|
+
if obj is None or attr is None or obj.type != "identifier":
|
|
459
|
+
return None
|
|
460
|
+
return get_identifier(attr, code_bytes) if get_identifier(obj, code_bytes) == receiver else None
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def receiver_attribute_names(
|
|
464
|
+
node: BaseNode, code_bytes: bytes, decorators: list[str] | None = None
|
|
465
|
+
) -> list[str]:
|
|
466
|
+
"""The attributes a method reads or writes through its first parameter, sorted (#451).
|
|
467
|
+
|
|
468
|
+
`def save(self): self._conn.commit(); self.flush()` gives `["_conn", "flush"]`
|
|
469
|
+
— fields and methods alike, since LCOM4 links two methods that share either.
|
|
470
|
+
The receiver is whatever the first parameter is named, so `cls` in a
|
|
471
|
+
classmethod counts too; a staticmethod has none and gives `[]`.
|
|
472
|
+
|
|
473
|
+
A nested class is not walked: its own methods' `self` is another object. A
|
|
474
|
+
nested function or lambda is, because a closure over `self` uses the same
|
|
475
|
+
state — unless one of its parameters takes the receiver's name, after which
|
|
476
|
+
that name means something else inside it (#521 review).
|
|
477
|
+
"""
|
|
478
|
+
receiver = _receiver_name(node, code_bytes, decorators)
|
|
479
|
+
body = node.child_by_field_name("body")
|
|
480
|
+
if receiver is None or body is None:
|
|
481
|
+
return []
|
|
482
|
+
names: set[str] = set()
|
|
483
|
+
stack = [body]
|
|
484
|
+
while stack:
|
|
485
|
+
current = stack.pop()
|
|
486
|
+
if current.type == "class_definition" or (
|
|
487
|
+
current.type in _REBINDING_SCOPES and receiver in _parameter_names(current, code_bytes)
|
|
488
|
+
):
|
|
489
|
+
continue
|
|
490
|
+
name = _receiver_attribute(current, code_bytes, receiver)
|
|
491
|
+
if name is not None:
|
|
492
|
+
names.add(name)
|
|
493
|
+
stack.extend(current.children)
|
|
494
|
+
return sorted(names)
|
|
495
|
+
|
|
496
|
+
|
|
405
497
|
def emit_annotation_edges(
|
|
406
498
|
type_node: BaseNode,
|
|
407
499
|
code_bytes: bytes,
|
cgis/extractors/registry.py
CHANGED
|
@@ -5,7 +5,7 @@ did not: `cli.py` registered the extractors, `ContextCollector` filtered changed
|
|
|
5
5
|
files by `.py`, fenced every file as ```python, and derived FQNs with the
|
|
6
6
|
*Python* helper. The last of those is the one that bites — the Python helper
|
|
7
7
|
strips `.py` and collapses `/__init__`, so handing it `src/app/foo.ts` yields
|
|
8
|
-
`src.app.foo.ts`, an id no extractor ever emitted. `
|
|
8
|
+
`src.app.foo.ts`, an id no extractor ever emitted. `GraphContextCollector.sections` then finds
|
|
9
9
|
nothing, logs at debug, and the review completes looking normal with no graph
|
|
10
10
|
context at all.
|
|
11
11
|
|
|
@@ -9,6 +9,9 @@ from cgis.extractors._typescript_bindings import shadowed_globals
|
|
|
9
9
|
from cgis.extractors.base import BaseExtractor
|
|
10
10
|
|
|
11
11
|
_RAW_CALL_PREFIX = "raw_call:"
|
|
12
|
+
#: `Node.language` for everything this extractor emits, `.ts` and `.tsx` alike;
|
|
13
|
+
#: the field defaults to "python", which every TS node used to be stored as (#504).
|
|
14
|
+
TYPESCRIPT_LANG = "typescript"
|
|
12
15
|
_EXPORT_UNWRAP_TYPES = frozenset(
|
|
13
16
|
{
|
|
14
17
|
"class_declaration",
|
|
@@ -78,6 +81,7 @@ def _make_node(fqn: str, name: str, node_type: NodeType, file_path: str, ts_node
|
|
|
78
81
|
file_path=file_path,
|
|
79
82
|
start_line=ts_node.start_point[0] + 1,
|
|
80
83
|
end_line=ts_node.end_point[0] + 1,
|
|
84
|
+
language=TYPESCRIPT_LANG,
|
|
81
85
|
namespace=NodeNamespace.INTERNAL,
|
|
82
86
|
)
|
|
83
87
|
|
|
@@ -135,6 +139,7 @@ class TypeScriptExtractor(BaseExtractor):
|
|
|
135
139
|
file_path=file_path,
|
|
136
140
|
start_line=1,
|
|
137
141
|
end_line=tree.root_node.end_point[0] + 1,
|
|
142
|
+
language=TYPESCRIPT_LANG,
|
|
138
143
|
namespace=NodeNamespace.INTERNAL,
|
|
139
144
|
)
|
|
140
145
|
shadowed = shadowed_globals(tree.root_node)
|
cgis/guardian/axes.py
CHANGED
|
@@ -121,7 +121,7 @@ async def run_axis_review(
|
|
|
121
121
|
merged.findings,
|
|
122
122
|
context.get("diff", ""),
|
|
123
123
|
concurrency,
|
|
124
|
-
evidence=await evidence_for(collector, os.environ),
|
|
124
|
+
evidence=await evidence_for(collector.source, os.environ),
|
|
125
125
|
)
|
|
126
126
|
judged = sum(1 for j in judgements if j is not None)
|
|
127
127
|
if judged == 0:
|
cgis/guardian/chunked.py
CHANGED
|
@@ -121,12 +121,12 @@ async def run_chunked_review(
|
|
|
121
121
|
result only when EVERY chunk failed; skeptic failure returns the merged
|
|
122
122
|
findings unverified.
|
|
123
123
|
"""
|
|
124
|
-
diff = collector.get_git_diff()
|
|
125
|
-
if collector.
|
|
124
|
+
diff = collector.source.get_git_diff()
|
|
125
|
+
if not collector.graph.has_db(): # routed guard (§4.1) — belt and braces
|
|
126
126
|
_msg = "run_chunked_review requires a graph DB"
|
|
127
127
|
raise RuntimeError(_msg)
|
|
128
|
-
with SQLiteStore(str(collector.db_path)) as store:
|
|
129
|
-
chunks = build_chunks(diff, store, source_root=collector.source_root)
|
|
128
|
+
with SQLiteStore(str(collector.graph.db_path)) as store:
|
|
129
|
+
chunks = build_chunks(diff, store, source_root=collector.graph.source_root)
|
|
130
130
|
if not chunks:
|
|
131
131
|
if not split_diff_by_file(diff):
|
|
132
132
|
return RoutedReview(
|
|
@@ -189,7 +189,7 @@ async def run_chunked_review(
|
|
|
189
189
|
skeptic_provider,
|
|
190
190
|
merged.findings,
|
|
191
191
|
skeptic_context["diff"],
|
|
192
|
-
evidence=await evidence_for(collector, os.environ),
|
|
192
|
+
evidence=await evidence_for(collector.source, os.environ),
|
|
193
193
|
)
|
|
194
194
|
judged = sum(1 for j in judgements if j is not None)
|
|
195
195
|
if judged == 0:
|
|
@@ -231,7 +231,7 @@ async def run_review_routed(
|
|
|
231
231
|
)
|
|
232
232
|
|
|
233
233
|
chunked = "chunked" in collector.features
|
|
234
|
-
if chunked and
|
|
234
|
+
if chunked and not collector.graph.has_db():
|
|
235
235
|
log.warning("chunked requested but no graph DB; falling back to single pass.")
|
|
236
236
|
chunked = False
|
|
237
237
|
if not chunked:
|
cgis/guardian/collector.py
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
"""Gathers git diff and project files needed for Guardian review context.
|
|
1
|
+
"""Gathers git diff and project files needed for Guardian review context.
|
|
2
|
+
|
|
3
|
+
Three classes, split along their dependencies (#215): `SourceCollector` reads git
|
|
4
|
+
and the working tree, `GraphContextCollector` reads graph.db and the ontology, and
|
|
5
|
+
`ContextCollector` composes the two into a review's context sections. The first
|
|
6
|
+
two know nothing of features or of each other.
|
|
7
|
+
"""
|
|
2
8
|
|
|
3
9
|
import subprocess
|
|
10
|
+
from collections.abc import Mapping
|
|
4
11
|
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
5
13
|
|
|
6
14
|
import structlog
|
|
7
15
|
|
|
@@ -9,7 +17,7 @@ from cgis.extractors.registry import is_supported, language_for
|
|
|
9
17
|
from cgis.guardian.chunker import Chunk
|
|
10
18
|
from cgis.query.drift.drift import DriftScorer
|
|
11
19
|
from cgis.query.drift.fingerprint import FingerprintExtractor
|
|
12
|
-
from cgis.query.drift.quotient import build_quotient
|
|
20
|
+
from cgis.query.drift.quotient import build_quotient, quotient_call_tally
|
|
13
21
|
from cgis.query.engine import QueryEngine
|
|
14
22
|
from cgis.query.render.mermaid import MermaidCompiler
|
|
15
23
|
from cgis.storage.sqlite_store import SQLiteStore
|
|
@@ -36,42 +44,40 @@ def parse_features(raw: str) -> frozenset[str]:
|
|
|
36
44
|
return frozenset(items)
|
|
37
45
|
|
|
38
46
|
|
|
39
|
-
|
|
40
|
-
|
|
47
|
+
#: How a review's feature set came to be (#505). "env" — GUARDIAN_FEATURES was
|
|
48
|
+
#: set, possibly to nothing; "default" — it was unset or blank, so the review ran
|
|
49
|
+
#: with no optional section. Recorded beside the set rather than inferred from
|
|
50
|
+
#: it: an empty set is what both an unset variable and `GUARDIAN_FEATURES=,`
|
|
51
|
+
#: produce, and only one of them was a choice.
|
|
52
|
+
FeaturesSource = Literal["env", "default"]
|
|
41
53
|
|
|
42
|
-
def __init__(
|
|
43
|
-
self,
|
|
44
|
-
project_root: Path,
|
|
45
|
-
base_branch: str = "main",
|
|
46
|
-
db_path: Path | None = None,
|
|
47
|
-
base_ref: str | None = None,
|
|
48
|
-
source_root: str = "src",
|
|
49
|
-
features: frozenset[str] = frozenset(),
|
|
50
|
-
include_graph: bool = True,
|
|
51
|
-
) -> None:
|
|
52
|
-
"""Set project root, diff base (branch or explicit ref), and optional graph DB.
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
def features_setting(env: Mapping[str, str]) -> tuple[frozenset[str], FeaturesSource]:
|
|
56
|
+
"""The context sections a review gets, and how that came to be, from one read (#505).
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
One call for both halves, like `temperature_setting` (#393): reading the
|
|
59
|
+
variable twice is how a record comes to describe a prompt it did not have.
|
|
60
|
+
An unknown name still raises, through `parse_features`.
|
|
61
|
+
"""
|
|
62
|
+
raw = (env.get("GUARDIAN_FEATURES") or "").strip()
|
|
63
|
+
if not raw:
|
|
64
|
+
return frozenset(), "default"
|
|
65
|
+
return parse_features(raw), "env"
|
|
60
66
|
|
|
61
|
-
features gates the optional context sections (spec §4): "full_files", "flow", "drift".
|
|
62
67
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
class SourceCollector:
|
|
69
|
+
"""What the PR changed and what its files say: git and the working tree."""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self, project_root: Path, base_branch: str = "main", base_ref: str | None = None
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Set project root and diff base (an explicit ref, else origin/<base_branch>).
|
|
75
|
+
|
|
76
|
+
base_ref, when given, is used verbatim (e.g. a SHA for benchmark replays).
|
|
66
77
|
"""
|
|
67
78
|
self.project_root = project_root
|
|
68
79
|
self.base_branch = base_branch
|
|
69
|
-
self.db_path = db_path
|
|
70
80
|
self.base_ref = base_ref
|
|
71
|
-
self.source_root = source_root
|
|
72
|
-
self.features = features
|
|
73
|
-
self.include_graph = include_graph
|
|
74
|
-
self.graph_stats: dict[str, int] = {"total": 0, "with_graph": 0, "flow_fallback": 0}
|
|
75
81
|
self._diff_cache: str | None = None
|
|
76
82
|
|
|
77
83
|
def _diff_range(self) -> str:
|
|
@@ -171,16 +177,33 @@ class ContextCollector:
|
|
|
171
177
|
sections.append(f"#### `{rel_path}`\n```{fence}\n{text}\n```")
|
|
172
178
|
return "\n\n".join(sections + omitted)
|
|
173
179
|
|
|
174
|
-
def _graph_sections(
|
|
175
|
-
self, changed_files: list[str], *, flow: bool
|
|
176
|
-
) -> tuple[list[str], dict[str, int]]:
|
|
177
|
-
"""Impact-graph Mermaid sections + local stats for the given files.
|
|
178
180
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
+
class GraphContextCollector:
|
|
182
|
+
"""What graph.db says about the changed files: impact graphs and drift."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, project_root: Path, db_path: Path | None, source_root: str = "src") -> None:
|
|
185
|
+
"""Set the graph DB, the ingest root its FQNs are relative to, and the project root.
|
|
186
|
+
|
|
187
|
+
source_root must match the ingest root used to build the graph DB (CI
|
|
188
|
+
runs `cgis ingest ./src`): changed-file paths are stripped of it before
|
|
189
|
+
lookup. project_root locates docs/ontology/patterns.yaml for drift.
|
|
190
|
+
"""
|
|
191
|
+
self.project_root = project_root
|
|
192
|
+
self.db_path = db_path
|
|
193
|
+
self.source_root = source_root
|
|
194
|
+
|
|
195
|
+
def has_db(self) -> bool:
|
|
196
|
+
"""Whether a graph DB was given and exists on disk."""
|
|
197
|
+
return self.db_path is not None and self.db_path.exists()
|
|
198
|
+
|
|
199
|
+
def sections(self, changed_files: list[str], *, flow: bool) -> tuple[list[str], dict[str, int]]:
|
|
200
|
+
"""Impact-graph Mermaid sections + their stats for the given files.
|
|
201
|
+
|
|
202
|
+
Holds no stats of its own — the caller decides whether to overwrite
|
|
203
|
+
(global path) or accumulate (per-chunk path).
|
|
181
204
|
"""
|
|
182
205
|
stats = {"total": 0, "with_graph": 0, "flow_fallback": 0}
|
|
183
|
-
if
|
|
206
|
+
if not self.has_db() or not changed_files:
|
|
184
207
|
return [], stats
|
|
185
208
|
stats["total"] = len(changed_files)
|
|
186
209
|
compiler = MermaidCompiler()
|
|
@@ -210,32 +233,13 @@ class ContextCollector:
|
|
|
210
233
|
stats["with_graph"] = len(sections)
|
|
211
234
|
return sections, stats
|
|
212
235
|
|
|
213
|
-
def collect_graph_context(self) -> str:
|
|
214
|
-
"""Query graph.db for impact graphs of changed files; return Mermaid blocks."""
|
|
215
|
-
if not self.include_graph:
|
|
216
|
-
return "" # diff-only prompt (fits a smaller context window)
|
|
217
|
-
if self.db_path is None or not self.db_path.exists():
|
|
218
|
-
return ""
|
|
219
|
-
changed_files = self.get_changed_source_files()
|
|
220
|
-
if not changed_files:
|
|
221
|
-
return ""
|
|
222
|
-
sections, stats = self._graph_sections(changed_files, flow="flow" in self.features)
|
|
223
|
-
self.graph_stats = stats
|
|
224
|
-
if stats["total"] > 0 and stats["with_graph"] == 0:
|
|
225
|
-
log.warning(
|
|
226
|
-
"No graph context found for any changed file.",
|
|
227
|
-
changed_files=stats["total"],
|
|
228
|
-
project_root=str(self.project_root),
|
|
229
|
-
)
|
|
230
|
-
return "\n\n".join(sections)
|
|
231
|
-
|
|
232
236
|
def collect_drift(self) -> str:
|
|
233
237
|
"""Compact per-domain drift table + quotient k=1 lines (spec §4.3).
|
|
234
238
|
|
|
235
239
|
First real consumer of drift v2 outside tests — the soft enforcement
|
|
236
240
|
channel deferred in #146/#151. Any failure degrades to an empty section.
|
|
237
241
|
"""
|
|
238
|
-
if
|
|
242
|
+
if not self.has_db():
|
|
239
243
|
return ""
|
|
240
244
|
patterns = self.project_root / "docs" / "ontology" / "patterns.yaml"
|
|
241
245
|
if not patterns.exists():
|
|
@@ -251,10 +255,11 @@ class ContextCollector:
|
|
|
251
255
|
reports = [scorer.score(extractor.extract(d.fqn_prefix), d) for d in domains]
|
|
252
256
|
level = scorer.load_project_level()
|
|
253
257
|
if level:
|
|
254
|
-
|
|
255
|
-
|
|
258
|
+
all_nodes, all_edges = store.get_all_nodes(), store.get_all_edges()
|
|
259
|
+
qnodes, qedges = build_quotient(all_nodes, all_edges, domains)
|
|
260
|
+
q_extractor = FingerprintExtractor.from_graph(
|
|
261
|
+
qnodes, qedges, quotient_call_tally(all_nodes, all_edges, domains)
|
|
256
262
|
)
|
|
257
|
-
q_extractor = FingerprintExtractor.from_graph(qnodes, qedges)
|
|
258
263
|
quotient_lines = [
|
|
259
264
|
f"Quotient k=1 [{b.name}] vs {qr.expected_pattern}: "
|
|
260
265
|
f"drift={qr.drift_score:.2f} (observe-only)"
|
|
@@ -280,22 +285,72 @@ class ContextCollector:
|
|
|
280
285
|
table + "\n".join(rows) + ("\n" + "\n".join(quotient_lines) if quotient_lines else "")
|
|
281
286
|
)
|
|
282
287
|
|
|
288
|
+
|
|
289
|
+
class ContextCollector:
|
|
290
|
+
"""Composes a review's context sections from its source and graph collaborators."""
|
|
291
|
+
|
|
292
|
+
def __init__(
|
|
293
|
+
self,
|
|
294
|
+
project_root: Path,
|
|
295
|
+
base_branch: str = "main",
|
|
296
|
+
db_path: Path | None = None,
|
|
297
|
+
base_ref: str | None = None,
|
|
298
|
+
source_root: str = "src",
|
|
299
|
+
features: frozenset[str] = frozenset(),
|
|
300
|
+
include_graph: bool = True,
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Build the source and graph collaborators and set what the review asks for.
|
|
303
|
+
|
|
304
|
+
project_root, base_branch and base_ref go to `source`; db_path and
|
|
305
|
+
source_root to `graph` (see each for their meaning).
|
|
306
|
+
|
|
307
|
+
features gates the optional context sections (spec §4): "full_files", "flow", "drift".
|
|
308
|
+
|
|
309
|
+
include_graph=False drops the impact-graph Mermaid section — a "diff-only"
|
|
310
|
+
prompt that fits a smaller context window (e.g. a local Ollama model whose
|
|
311
|
+
window is exceeded once the graph is appended).
|
|
312
|
+
"""
|
|
313
|
+
self.source = SourceCollector(project_root, base_branch, base_ref)
|
|
314
|
+
self.graph = GraphContextCollector(project_root, db_path, source_root)
|
|
315
|
+
self.features = features
|
|
316
|
+
self.include_graph = include_graph
|
|
317
|
+
self.graph_stats: dict[str, int] = {"total": 0, "with_graph": 0, "flow_fallback": 0}
|
|
318
|
+
|
|
319
|
+
def collect_graph_context(self) -> str:
|
|
320
|
+
"""Impact graphs of the changed files as Mermaid blocks; records graph_stats."""
|
|
321
|
+
if not self.include_graph:
|
|
322
|
+
return "" # diff-only prompt (fits a smaller context window)
|
|
323
|
+
if not self.graph.has_db():
|
|
324
|
+
return ""
|
|
325
|
+
changed_files = self.source.get_changed_source_files()
|
|
326
|
+
if not changed_files:
|
|
327
|
+
return ""
|
|
328
|
+
sections, stats = self.graph.sections(changed_files, flow="flow" in self.features)
|
|
329
|
+
self.graph_stats = stats
|
|
330
|
+
if stats["total"] > 0 and stats["with_graph"] == 0:
|
|
331
|
+
log.warning(
|
|
332
|
+
"No graph context found for any changed file.",
|
|
333
|
+
changed_files=stats["total"],
|
|
334
|
+
project_root=str(self.source.project_root),
|
|
335
|
+
)
|
|
336
|
+
return "\n\n".join(sections)
|
|
337
|
+
|
|
283
338
|
def collect_all(self) -> dict[str, str]:
|
|
284
339
|
"""Collects all relevant files, git diff, and optional graph context."""
|
|
285
340
|
context: dict[str, str] = {
|
|
286
|
-
"diff": self.get_git_diff(),
|
|
287
|
-
"contributing": self.read_file("CONTRIBUTING.md"),
|
|
288
|
-
"ontology": self.read_file("docs/ontology/core.yaml"),
|
|
341
|
+
"diff": self.source.get_git_diff(),
|
|
342
|
+
"contributing": self.source.read_file("CONTRIBUTING.md"),
|
|
343
|
+
"ontology": self.source.read_file("docs/ontology/core.yaml"),
|
|
289
344
|
}
|
|
290
345
|
graph_context = self.collect_graph_context()
|
|
291
346
|
if graph_context:
|
|
292
347
|
context["graph_context"] = graph_context
|
|
293
348
|
if "full_files" in self.features:
|
|
294
|
-
full_files = self.collect_full_files()
|
|
349
|
+
full_files = self.source.collect_full_files()
|
|
295
350
|
if full_files:
|
|
296
351
|
context["full_files"] = full_files
|
|
297
352
|
if "drift" in self.features:
|
|
298
|
-
drift = self.collect_drift()
|
|
353
|
+
drift = self.graph.collect_drift()
|
|
299
354
|
if drift:
|
|
300
355
|
context["drift"] = drift
|
|
301
356
|
return context
|
|
@@ -310,15 +365,15 @@ class ContextCollector:
|
|
|
310
365
|
source_files = [f for f in chunk.files if is_supported(f)]
|
|
311
366
|
context: dict[str, str] = {
|
|
312
367
|
"diff": chunk.diff,
|
|
313
|
-
"contributing": self.read_file("CONTRIBUTING.md"),
|
|
314
|
-
"ontology": self.read_file("docs/ontology/core.yaml"),
|
|
368
|
+
"contributing": self.source.read_file("CONTRIBUTING.md"),
|
|
369
|
+
"ontology": self.source.read_file("docs/ontology/core.yaml"),
|
|
315
370
|
}
|
|
316
|
-
sections, stats = self.
|
|
371
|
+
sections, stats = self.graph.sections(source_files, flow=True)
|
|
317
372
|
for key, value in stats.items():
|
|
318
373
|
self.graph_stats[key] = self.graph_stats.get(key, 0) + value
|
|
319
374
|
if sections:
|
|
320
375
|
context["graph_context"] = "\n\n".join(sections)
|
|
321
|
-
full_files = self.collect_full_files(source_files)
|
|
376
|
+
full_files = self.source.collect_full_files(source_files)
|
|
322
377
|
if full_files:
|
|
323
378
|
context["full_files"] = full_files
|
|
324
379
|
return context
|
cgis/guardian/core.py
CHANGED
|
@@ -138,7 +138,7 @@ class GuardianReviewer:
|
|
|
138
138
|
result.findings,
|
|
139
139
|
context.get("diff", ""),
|
|
140
140
|
self.concurrency,
|
|
141
|
-
evidence=await evidence_for(self.context_collector, os.environ),
|
|
141
|
+
evidence=await evidence_for(self.context_collector.source, os.environ),
|
|
142
142
|
)
|
|
143
143
|
judged = sum(1 for j in judgements if j is not None)
|
|
144
144
|
if judged == 0:
|
cgis/guardian/evidence.py
CHANGED
|
@@ -35,7 +35,7 @@ import structlog
|
|
|
35
35
|
from pydantic import BaseModel
|
|
36
36
|
|
|
37
37
|
if TYPE_CHECKING: # pragma: no cover
|
|
38
|
-
from cgis.guardian.collector import
|
|
38
|
+
from cgis.guardian.collector import SourceCollector
|
|
39
39
|
|
|
40
40
|
log = structlog.getLogger(__name__)
|
|
41
41
|
|
|
@@ -210,7 +210,7 @@ def collect_evidence(project_root: Path, changed_files: tuple[str, ...]) -> Evid
|
|
|
210
210
|
EVIDENCE_FLAG = "GUARDIAN_EVIDENCE"
|
|
211
211
|
|
|
212
212
|
|
|
213
|
-
async def evidence_for(collector: "
|
|
213
|
+
async def evidence_for(collector: "SourceCollector", env: Mapping[str, str]) -> Evidence | None:
|
|
214
214
|
"""Evidence for a review, or None when it is switched off or unavailable.
|
|
215
215
|
|
|
216
216
|
The single seam every caller uses, so "is the flag on" is decided in one
|