codegraph-brain 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. cgis/__init__.py +10 -0
  2. cgis/__main__.py +16 -0
  3. cgis/api/.gitkeep +0 -0
  4. cgis/api/__init__.py +1 -0
  5. cgis/api/mcp_server.py +550 -0
  6. cgis/cli.py +1516 -0
  7. cgis/core/.gitkeep +0 -0
  8. cgis/core/models.py +140 -0
  9. cgis/extractors/.gitkeep +0 -0
  10. cgis/extractors/_python_ast.py +194 -0
  11. cgis/extractors/_python_classes.py +126 -0
  12. cgis/extractors/_python_functions.py +312 -0
  13. cgis/extractors/_python_imports.py +188 -0
  14. cgis/extractors/_python_types.py +84 -0
  15. cgis/extractors/base.py +42 -0
  16. cgis/extractors/python_extractor.py +235 -0
  17. cgis/extractors/typescript_extractor.py +310 -0
  18. cgis/guardian/__init__.py +1 -0
  19. cgis/guardian/bench.py +199 -0
  20. cgis/guardian/chunked.py +240 -0
  21. cgis/guardian/chunker.py +148 -0
  22. cgis/guardian/collector.py +309 -0
  23. cgis/guardian/core.py +120 -0
  24. cgis/guardian/diff_index.py +151 -0
  25. cgis/guardian/findings.py +70 -0
  26. cgis/guardian/github_poster.py +133 -0
  27. cgis/guardian/metrics.py +108 -0
  28. cgis/guardian/prompts.py +217 -0
  29. cgis/guardian/providers/__init__.py +1 -0
  30. cgis/guardian/providers/base.py +112 -0
  31. cgis/guardian/providers/gemini.py +83 -0
  32. cgis/guardian/providers/mistral.py +83 -0
  33. cgis/guardian/providers/ollama.py +99 -0
  34. cgis/guardian/recording.py +82 -0
  35. cgis/guardian/render.py +107 -0
  36. cgis/guardian/runner.py +295 -0
  37. cgis/guardian/skeptic.py +208 -0
  38. cgis/pipeline.py +252 -0
  39. cgis/py.typed +0 -0
  40. cgis/query/analysis/__init__.py +0 -0
  41. cgis/query/analysis/analyzer.py +241 -0
  42. cgis/query/analysis/anomaly.py +34 -0
  43. cgis/query/analysis/cohesion.py +277 -0
  44. cgis/query/analysis/health.py +128 -0
  45. cgis/query/analysis/suggest_service.py +221 -0
  46. cgis/query/context/__init__.py +0 -0
  47. cgis/query/context/audit.py +134 -0
  48. cgis/query/context/context_service.py +129 -0
  49. cgis/query/context/prompt.py +184 -0
  50. cgis/query/context/snippet.py +96 -0
  51. cgis/query/drift/__init__.py +0 -0
  52. cgis/query/drift/_scc.py +90 -0
  53. cgis/query/drift/drift.py +867 -0
  54. cgis/query/drift/drift_service.py +217 -0
  55. cgis/query/drift/fingerprint.py +255 -0
  56. cgis/query/drift/fractal.py +292 -0
  57. cgis/query/drift/ontology_init.py +470 -0
  58. cgis/query/drift/quotient.py +75 -0
  59. cgis/query/drift/triads.py +234 -0
  60. cgis/query/engine.py +170 -0
  61. cgis/query/fqn.py +65 -0
  62. cgis/query/render/__init__.py +0 -0
  63. cgis/query/render/graph_json.py +42 -0
  64. cgis/query/render/mermaid.py +235 -0
  65. cgis/query/render/metrics.py +346 -0
  66. cgis/resolver/.gitkeep +0 -0
  67. cgis/resolver/__init__.py +1 -0
  68. cgis/resolver/engine.py +145 -0
  69. cgis/resolver/indices.py +184 -0
  70. cgis/resolver/symbols.py +179 -0
  71. cgis/resolver/uplift.py +263 -0
  72. cgis/storage/.gitkeep +0 -0
  73. cgis/storage/sqlite_store.py +589 -0
  74. codegraph_brain-0.6.0.dist-info/METADATA +240 -0
  75. codegraph_brain-0.6.0.dist-info/RECORD +78 -0
  76. codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
  77. codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
  78. codegraph_brain-0.6.0.dist-info/licenses/LICENSE +21 -0
