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 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}`` computed with vectorized
956
- DuckDB aggregations over the whole graph (fan-in/fan-out coupling,
957
- declared-member counts, PageRank) — the global "what are the hotspots?" view
958
- that complements the node-local trace/impact/context tools. Requires the
959
- optional ``duckdb`` extra; an unavailable dependency is reported as a normal
960
- ❌ message.
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 as two Rich tables (bottlenecks + God classes)."""
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,
@@ -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. `_graph_sections` then finds
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.db_path is None: # routed guard (§4.1) — belt and braces
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 (collector.db_path is None or not collector.db_path.exists()):
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:
@@ -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
- class ContextCollector:
40
- """Gathers all necessary context for the review."""
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
- base_ref, when given, is used verbatim (e.g. a SHA for benchmark
55
- replays); otherwise the diff base is origin/<base_branch>.
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
- source_root must match the ingest root used to build the graph DB
58
- (CI runs `cgis ingest ./src`): node FQNs are relative to that root,
59
- so changed-file paths are stripped of it before lookup.
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
- include_graph=False drops the impact-graph Mermaid section — a "diff-only"
64
- prompt that fits a smaller context window (e.g. a local Ollama model whose
65
- window is exceeded once the graph is appended).
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
- Pure with respect to self.graph_stats — callers decide whether to
180
- overwrite (global path) or accumulate (per-chunk path).
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 self.db_path is None or not self.db_path.exists() or not changed_files:
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 self.db_path is None or not self.db_path.exists():
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
- qnodes, qedges = build_quotient(
255
- store.get_all_nodes(), store.get_all_edges(), domains
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._graph_sections(source_files, flow=True)
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 ContextCollector
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: "ContextCollector", env: Mapping[str, str]) -> Evidence | None:
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