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
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
|
+
]
|
cgis/guardian/chunked.py
ADDED
|
@@ -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
|
+
)
|
cgis/guardian/chunker.py
ADDED
|
@@ -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
|