cgis/guardian/bench.py ADDED
@@ -0,0 +1,199 @@
1
+ """Benchmark ground truth, matching, and scoring (spec §3.1-3.3).
2
+
3
+ Matching is deterministic and pure so the scorer can be unit-tested without
4
+ any LLM: a prediction matches a ground-truth entry iff same file AND (line
5
+ within the entry's range, or the entry has no range). Greedy by descending
6
+ prediction confidence; each entry matches at most once.
7
+ """
8
+
9
+ import statistics
10
+ from collections.abc import Sequence
11
+ from pathlib import Path
12
+ from typing import Literal
13
+
14
+ import yaml
15
+ from pydantic import BaseModel, Field, model_validator
16
+
17
+ from cgis.guardian.findings import Category, Finding, Severity
18
+
19
+ # Re-exported: the recording format moved to its own module when production
20
+ # became a second consumer (#279); bench remains the historical import site.
21
+ from cgis.guardian.recording import FinderRecording as FinderRecording # noqa: PLC0414
22
+ from cgis.guardian.recording import load_finder_recording as load_finder_recording # noqa: PLC0414
23
+ from cgis.guardian.recording import save_finder_recording as save_finder_recording # noqa: PLC0414
24
+ from cgis.guardian.skeptic import visible_findings
25
+
26
+
27
+ class GroundTruthEntry(BaseModel, frozen=True):
28
+ """One curated real finding for a benchmark PR."""
29
+
30
+ id: str
31
+ file: str
32
+ lines: tuple[int, int] | None = None
33
+ severity: Severity
34
+ category: Category
35
+ summary: str
36
+ source: Literal["gemini", "sonar", "fix-commit", "human"]
37
+
38
+ @model_validator(mode="after")
39
+ def _lines_ordered(self) -> "GroundTruthEntry":
40
+ """Reject inverted ranges — a transposed YAML range would silently never match."""
41
+ if self.lines is not None and self.lines[0] > self.lines[1]:
42
+ _msg = f"lines range is inverted: {self.lines}"
43
+ raise ValueError(_msg)
44
+ return self
45
+
46
+
47
+ class AmbiguousEntry(BaseModel, frozen=True):
48
+ """A debatable suggestion — neither a miss nor noise (spec §3.1)."""
49
+
50
+ file: str
51
+ summary: str
52
+
53
+
54
+ class GroundTruth(BaseModel, frozen=True):
55
+ """Curated findings for one merged PR."""
56
+
57
+ pr: int
58
+ base: str
59
+ head: str
60
+ findings: list[GroundTruthEntry]
61
+ ambiguous: list[AmbiguousEntry] = Field(default_factory=list)
62
+
63
+
64
+ class MatchResult(BaseModel, frozen=True):
65
+ """Outcome of matching predictions against one PR's ground truth."""
66
+
67
+ matched: dict[str, int] # ground-truth id -> prediction index
68
+ missed: list[str] # ground-truth ids with no match
69
+ noise: list[int] # prediction indices matching nothing
70
+ ambiguous_hits: list[int] # prediction indices on ambiguous files
71
+ total_predictions: int # len(predictions) passed to match_findings
72
+
73
+
74
+ class BenchScore(BaseModel, frozen=True):
75
+ """Per-PR metrics derived from a MatchResult (spec §3.3)."""
76
+
77
+ recall: float
78
+ precision: float
79
+ noise: int
80
+ missed: list[str]
81
+
82
+
83
+ def load_ground_truth(path: Path) -> GroundTruth:
84
+ """Load and validate one benchmarks/guardian/pr-N.yaml file."""
85
+ return GroundTruth.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
86
+
87
+
88
+ def _entry_accepts(entry: GroundTruthEntry, prediction: Finding) -> bool:
89
+ """File must match; a lines range additionally requires the line within it."""
90
+ if entry.file != prediction.file:
91
+ return False
92
+ if entry.lines is None:
93
+ return True
94
+ return prediction.line is not None and entry.lines[0] <= prediction.line <= entry.lines[1]
95
+
96
+
97
+ def match_findings(predictions: Sequence[Finding], truth: GroundTruth) -> MatchResult:
98
+ """Match predictions to ground truth: greedy by descending confidence.
99
+
100
+ Ties broken by original sequence position (stable sort).
101
+ """
102
+ matched: dict[str, int] = {}
103
+ noise: list[int] = []
104
+ ambiguous_hits: list[int] = []
105
+ ambiguous_files = {a.file for a in truth.ambiguous}
106
+ order = sorted(range(len(predictions)), key=lambda i: -predictions[i].confidence)
107
+ for i in order:
108
+ prediction = predictions[i]
109
+ hit = next(
110
+ (e.id for e in truth.findings if e.id not in matched and _entry_accepts(e, prediction)),
111
+ None,
112
+ )
113
+ if hit is not None:
114
+ matched[hit] = i
115
+ elif prediction.file in ambiguous_files:
116
+ ambiguous_hits.append(i)
117
+ else:
118
+ noise.append(i)
119
+ missed = [e.id for e in truth.findings if e.id not in matched]
120
+ return MatchResult(
121
+ matched=matched,
122
+ missed=missed,
123
+ noise=sorted(noise),
124
+ ambiguous_hits=sorted(ambiguous_hits),
125
+ total_predictions=len(predictions),
126
+ )
127
+
128
+
129
+ def score(match: MatchResult, truth: GroundTruth) -> BenchScore:
130
+ """Compute recall / precision / noise for one PR.
131
+
132
+ Precision is TP / (TP + FP): ambiguous hits are excluded from the
133
+ denominator — they are neither correct nor noise (spec §3.1), so they
134
+ must not depress precision.
135
+ """
136
+ gt_total = len(truth.findings)
137
+ recall = len(match.matched) / gt_total if gt_total else 1.0
138
+ relevant = len(match.matched) + len(match.noise)
139
+ precision = len(match.matched) / relevant if relevant else 1.0
140
+ return BenchScore(
141
+ recall=recall, precision=precision, noise=len(match.noise), missed=match.missed
142
+ )
143
+
144
+
145
+ def score_separation(gt_scores: Sequence[int], noise_scores: Sequence[int]) -> float | None:
146
+ """Median impact_score of GT-matching findings minus that of the rest (#246 §4.3).
147
+
148
+ This is the gate metric for the per-finding skeptic: it answers whether the
149
+ skeptic RANKS, which noise counts alone cannot. A flat distribution scores 0
150
+ even when noise happens to fall.
151
+
152
+ None when either population is empty — with nothing to compare, the run
153
+ cannot answer the question. Callers pool across benchmark PRs before
154
+ calling: individual PRs carry 1-6 GT matches, too few for a median to mean
155
+ anything.
156
+ """
157
+ if not gt_scores or not noise_scores:
158
+ return None
159
+ return statistics.median(gt_scores) - statistics.median(noise_scores)
160
+
161
+
162
+ def killed_ground_truth(
163
+ findings: Sequence[Finding], truth: GroundTruth, threshold: int = 0
164
+ ) -> list[str]:
165
+ """Ground-truth ids the finder matched but the skeptic then made invisible (#270).
166
+
167
+ "Before" is every finding the finder produced, judged or not; "after" is what
168
+ a human would actually read — refuted findings dropped, and anything scored
169
+ below ``threshold`` hidden. A finding hidden by the threshold is exactly as
170
+ invisible as a refuted one, so both count.
171
+
172
+ This is the failure mode the benchmark exists to catch, and it cannot be
173
+ derived from ``matched_gt``: that flag is computed on the post-skeptic
174
+ visible list, so a refuted finding can never carry it and the kill is
175
+ invisible by construction.
176
+
177
+ Ids, not a count: which ground truth was lost is the actionable part.
178
+ """
179
+ before = set(match_findings(list(findings), truth).matched)
180
+ after = set(match_findings(visible_findings(findings, threshold), truth).matched)
181
+ return sorted(before - after)
182
+
183
+
184
+ def annotate_matches(
185
+ findings: Sequence[Finding], visible: Sequence[Finding], match: MatchResult
186
+ ) -> list[dict[str, object]]:
187
+ """Dump each finding with a ``matched_gt`` flag for the benchmark record (#246 §4.2).
188
+
189
+ ``match`` indexes into ``visible`` (what the matcher scored), while the
190
+ record keeps EVERY finding including the ones the skeptic hid — otherwise
191
+ "noise fell" would be indistinguishable from "we went blind". Identity, not
192
+ equality, maps a finding back to its matcher position: two findings can be
193
+ field-identical, and only one of them may have matched.
194
+ """
195
+ position = {id(f): i for i, f in enumerate(visible)}
196
+ matched_positions = set(match.matched.values())
197
+ return [
198
+ {**f.model_dump(), "matched_gt": position.get(id(f)) in matched_positions} for f in findings
199
+ ]
@@ -0,0 +1,240 @@
1
+ """Chunked review: per-chunk finder passes behind GUARDIAN_FEATURES=chunked.
2
+
3
+ Slice 2 of #154 (spec: 2026-06-11-guardian-chunked-review-design.md). The
4
+ finder LGTMs large PRs (attention dilution); each chunk gets a small,
5
+ complete world instead — its own diff, full files, and impact graph.
6
+ """
7
+
8
+ import structlog
9
+ from pydantic import BaseModel
10
+
11
+ from cgis.guardian.chunker import Chunk, build_chunks, split_diff_by_file
12
+ from cgis.guardian.collector import ContextCollector
13
+ from cgis.guardian.core import GuardianReviewer, finder_pass
14
+ from cgis.guardian.findings import Finding, ReviewResult
15
+ from cgis.guardian.providers.base import BaseProvider
16
+ from cgis.guardian.skeptic import (
17
+ apply_judgements,
18
+ judge_all,
19
+ skeptic_status_for,
20
+ )
21
+ from cgis.storage.sqlite_store import SQLiteStore
22
+
23
+ log = structlog.getLogger(__name__)
24
+
25
+ MAX_CHUNKS = 8
26
+
27
+
28
+ class RoutedReview(BaseModel, frozen=True):
29
+ """Review outcome plus chunk accounting (chunk_count=None = single-pass path)."""
30
+
31
+ result: ReviewResult
32
+ chunk_count: int | None = None
33
+
34
+
35
+ def _cap_chunks(chunks: list[Chunk]) -> list[Chunk]:
36
+ """Bound API calls (spec §4.3): keep the MAX_CHUNKS-1 largest, merge the rest.
37
+
38
+ The overflow chunk goes last; kept chunks stay in slice-1 order (sorted
39
+ by first file). Ties in size break by first file name — deterministic.
40
+ """
41
+ if len(chunks) <= MAX_CHUNKS:
42
+ return chunks
43
+ ranked = sorted(chunks, key=lambda c: (-len(c.diff), c.files[0]))
44
+ keep, rest = ranked[: MAX_CHUNKS - 1], ranked[MAX_CHUNKS - 1 :]
45
+ rest_sorted = sorted(rest, key=lambda c: c.files[0])
46
+ overflow = Chunk(
47
+ files=tuple(sorted({f for c in rest_sorted for f in c.files})),
48
+ diff="".join(c.diff for c in rest_sorted),
49
+ )
50
+ log.warning("Chunk cap hit; smallest chunks merged.", merged=len(rest), cap=MAX_CHUNKS)
51
+ return [*sorted(keep, key=lambda c: c.files[0]), overflow]
52
+
53
+
54
+ def _normalize_path(path: str) -> str:
55
+ """Strip LLM path artifacts (leading ./, diff-header a/ b/ prefixes).
56
+
57
+ Used only as a fallback after an exact match fails, so a real directory
58
+ literally named `a/` or `b/` can never be corrupted by the stripping
59
+ (gemini review, PR #159).
60
+ """
61
+ p = path.removeprefix("./")
62
+ if p.startswith(("a/", "b/")):
63
+ p = p[2:]
64
+ return p
65
+
66
+
67
+ def _chunk_survivors(chunk: Chunk, findings: list[Finding]) -> list[Finding]:
68
+ """Keep findings inside the chunk's files; drop out-of-chunk hallucinations.
69
+
70
+ Exact match first; then a normalized fallback so an LLM path artifact
71
+ ("./x.py", "a/x.py") doesn't drop a real finding. Fallback survivors are
72
+ canonicalized so inline-comment anchoring still works downstream.
73
+ """
74
+ allowed = set(chunk.files)
75
+ survivors: list[Finding] = []
76
+ for finding in findings:
77
+ if finding.file in allowed:
78
+ survivors.append(finding)
79
+ continue
80
+ normalized = _normalize_path(finding.file)
81
+ if normalized in allowed:
82
+ survivors.append(finding.model_copy(update={"file": normalized}))
83
+ continue
84
+ log.warning("Out-of-chunk finding dropped.", file=finding.file, title=finding.title)
85
+ return survivors
86
+
87
+
88
+ def _dedup(findings: list[Finding]) -> list[Finding]:
89
+ """Drop duplicate (file, line, category) findings, keeping the higher confidence.
90
+
91
+ Cross-chunk duplicates are impossible after the per-chunk file filter
92
+ (chunks partition files) — this is insurance against intra-pass
93
+ duplicates. First-occurrence order is preserved.
94
+ """
95
+ best: dict[tuple[str, int | None, str], Finding] = {}
96
+ order: list[tuple[str, int | None, str]] = []
97
+ for finding in findings:
98
+ key = (finding.file, finding.line, finding.category)
99
+ if key not in best:
100
+ best[key] = finding
101
+ order.append(key)
102
+ elif finding.confidence > best[key].confidence:
103
+ best[key] = finding
104
+ return [best[k] for k in order]
105
+
106
+
107
+ async def _single_pass(
108
+ provider: BaseProvider,
109
+ collector: ContextCollector,
110
+ skeptic_provider: BaseProvider | None,
111
+ ) -> RoutedReview:
112
+ """Run the unchunked reviewer.
113
+
114
+ Shared by the routing default and the no-reviewable-files fallback so the
115
+ two cannot drift apart (#277). chunk_count is None, which is the recorded
116
+ marker for "this review did not chunk".
117
+ """
118
+ reviewer = GuardianReviewer(
119
+ provider=provider,
120
+ context_collector=collector,
121
+ skeptic_provider=skeptic_provider,
122
+ )
123
+ return RoutedReview(result=await reviewer.run_review(), chunk_count=None)
124
+
125
+
126
+ async def run_chunked_review(
127
+ *,
128
+ provider: BaseProvider,
129
+ collector: ContextCollector,
130
+ skeptic_provider: BaseProvider | None,
131
+ ) -> RoutedReview:
132
+ """Per-chunk finder passes -> filter -> merge -> dedup -> one skeptic pass.
133
+
134
+ Degradations (spec §5): a chunk whose finder call raises or never parses
135
+ contributes zero findings and a ⚠ bullet; parse_failed on the merged
136
+ result only when EVERY chunk failed; skeptic failure returns the merged
137
+ findings unverified.
138
+ """
139
+ diff = collector.get_git_diff()
140
+ if collector.db_path is None: # routed guard (§4.1) — belt and braces
141
+ _msg = "run_chunked_review requires a graph DB"
142
+ raise RuntimeError(_msg)
143
+ with SQLiteStore(str(collector.db_path)) as store:
144
+ chunks = build_chunks(diff, store, source_root=collector.source_root)
145
+ if not chunks:
146
+ if not split_diff_by_file(diff):
147
+ return RoutedReview(
148
+ result=ReviewResult(findings=[], summary="Empty diff — nothing to review."),
149
+ chunk_count=0,
150
+ )
151
+ # Blocks exist but none are reviewable source: a docs-only PR. Single
152
+ # pass reviews it today, so returning "nothing to review" here would be
153
+ # a silent regression — exactly the invisible-skip failure #277 is about.
154
+ log.info("No reviewable source in the diff; falling back to single pass.")
155
+ return await _single_pass(provider, collector, skeptic_provider)
156
+ chunks = _cap_chunks(chunks)
157
+
158
+ bullets: list[str] = []
159
+ kept: list[Finding] = []
160
+ finding_contexts: list[dict[str, str]] = []
161
+ failed = 0
162
+ for chunk in chunks:
163
+ label = ", ".join(chunk.files)
164
+ try:
165
+ # Context collection is inside the guard on purpose: it opens the
166
+ # graph DB per chunk, and a flaky DB must cost one chunk, not the
167
+ # whole review (opus quality review on slice 2).
168
+ context = collector.collect_for_chunk(chunk)
169
+ result = await finder_pass(provider, context)
170
+ except Exception:
171
+ log.warning(
172
+ "Chunk finder call failed; chunk skipped.",
173
+ files=chunk.files,
174
+ exc_info=True,
175
+ )
176
+ failed += 1
177
+ bullets.append(f"- [{label}]: ⚠ finder call failed")
178
+ continue
179
+ if result.parse_failed:
180
+ failed += 1
181
+ bullets.append(f"- [{label}]: ⚠ finder output unparsable")
182
+ continue
183
+ survivors = _chunk_survivors(chunk, result.findings)
184
+ if survivors:
185
+ finding_contexts.append(context)
186
+ kept.extend(survivors)
187
+ bullets.append(f"- [{label}]: {result.summary}")
188
+
189
+ merged = ReviewResult(
190
+ findings=_dedup(kept),
191
+ summary="\n".join(bullets),
192
+ parse_failed=failed == len(chunks),
193
+ )
194
+ if skeptic_provider is None or not merged.findings or merged.parse_failed:
195
+ return RoutedReview(result=merged, chunk_count=len(chunks))
196
+
197
+ # Judgement context is limited to chunks that produced findings — not the
198
+ # full PR diff (spec §4.5); judge_all narrows further, per finding's file.
199
+ skeptic_context = {
200
+ "diff": "\n".join(c["diff"] for c in finding_contexts),
201
+ "full_files": "\n\n".join(c["full_files"] for c in finding_contexts if "full_files" in c),
202
+ }
203
+ judgements = await judge_all(skeptic_provider, merged.findings, skeptic_context["diff"])
204
+ judged = sum(1 for j in judgements if j is not None)
205
+ if judged == 0:
206
+ log.warning("Every skeptic judgement failed; returning unverified findings.")
207
+ return RoutedReview(
208
+ result=merged.model_copy(
209
+ update={
210
+ "findings": apply_judgements(merged.findings, judgements),
211
+ "skeptic_status": skeptic_status_for(judged, len(judgements)),
212
+ "skeptic_judged": judged,
213
+ "skeptic_total": len(judgements),
214
+ }
215
+ ),
216
+ chunk_count=len(chunks),
217
+ )
218
+
219
+
220
+ async def run_review_routed(
221
+ *,
222
+ provider: BaseProvider,
223
+ collector: ContextCollector,
224
+ skeptic_provider: BaseProvider | None,
225
+ ) -> RoutedReview:
226
+ """Single entry point for runner and bench: chunked vs single-pass (spec §4.1).
227
+
228
+ chunked without a graph DB falls back to single pass: build_chunks would
229
+ degrade to all-isolated chunks = one API call per file with zero
230
+ connectivity benefit — strictly worse than the status quo.
231
+ """
232
+ chunked = "chunked" in collector.features
233
+ if chunked and (collector.db_path is None or not collector.db_path.exists()):
234
+ log.warning("chunked requested but no graph DB; falling back to single pass.")
235
+ chunked = False
236
+ if not chunked:
237
+ return await _single_pass(provider, collector, skeptic_provider)
238
+ return await run_chunked_review(
239
+ provider=provider, collector=collector, skeptic_provider=skeptic_provider
240
+ )
@@ -0,0 +1,148 @@
1
+ """Connected-subgraph chunking of a PR diff (spec: 2026-06-11-guardian-chunker-design.md).
2
+
3
+ Slice 1 of #154: pure logic, no LLM calls; not wired into the review loop yet.
4
+ """
5
+
6
+ import re
7
+
8
+ import structlog
9
+ from pydantic import BaseModel
10
+
11
+ from cgis.core.models import EdgeType
12
+
13
+ # Re-exported: the per-file split moved to the pure diff leaf next to the other
14
+ # unified-diff parsers, since the skeptic pass needs it too (#246 plan T3).
15
+ from cgis.guardian.diff_index import split_diff_by_file as split_diff_by_file # noqa: PLC0414
16
+ from cgis.storage.sqlite_store import RAW_CALL_PREFIX, SQLiteStore
17
+
18
+ log = structlog.getLogger(__name__)
19
+
20
+
21
+ class Chunk(BaseModel, frozen=True):
22
+ """One connected group of changed files and its slice of the diff."""
23
+
24
+ files: tuple[str, ...]
25
+ diff: str
26
+
27
+
28
+ def build_chunks(
29
+ diff_text: str,
30
+ store: SQLiteStore | None,
31
+ source_root: str = "",
32
+ reviewable_suffixes: tuple[str, ...] = (".py",),
33
+ ) -> list[Chunk]:
34
+ """Group changed files into connected-component chunks via IMPORTS/CALLS.
35
+
36
+ Only files ending in ``reviewable_suffixes`` are chunked. A markdown or lock
37
+ file would otherwise cost one finder call and receive no context at all —
38
+ ``collect_for_chunk`` narrows to .py before assembling one (#277). An
39
+ all-filtered diff returns [], which the caller distinguishes from an empty
40
+ diff and answers with a single-pass fallback.
41
+
42
+ Degrades honestly: no store / file absent from the graph / store errors →
43
+ isolated single-file chunks, never worse than the unchunked status quo.
44
+ Deterministic: files sorted inside a chunk, chunks sorted by first file.
45
+ """
46
+ blocks = {
47
+ path: block
48
+ for path, block in split_diff_by_file(diff_text).items()
49
+ if path.endswith(reviewable_suffixes)
50
+ }
51
+ if not blocks:
52
+ return []
53
+ files = sorted(blocks)
54
+ parent: dict[str, str] = {f: f for f in files}
55
+
56
+ def find(x: str) -> str:
57
+ """Find root of union-find tree with path halving."""
58
+ while parent[x] != x:
59
+ parent[x] = parent[parent[x]] # path halving
60
+ x = parent[x]
61
+ return x
62
+
63
+ def union(a: str, b: str) -> None:
64
+ """Merge two sets in the union-find structure."""
65
+ parent[find(a)] = find(b)
66
+
67
+ for a, b in _graph_pairs(store, set(files), source_root):
68
+ union(a, b)
69
+
70
+ for test_file, impl_file in _test_pairs(files):
71
+ union(test_file, impl_file)
72
+
73
+ groups: dict[str, list[str]] = {}
74
+ for f in files: # files is sorted → each group list is sorted
75
+ groups.setdefault(find(f), []).append(f)
76
+ return [
77
+ Chunk(files=tuple(group), diff="".join(blocks[f] for f in group))
78
+ for group in sorted(groups.values())
79
+ ]
80
+
81
+
82
+ _CHUNK_EDGE_TYPES = frozenset({EdgeType.IMPORTS, EdgeType.CALLS})
83
+
84
+
85
+ def _graph_pairs(
86
+ store: SQLiteStore | None, changed: set[str], source_root: str
87
+ ) -> list[tuple[str, str]]:
88
+ """File pairs joined by an IMPORTS/CALLS edge with both endpoints changed.
89
+
90
+ Graph paths are normalized with source_root (collector convention,
91
+ fix 48790da). Any store failure degrades to no pairs — the chunker sits
92
+ on the review path and must not take guardian down.
93
+ """
94
+ if store is None:
95
+ return []
96
+ prefix = f"{source_root}/" if source_root else ""
97
+ try:
98
+ # Load only the changed files' nodes and their outgoing edges instead
99
+ # of the whole graph: any qualifying edge has BOTH endpoints changed,
100
+ # so its source is always among these nodes (gemini review, PR #157).
101
+ fqn_to_file = {
102
+ node.id: prefix + node.file_path
103
+ for path in changed
104
+ if not prefix or path.startswith(prefix)
105
+ for node in store.get_nodes_by_file(path.removeprefix(prefix))
106
+ }
107
+ pairs: list[tuple[str, str]] = []
108
+ for edge in store.get_outgoing_edges_batch(list(fqn_to_file)):
109
+ if edge.type not in _CHUNK_EDGE_TYPES or edge.target.startswith(RAW_CALL_PREFIX):
110
+ continue
111
+ src = fqn_to_file.get(edge.source)
112
+ dst = fqn_to_file.get(edge.target)
113
+ if src and dst and src != dst:
114
+ pairs.append((src, dst))
115
+ except Exception:
116
+ log.warning("Graph connectivity skipped; falling back to isolated chunks.", exc_info=True)
117
+ return []
118
+ return pairs
119
+
120
+
121
+ _TEST_FILE_RE = re.compile(r"^tests/(?:.+/)?test_(?P<name>[^/]+)\.py$")
122
+
123
+
124
+ def _test_pairs(files: list[str]) -> list[tuple[str, str]]:
125
+ """Pair each changed tests/**/test_X.py with its unique implementation file.
126
+
127
+ Candidate = changed non-test .py whose path normalized to underscores
128
+ (src/cgis/guardian/core.py -> src_cgis_guardian_core) equals X or ends
129
+ with "_X". The underscore boundary stops cross-word bleed (test_core.py
130
+ must not capture score.py); same-suffix module names can still collide
131
+ (test_index.py would match diff_index.py) — accepted per spec §4.2.5,
132
+ and the unique-candidate rule below limits the blast radius. Zero or
133
+ several candidates -> the test stays isolated.
134
+ """
135
+ impl = [f for f in files if f.endswith(".py") and not _TEST_FILE_RE.match(f)]
136
+ norm = {f: f.removesuffix(".py").replace("/", "_") for f in impl}
137
+ pairs: list[tuple[str, str]] = []
138
+ for f in files:
139
+ match = _TEST_FILE_RE.match(f)
140
+ if not match:
141
+ continue
142
+ name = match.group("name")
143
+ candidates = [i for i in impl if norm[i] == name or norm[i].endswith("_" + name)]
144
+ if len(candidates) == 1:
145
+ pairs.append((f, candidates[0]))
146
+ elif candidates:
147
+ log.debug("Ambiguous test pairing; left isolated.", test=f, candidates=candidates)
148
+ return pairs