codegraph-brain 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cgis/__init__.py +10 -0
- cgis/__main__.py +16 -0
- cgis/api/.gitkeep +0 -0
- cgis/api/__init__.py +1 -0
- cgis/api/mcp_server.py +550 -0
- cgis/cli.py +1516 -0
- cgis/core/.gitkeep +0 -0
- cgis/core/models.py +140 -0
- cgis/extractors/.gitkeep +0 -0
- cgis/extractors/_python_ast.py +194 -0
- cgis/extractors/_python_classes.py +126 -0
- cgis/extractors/_python_functions.py +312 -0
- cgis/extractors/_python_imports.py +188 -0
- cgis/extractors/_python_types.py +84 -0
- cgis/extractors/base.py +42 -0
- cgis/extractors/python_extractor.py +235 -0
- cgis/extractors/typescript_extractor.py +310 -0
- cgis/guardian/__init__.py +1 -0
- cgis/guardian/bench.py +199 -0
- cgis/guardian/chunked.py +240 -0
- cgis/guardian/chunker.py +148 -0
- cgis/guardian/collector.py +309 -0
- cgis/guardian/core.py +120 -0
- cgis/guardian/diff_index.py +151 -0
- cgis/guardian/findings.py +70 -0
- cgis/guardian/github_poster.py +133 -0
- cgis/guardian/metrics.py +108 -0
- cgis/guardian/prompts.py +217 -0
- cgis/guardian/providers/__init__.py +1 -0
- cgis/guardian/providers/base.py +112 -0
- cgis/guardian/providers/gemini.py +83 -0
- cgis/guardian/providers/mistral.py +83 -0
- cgis/guardian/providers/ollama.py +99 -0
- cgis/guardian/recording.py +82 -0
- cgis/guardian/render.py +107 -0
- cgis/guardian/runner.py +295 -0
- cgis/guardian/skeptic.py +208 -0
- cgis/pipeline.py +252 -0
- cgis/py.typed +0 -0
- cgis/query/analysis/__init__.py +0 -0
- cgis/query/analysis/analyzer.py +241 -0
- cgis/query/analysis/anomaly.py +34 -0
- cgis/query/analysis/cohesion.py +277 -0
- cgis/query/analysis/health.py +128 -0
- cgis/query/analysis/suggest_service.py +221 -0
- cgis/query/context/__init__.py +0 -0
- cgis/query/context/audit.py +134 -0
- cgis/query/context/context_service.py +129 -0
- cgis/query/context/prompt.py +184 -0
- cgis/query/context/snippet.py +96 -0
- cgis/query/drift/__init__.py +0 -0
- cgis/query/drift/_scc.py +90 -0
- cgis/query/drift/drift.py +867 -0
- cgis/query/drift/drift_service.py +217 -0
- cgis/query/drift/fingerprint.py +255 -0
- cgis/query/drift/fractal.py +292 -0
- cgis/query/drift/ontology_init.py +470 -0
- cgis/query/drift/quotient.py +75 -0
- cgis/query/drift/triads.py +234 -0
- cgis/query/engine.py +170 -0
- cgis/query/fqn.py +65 -0
- cgis/query/render/__init__.py +0 -0
- cgis/query/render/graph_json.py +42 -0
- cgis/query/render/mermaid.py +235 -0
- cgis/query/render/metrics.py +346 -0
- cgis/resolver/.gitkeep +0 -0
- cgis/resolver/__init__.py +1 -0
- cgis/resolver/engine.py +145 -0
- cgis/resolver/indices.py +184 -0
- cgis/resolver/symbols.py +179 -0
- cgis/resolver/uplift.py +263 -0
- cgis/storage/.gitkeep +0 -0
- cgis/storage/sqlite_store.py +589 -0
- codegraph_brain-0.6.0.dist-info/METADATA +240 -0
- codegraph_brain-0.6.0.dist-info/RECORD +78 -0
- codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
- codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
- codegraph_brain-0.6.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Gathers git diff and project files needed for Guardian review context."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import structlog
|
|
7
|
+
|
|
8
|
+
from cgis.extractors.python_extractor import file_path_to_module_fqn
|
|
9
|
+
from cgis.guardian.chunker import Chunk
|
|
10
|
+
from cgis.query.drift.drift import DriftScorer
|
|
11
|
+
from cgis.query.drift.fingerprint import FingerprintExtractor
|
|
12
|
+
from cgis.query.drift.quotient import build_quotient
|
|
13
|
+
from cgis.query.engine import QueryEngine
|
|
14
|
+
from cgis.query.render.mermaid import MermaidCompiler
|
|
15
|
+
from cgis.storage.sqlite_store import SQLiteStore
|
|
16
|
+
|
|
17
|
+
log = structlog.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
VALID_FEATURES = frozenset({"full_files", "flow", "drift", "chunked"})
|
|
20
|
+
|
|
21
|
+
_MAX_FILE_LINES = 1200
|
|
22
|
+
_MAX_TOTAL_CHARS = 120_000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_features(raw: str) -> frozenset[str]:
|
|
26
|
+
"""Parse a GUARDIAN_FEATURES value ('full_files,flow,drift') into a validated set.
|
|
27
|
+
|
|
28
|
+
Raises ValueError on unknown names: a typo silently disabling an ablation
|
|
29
|
+
arm would corrupt the benchmark comparison.
|
|
30
|
+
"""
|
|
31
|
+
items = {item.strip() for item in raw.split(",") if item.strip()}
|
|
32
|
+
unknown = items - VALID_FEATURES
|
|
33
|
+
if unknown:
|
|
34
|
+
_msg = f"Unknown GUARDIAN_FEATURES: {sorted(unknown)}; valid: {sorted(VALID_FEATURES)}"
|
|
35
|
+
raise ValueError(_msg)
|
|
36
|
+
return frozenset(items)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ContextCollector:
|
|
40
|
+
"""Gathers all necessary context for the review."""
|
|
41
|
+
|
|
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
|
+
base_ref, when given, is used verbatim (e.g. a SHA for benchmark
|
|
55
|
+
replays); otherwise the diff base is origin/<base_branch>.
|
|
56
|
+
|
|
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.
|
|
60
|
+
|
|
61
|
+
features gates the optional context sections (spec §4): "full_files", "flow", "drift".
|
|
62
|
+
|
|
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).
|
|
66
|
+
"""
|
|
67
|
+
self.project_root = project_root
|
|
68
|
+
self.base_branch = base_branch
|
|
69
|
+
self.db_path = db_path
|
|
70
|
+
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
|
+
self._diff_cache: str | None = None
|
|
76
|
+
|
|
77
|
+
def _diff_range(self) -> str:
|
|
78
|
+
"""Return the git range argument for diff commands."""
|
|
79
|
+
base = self.base_ref or f"origin/{self.base_branch}"
|
|
80
|
+
return f"{base}...HEAD"
|
|
81
|
+
|
|
82
|
+
def get_git_diff(self) -> str:
|
|
83
|
+
"""Returns diff between HEAD and the base branch on origin.
|
|
84
|
+
|
|
85
|
+
The diff is cached after the first successful call: within one review
|
|
86
|
+
run it is needed twice (LLM context and inline-comment line index),
|
|
87
|
+
and the working tree does not change in between.
|
|
88
|
+
"""
|
|
89
|
+
if self._diff_cache is not None:
|
|
90
|
+
return self._diff_cache
|
|
91
|
+
try:
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
["git", "diff", self._diff_range()],
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
check=True,
|
|
97
|
+
cwd=self.project_root,
|
|
98
|
+
)
|
|
99
|
+
except subprocess.CalledProcessError as e:
|
|
100
|
+
return f"Error getting git diff: {e.stderr}"
|
|
101
|
+
else:
|
|
102
|
+
self._diff_cache = result.stdout
|
|
103
|
+
return self._diff_cache
|
|
104
|
+
|
|
105
|
+
def get_changed_py_files(self) -> list[str]:
|
|
106
|
+
"""Returns relative paths of .py files changed vs the base branch."""
|
|
107
|
+
try:
|
|
108
|
+
result = subprocess.run(
|
|
109
|
+
["git", "diff", "--name-only", self._diff_range()],
|
|
110
|
+
capture_output=True,
|
|
111
|
+
text=True,
|
|
112
|
+
check=True,
|
|
113
|
+
cwd=self.project_root,
|
|
114
|
+
)
|
|
115
|
+
except subprocess.CalledProcessError:
|
|
116
|
+
return []
|
|
117
|
+
return [p for p in result.stdout.splitlines() if p.endswith(".py")]
|
|
118
|
+
|
|
119
|
+
def read_file(self, relative_path: str) -> str:
|
|
120
|
+
"""Reads a file from the project root; returns "" when it does not exist.
|
|
121
|
+
|
|
122
|
+
An empty string (not an error marker) lets the prompt builder omit the
|
|
123
|
+
corresponding section entirely — a repo without CONTRIBUTING.md or
|
|
124
|
+
docs/ontology/ should review cleanly, not get "Error: File ..." injected
|
|
125
|
+
as if it were the standards/ontology text. `is_file()` (not `exists()`)
|
|
126
|
+
so a path that resolves to a directory degrades to "" instead of raising
|
|
127
|
+
IsADirectoryError on read.
|
|
128
|
+
"""
|
|
129
|
+
file_path = self.project_root / relative_path
|
|
130
|
+
if not file_path.is_file():
|
|
131
|
+
return ""
|
|
132
|
+
return file_path.read_text(encoding="utf-8")
|
|
133
|
+
|
|
134
|
+
def collect_full_files(self, files: list[str] | None = None) -> str:
|
|
135
|
+
"""Full HEAD text of given (default: changed) .py files, smallest-first under budgets.
|
|
136
|
+
|
|
137
|
+
Per-file cap ~1200 lines and a global ~120K-char budget; omitted files get
|
|
138
|
+
an explicit note so the model never reads absence-of-file as absence-of-code.
|
|
139
|
+
In chunked mode the budget applies per chunk (spec §4.2).
|
|
140
|
+
"""
|
|
141
|
+
changed = files if files is not None else self.get_changed_py_files()
|
|
142
|
+
sized: list[tuple[int, str, str]] = []
|
|
143
|
+
omitted: list[str] = []
|
|
144
|
+
for rel_path in changed:
|
|
145
|
+
path = self.project_root / rel_path
|
|
146
|
+
if not path.exists(): # deleted in this PR — nothing to show at HEAD
|
|
147
|
+
continue
|
|
148
|
+
text = path.read_text(encoding="utf-8")
|
|
149
|
+
if len(text.splitlines()) > _MAX_FILE_LINES:
|
|
150
|
+
omitted.append(f"file omitted: too large ({rel_path})")
|
|
151
|
+
continue
|
|
152
|
+
sized.append((len(text), rel_path, text))
|
|
153
|
+
|
|
154
|
+
sections: list[str] = []
|
|
155
|
+
used = 0
|
|
156
|
+
for size, rel_path, text in sorted(sized):
|
|
157
|
+
if used + size > _MAX_TOTAL_CHARS:
|
|
158
|
+
omitted.append(f"file omitted: budget exhausted ({rel_path})")
|
|
159
|
+
continue
|
|
160
|
+
used += size
|
|
161
|
+
sections.append(f"#### `{rel_path}`\n```python\n{text}\n```")
|
|
162
|
+
return "\n\n".join(sections + omitted)
|
|
163
|
+
|
|
164
|
+
def _graph_sections(
|
|
165
|
+
self, changed_files: list[str], *, flow: bool
|
|
166
|
+
) -> tuple[list[str], dict[str, int]]:
|
|
167
|
+
"""Impact-graph Mermaid sections + local stats for the given files.
|
|
168
|
+
|
|
169
|
+
Pure with respect to self.graph_stats — callers decide whether to
|
|
170
|
+
overwrite (global path) or accumulate (per-chunk path).
|
|
171
|
+
"""
|
|
172
|
+
stats = {"total": 0, "with_graph": 0, "flow_fallback": 0}
|
|
173
|
+
if self.db_path is None or not self.db_path.exists() or not changed_files:
|
|
174
|
+
return [], stats
|
|
175
|
+
stats["total"] = len(changed_files)
|
|
176
|
+
compiler = MermaidCompiler()
|
|
177
|
+
sections: list[str] = []
|
|
178
|
+
with SQLiteStore(str(self.db_path)) as store:
|
|
179
|
+
engine = QueryEngine(store)
|
|
180
|
+
for rel_path in changed_files:
|
|
181
|
+
module_fqn = file_path_to_module_fqn(rel_path, self.source_root)
|
|
182
|
+
nodes, edges = engine.get_impact_graph(module_fqn, max_depth=2)
|
|
183
|
+
title = "Impact graph"
|
|
184
|
+
if not nodes and flow:
|
|
185
|
+
# New file: nothing references it yet (#94) — show what it calls.
|
|
186
|
+
nodes, edges = engine.get_flow_graph(module_fqn, max_depth=2)
|
|
187
|
+
title = "Dependency graph (outbound)"
|
|
188
|
+
if nodes:
|
|
189
|
+
stats["flow_fallback"] += 1
|
|
190
|
+
if not nodes:
|
|
191
|
+
log.debug("No impact graph for module", fqn=module_fqn)
|
|
192
|
+
continue
|
|
193
|
+
mermaid = compiler.compile(nodes, edges)
|
|
194
|
+
sections.append(f"#### {title} for `{module_fqn}`:\n```mermaid\n{mermaid}\n```")
|
|
195
|
+
stats["with_graph"] = len(sections)
|
|
196
|
+
return sections, stats
|
|
197
|
+
|
|
198
|
+
def collect_graph_context(self) -> str:
|
|
199
|
+
"""Query graph.db for impact graphs of changed files; return Mermaid blocks."""
|
|
200
|
+
if not self.include_graph:
|
|
201
|
+
return "" # diff-only prompt (fits a smaller context window)
|
|
202
|
+
if self.db_path is None or not self.db_path.exists():
|
|
203
|
+
return ""
|
|
204
|
+
changed_files = self.get_changed_py_files()
|
|
205
|
+
if not changed_files:
|
|
206
|
+
return ""
|
|
207
|
+
sections, stats = self._graph_sections(changed_files, flow="flow" in self.features)
|
|
208
|
+
self.graph_stats = stats
|
|
209
|
+
if stats["total"] > 0 and stats["with_graph"] == 0:
|
|
210
|
+
log.warning(
|
|
211
|
+
"No graph context found for any changed file.",
|
|
212
|
+
changed_files=stats["total"],
|
|
213
|
+
project_root=str(self.project_root),
|
|
214
|
+
)
|
|
215
|
+
return "\n\n".join(sections)
|
|
216
|
+
|
|
217
|
+
def collect_drift(self) -> str:
|
|
218
|
+
"""Compact per-domain drift table + quotient k=1 lines (spec §4.3).
|
|
219
|
+
|
|
220
|
+
First real consumer of drift v2 outside tests — the soft enforcement
|
|
221
|
+
channel deferred in #146/#151. Any failure degrades to an empty section.
|
|
222
|
+
"""
|
|
223
|
+
if self.db_path is None or not self.db_path.exists():
|
|
224
|
+
return ""
|
|
225
|
+
patterns = self.project_root / "docs" / "ontology" / "patterns.yaml"
|
|
226
|
+
if not patterns.exists():
|
|
227
|
+
return ""
|
|
228
|
+
try:
|
|
229
|
+
scorer = DriftScorer(str(patterns))
|
|
230
|
+
domains = scorer.load_project_domains()
|
|
231
|
+
quotient_lines: list[str] = []
|
|
232
|
+
with SQLiteStore(str(self.db_path)) as store:
|
|
233
|
+
extractor = FingerprintExtractor(store)
|
|
234
|
+
# default_tolerance=0.50 fallback is deliberate here: collector has no
|
|
235
|
+
# max_drift to thread, and production domains declare their own tolerance.
|
|
236
|
+
reports = [scorer.score(extractor.extract(d.fqn_prefix), d) for d in domains]
|
|
237
|
+
level = scorer.load_project_level()
|
|
238
|
+
if level:
|
|
239
|
+
qnodes, qedges = build_quotient(
|
|
240
|
+
store.get_all_nodes(), store.get_all_edges(), domains
|
|
241
|
+
)
|
|
242
|
+
q_extractor = FingerprintExtractor.from_graph(qnodes, qedges)
|
|
243
|
+
quotient_lines = [
|
|
244
|
+
f"Quotient k=1 [{b.name}] vs {qr.expected_pattern}: "
|
|
245
|
+
f"drift={qr.drift_score:.2f} (observe-only)"
|
|
246
|
+
for b in level
|
|
247
|
+
# default_tolerance=0.50 fallback is deliberate here (see comment above).
|
|
248
|
+
for qr in [scorer.score(q_extractor.extract(b.fqn_prefix), b)]
|
|
249
|
+
]
|
|
250
|
+
except Exception:
|
|
251
|
+
log.warning("Drift section skipped.", exc_info=True)
|
|
252
|
+
return ""
|
|
253
|
+
|
|
254
|
+
if not reports and not quotient_lines: # no domains declared — skip the empty table
|
|
255
|
+
return ""
|
|
256
|
+
|
|
257
|
+
rows = [
|
|
258
|
+
f"| {r.fqn_prefix} | {r.expected_pattern or '(hygiene)'} "
|
|
259
|
+
f"| {r.drift_score:.2f} | {r.tolerance:.2f} "
|
|
260
|
+
f"| {'⚠' if r.drift_score > r.tolerance else ''} |"
|
|
261
|
+
for r in reports
|
|
262
|
+
]
|
|
263
|
+
table = "| domain | expected | drift | tolerance | over |\n|---|---|---|---|---|\n"
|
|
264
|
+
return (
|
|
265
|
+
table + "\n".join(rows) + ("\n" + "\n".join(quotient_lines) if quotient_lines else "")
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def collect_all(self) -> dict[str, str]:
|
|
269
|
+
"""Collects all relevant files, git diff, and optional graph context."""
|
|
270
|
+
context: dict[str, str] = {
|
|
271
|
+
"diff": self.get_git_diff(),
|
|
272
|
+
"contributing": self.read_file("CONTRIBUTING.md"),
|
|
273
|
+
"ontology": self.read_file("docs/ontology/core.yaml"),
|
|
274
|
+
}
|
|
275
|
+
graph_context = self.collect_graph_context()
|
|
276
|
+
if graph_context:
|
|
277
|
+
context["graph_context"] = graph_context
|
|
278
|
+
if "full_files" in self.features:
|
|
279
|
+
full_files = self.collect_full_files()
|
|
280
|
+
if full_files:
|
|
281
|
+
context["full_files"] = full_files
|
|
282
|
+
if "drift" in self.features:
|
|
283
|
+
drift = self.collect_drift()
|
|
284
|
+
if drift:
|
|
285
|
+
context["drift"] = drift
|
|
286
|
+
return context
|
|
287
|
+
|
|
288
|
+
def collect_for_chunk(self, chunk: Chunk) -> dict[str, str]:
|
|
289
|
+
"""Per-chunk context: the chunk's diff, full files, and impact graphs (spec §4.2).
|
|
290
|
+
|
|
291
|
+
chunked implies per-chunk full_files, graph context, AND the flow
|
|
292
|
+
fallback — each chunk gets a small, complete world. graph_stats
|
|
293
|
+
ACCUMULATE across chunks so the footer coverage stays truthful.
|
|
294
|
+
"""
|
|
295
|
+
py_files = [f for f in chunk.files if f.endswith(".py")]
|
|
296
|
+
context: dict[str, str] = {
|
|
297
|
+
"diff": chunk.diff,
|
|
298
|
+
"contributing": self.read_file("CONTRIBUTING.md"),
|
|
299
|
+
"ontology": self.read_file("docs/ontology/core.yaml"),
|
|
300
|
+
}
|
|
301
|
+
sections, stats = self._graph_sections(py_files, flow=True)
|
|
302
|
+
for key, value in stats.items():
|
|
303
|
+
self.graph_stats[key] = self.graph_stats.get(key, 0) + value
|
|
304
|
+
if sections:
|
|
305
|
+
context["graph_context"] = "\n\n".join(sections)
|
|
306
|
+
full_files = self.collect_full_files(py_files)
|
|
307
|
+
if full_files:
|
|
308
|
+
context["full_files"] = full_files
|
|
309
|
+
return context
|
cgis/guardian/core.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Main orchestrator that wires together collector, prompts, and LLM provider."""
|
|
2
|
+
|
|
3
|
+
import structlog
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
|
|
6
|
+
from cgis.guardian.collector import ContextCollector
|
|
7
|
+
from cgis.guardian.findings import ReviewResult, extract_json
|
|
8
|
+
from cgis.guardian.prompts import PromptBuilder
|
|
9
|
+
from cgis.guardian.providers.base import BaseProvider
|
|
10
|
+
from cgis.guardian.skeptic import (
|
|
11
|
+
DEFAULT_SKEPTIC_CONCURRENCY,
|
|
12
|
+
apply_judgements,
|
|
13
|
+
judge_all,
|
|
14
|
+
skeptic_status_for,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
log = structlog.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
_RETRY_SUFFIX = (
|
|
20
|
+
"\n\n---\nYour previous response failed validation against the required JSON schema:\n"
|
|
21
|
+
"{error}\n"
|
|
22
|
+
"Respond again with ONLY the JSON object — no prose, no markdown fences."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _sanitize_finder_result(result: ReviewResult) -> ReviewResult:
|
|
27
|
+
"""Reset skeptic-owned fields the finder LLM may have hallucinated.
|
|
28
|
+
|
|
29
|
+
ReviewResult doubles as the finder's structured-output schema, so the
|
|
30
|
+
model sees `skeptic_status`, the judged/total counters and per-finding
|
|
31
|
+
`verdict`/`skeptic_note`/`impact_score`, and sometimes fills them in. A
|
|
32
|
+
hallucinated `verdict="refuted"` would make visible_findings() silently drop
|
|
33
|
+
a finder finding; a hallucinated `impact_score` would hide one behind the
|
|
34
|
+
impact threshold just as silently — only the skeptic pass may set these.
|
|
35
|
+
"""
|
|
36
|
+
findings = [
|
|
37
|
+
f.model_copy(update={"verdict": None, "skeptic_note": None, "impact_score": None})
|
|
38
|
+
for f in result.findings
|
|
39
|
+
]
|
|
40
|
+
return result.model_copy(
|
|
41
|
+
update={
|
|
42
|
+
"findings": findings,
|
|
43
|
+
"skeptic_status": "off",
|
|
44
|
+
"skeptic_judged": 0,
|
|
45
|
+
"skeptic_total": 0,
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def finder_pass(provider: BaseProvider, context: dict[str, str]) -> ReviewResult:
|
|
51
|
+
"""Run the finder (pass 1) with parse-retry semantics.
|
|
52
|
+
|
|
53
|
+
Parse policy (spec §2.3): one retry with the validation error appended;
|
|
54
|
+
on a second failure the raw text becomes the summary with parse_failed=True.
|
|
55
|
+
Module-level so the chunked orchestrator reuses it per chunk (slice 2).
|
|
56
|
+
"""
|
|
57
|
+
builder = PromptBuilder()
|
|
58
|
+
system_prompt = builder.build_system_prompt()
|
|
59
|
+
user_prompt = builder.build_user_prompt(context)
|
|
60
|
+
raw = await provider.generate_structured(system_prompt, user_prompt, ReviewResult)
|
|
61
|
+
try:
|
|
62
|
+
return _sanitize_finder_result(ReviewResult.model_validate_json(extract_json(raw)))
|
|
63
|
+
except ValidationError as exc:
|
|
64
|
+
log.warning(
|
|
65
|
+
"Structured output failed validation; retrying once.",
|
|
66
|
+
validation_error=str(exc),
|
|
67
|
+
)
|
|
68
|
+
retry_prompt = user_prompt + _RETRY_SUFFIX.format(error=exc)
|
|
69
|
+
raw = await provider.generate_structured(system_prompt, retry_prompt, ReviewResult)
|
|
70
|
+
try:
|
|
71
|
+
return _sanitize_finder_result(ReviewResult.model_validate_json(extract_json(raw)))
|
|
72
|
+
except ValidationError:
|
|
73
|
+
log.exception("Structured output failed twice; falling back to raw text.")
|
|
74
|
+
return ReviewResult(findings=[], summary=raw, parse_failed=True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class GuardianReviewer:
|
|
78
|
+
"""Orchestrates the entire review process."""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
provider: BaseProvider,
|
|
83
|
+
context_collector: ContextCollector,
|
|
84
|
+
skeptic_provider: BaseProvider | None = None,
|
|
85
|
+
concurrency: int = DEFAULT_SKEPTIC_CONCURRENCY,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Wire up the LLM provider, context collector, and optional skeptic.
|
|
88
|
+
|
|
89
|
+
``concurrency`` bounds the per-finding judgement calls; the default keeps
|
|
90
|
+
provider rate limits out of reach (#246 §3.4).
|
|
91
|
+
"""
|
|
92
|
+
self.provider = provider
|
|
93
|
+
self.context_collector = context_collector
|
|
94
|
+
self.skeptic_provider = skeptic_provider
|
|
95
|
+
self.concurrency = concurrency
|
|
96
|
+
|
|
97
|
+
async def _finder_pass(self, context: dict[str, str]) -> ReviewResult:
|
|
98
|
+
"""Delegate to the module-level finder_pass (kept for call-site stability)."""
|
|
99
|
+
return await finder_pass(self.provider, context)
|
|
100
|
+
|
|
101
|
+
async def run_review(self) -> ReviewResult:
|
|
102
|
+
"""Run the review; optionally judge each finding with the skeptic pass (#246 §3.4)."""
|
|
103
|
+
context = self.context_collector.collect_all()
|
|
104
|
+
result = await self._finder_pass(context)
|
|
105
|
+
if self.skeptic_provider is None or not result.findings or result.parse_failed:
|
|
106
|
+
return result
|
|
107
|
+
judgements = await judge_all(
|
|
108
|
+
self.skeptic_provider, result.findings, context.get("diff", ""), self.concurrency
|
|
109
|
+
)
|
|
110
|
+
judged = sum(1 for j in judgements if j is not None)
|
|
111
|
+
if judged == 0:
|
|
112
|
+
log.warning("Every skeptic judgement failed; returning single-pass results.")
|
|
113
|
+
return result.model_copy(
|
|
114
|
+
update={
|
|
115
|
+
"findings": apply_judgements(result.findings, judgements),
|
|
116
|
+
"skeptic_status": skeptic_status_for(judged, len(judgements)),
|
|
117
|
+
"skeptic_judged": judged,
|
|
118
|
+
"skeptic_total": len(judgements),
|
|
119
|
+
}
|
|
120
|
+
)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Pure unified-diff parsers: RIGHT-side line index, and the per-file split.
|
|
2
|
+
|
|
3
|
+
Two families of `---`/`+++` header patterns live here on purpose, and they are
|
|
4
|
+
NOT interchangeable. The line indexer keys on the new path only and ignores
|
|
5
|
+
git's C-quoting; the per-file splitter is quote-aware and falls back to the old
|
|
6
|
+
path so deletions stay reviewable. Keeping them side by side makes the
|
|
7
|
+
difference visible instead of accidental.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
import structlog
|
|
13
|
+
|
|
14
|
+
log = structlog.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
_DEV_NULL = "/dev/null" # git's marker for "this side of the diff has no file"
|
|
17
|
+
|
|
18
|
+
_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
|
19
|
+
_NEW_FILE_RE = re.compile(r"^\+\+\+ (?:b/)?(.+)$")
|
|
20
|
+
|
|
21
|
+
# Quote-aware variants used by split_diff_by_file. Git C-quotes paths with
|
|
22
|
+
# special characters: `--- "a/x y.py"` — the optional quotes wrap the WHOLE
|
|
23
|
+
# `a/...` token, so they must be stripped around the prefix (PR #157 review).
|
|
24
|
+
_QUOTED_OLD_FILE_RE = re.compile(r'^--- "?(?:a/)?(.+?)"?$')
|
|
25
|
+
_QUOTED_NEW_FILE_RE = re.compile(r'^\+\+\+ "?(?:b/)?(.+?)"?$')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _new_file_path(header: re.Match[str]) -> str | None:
|
|
29
|
+
"""New-side path from a matched `+++` header; None for deletions (/dev/null)."""
|
|
30
|
+
path = header.group(1)
|
|
31
|
+
return None if path == _DEV_NULL else path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def diff_line_content(diff_text: str) -> dict[str, dict[int, str]]:
|
|
35
|
+
"""Map each changed file (new path) to ``{RIGHT-side line number: line text}``.
|
|
36
|
+
|
|
37
|
+
The text is the line content with its leading diff marker (``+``/`` ``)
|
|
38
|
+
stripped, so it can be matched verbatim against a finding's quote to anchor
|
|
39
|
+
the comment deterministically (#181). Same right-side accounting as
|
|
40
|
+
:func:`diff_line_index`: added and context lines carry a number, removed
|
|
41
|
+
lines don't, and ``+++ /dev/null`` deletions are excluded.
|
|
42
|
+
"""
|
|
43
|
+
content: dict[str, dict[int, str]] = {}
|
|
44
|
+
current: str | None = None
|
|
45
|
+
in_hunk = False
|
|
46
|
+
new_line = 0
|
|
47
|
+
for line in diff_text.splitlines():
|
|
48
|
+
if line.startswith("diff --git"):
|
|
49
|
+
current = None
|
|
50
|
+
in_hunk = False
|
|
51
|
+
continue
|
|
52
|
+
# Real `+++` headers only appear between hunks (after `diff --git`
|
|
53
|
+
# resets in_hunk); inside a hunk a `+++ ...` line is added CONTENT
|
|
54
|
+
# whose text starts with `++` — counting it as a header would both
|
|
55
|
+
# drop the rest of the file and shift line numbers.
|
|
56
|
+
if not in_hunk and (header := _NEW_FILE_RE.match(line)):
|
|
57
|
+
current = _new_file_path(header)
|
|
58
|
+
continue
|
|
59
|
+
if (hunk := _HUNK_RE.match(line)) and current is not None:
|
|
60
|
+
new_line = int(hunk.group(1))
|
|
61
|
+
in_hunk = True
|
|
62
|
+
content.setdefault(current, {})
|
|
63
|
+
continue
|
|
64
|
+
if not in_hunk or current is None or line.startswith(("-", "\\")):
|
|
65
|
+
continue # outside a hunk / removed line / "\ No newline" marker
|
|
66
|
+
content[current][new_line] = line[1:] # drop the '+'/' ' marker, keep the text
|
|
67
|
+
new_line += 1
|
|
68
|
+
return {path: lines for path, lines in content.items() if lines}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def diff_line_index(diff_text: str) -> dict[str, set[int]]:
|
|
72
|
+
"""Map each changed file (new path) to the set of RIGHT-side line numbers.
|
|
73
|
+
|
|
74
|
+
GitHub only accepts inline review comments on lines present in the diff;
|
|
75
|
+
context and added lines count, removed lines do not (spec §6.2). Renames
|
|
76
|
+
are keyed by the new path so keys match Finding.file. Files deleted in the
|
|
77
|
+
PR (+++ /dev/null) have no RIGHT side and are excluded. Derived from
|
|
78
|
+
:func:`diff_line_content` so the two never diverge.
|
|
79
|
+
"""
|
|
80
|
+
return {path: set(lines) for path, lines in diff_line_content(diff_text).items()}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _block_path(lines: list[str]) -> str | None:
|
|
84
|
+
"""Path for one diff block: the new path, or the old path for deletions.
|
|
85
|
+
|
|
86
|
+
Headers are only read before the first `@@` line — past that, a
|
|
87
|
+
`+++ ...` line is added content (the 5e53dd0 lesson, avoided structurally).
|
|
88
|
+
"""
|
|
89
|
+
old: str | None = None
|
|
90
|
+
new: str | None = None
|
|
91
|
+
for line in lines:
|
|
92
|
+
if line.startswith("@@"):
|
|
93
|
+
break
|
|
94
|
+
if old is None and (m := _QUOTED_OLD_FILE_RE.match(line)):
|
|
95
|
+
old = None if m.group(1) == _DEV_NULL else m.group(1)
|
|
96
|
+
elif new is None and (m := _QUOTED_NEW_FILE_RE.match(line)):
|
|
97
|
+
new = None if m.group(1) == _DEV_NULL else m.group(1)
|
|
98
|
+
return new or old or _git_header_path(lines[0])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _git_header_path(header: str) -> str | None:
|
|
102
|
+
"""Fallback for blocks without ---/+++ headers (binary, mode-only): b/ side.
|
|
103
|
+
|
|
104
|
+
Tries the quoted form first (`diff --git "a/x y.png" "b/x y.png"` — the
|
|
105
|
+
quote sits before b/, so a bare ` b/` search misses it; gemini review,
|
|
106
|
+
PR #157), then the plain ` b/` marker.
|
|
107
|
+
"""
|
|
108
|
+
quoted = ' "b/'
|
|
109
|
+
idx = header.rfind(quoted)
|
|
110
|
+
if idx != -1:
|
|
111
|
+
return header[idx + len(quoted) :].removesuffix('"') or None
|
|
112
|
+
marker = " b/"
|
|
113
|
+
idx = header.rfind(marker)
|
|
114
|
+
if idx == -1:
|
|
115
|
+
return None
|
|
116
|
+
return header[idx + len(marker) :] or None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def split_diff_by_file(diff_text: str) -> dict[str, str]:
|
|
120
|
+
"""Split a unified diff into per-file blocks keyed by repo-relative path.
|
|
121
|
+
|
|
122
|
+
Key = new path; deletions (`+++ /dev/null`) fall back to the old path so
|
|
123
|
+
deleted files stay reviewable (unlike diff_line_index, which drops them — no
|
|
124
|
+
RIGHT side to anchor an inline comment on). Splitting on a column-zero
|
|
125
|
+
`diff --git` is safe: inside a hunk every content line carries a
|
|
126
|
+
`+`/`-`/space prefix, so a bare header can only be a real one.
|
|
127
|
+
"""
|
|
128
|
+
blocks: dict[str, str] = {}
|
|
129
|
+
current: list[str] = []
|
|
130
|
+
|
|
131
|
+
def _flush() -> None:
|
|
132
|
+
if not current:
|
|
133
|
+
return
|
|
134
|
+
path = _block_path(current)
|
|
135
|
+
if path is None:
|
|
136
|
+
log.warning("Diff block without parsable path skipped.", head=current[0])
|
|
137
|
+
else:
|
|
138
|
+
block = "\n".join(current) + "\n"
|
|
139
|
+
if path in blocks:
|
|
140
|
+
log.warning("Duplicate diff block for path; merging.", path=path)
|
|
141
|
+
blocks[path] += block
|
|
142
|
+
else:
|
|
143
|
+
blocks[path] = block
|
|
144
|
+
current.clear()
|
|
145
|
+
|
|
146
|
+
for line in diff_text.splitlines():
|
|
147
|
+
if line.startswith("diff --git"):
|
|
148
|
+
_flush()
|
|
149
|
+
current.append(line)
|
|
150
|
+
_flush()
|
|
151
|
+
return blocks
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Structured findings contract for the Guardian reviewer (spec §2.1)."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
Severity = Literal["critical", "major", "minor"]
|
|
8
|
+
Category = Literal["logic", "contract", "tests", "types", "ontology"]
|
|
9
|
+
Verdict = Literal["confirmed", "refuted", "uncertain"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Finding(BaseModel, frozen=True):
|
|
13
|
+
"""One reviewed defect, anchored to a file (and optionally a line)."""
|
|
14
|
+
|
|
15
|
+
file: str
|
|
16
|
+
# ge (not gt): gemini's response Schema rejects exclusiveMinimum
|
|
17
|
+
line: int | None = Field(default=None, ge=1)
|
|
18
|
+
severity: Severity
|
|
19
|
+
category: Category
|
|
20
|
+
title: str
|
|
21
|
+
evidence: str
|
|
22
|
+
problem: str
|
|
23
|
+
fix: str
|
|
24
|
+
confidence: int = Field(ge=0, le=100)
|
|
25
|
+
# Verbatim single source line the finding sits on, used to derive the inline
|
|
26
|
+
# anchor deterministically instead of trusting the model's ``line`` (#181).
|
|
27
|
+
anchor: str | None = None
|
|
28
|
+
verdict: Verdict | None = None
|
|
29
|
+
skeptic_note: str | None = None
|
|
30
|
+
# 0-10 importance from the skeptic (#246 spec §3.1). None = not judged.
|
|
31
|
+
# Orthogonal to `verdict`: a finding can be true (confirmed) and worthless
|
|
32
|
+
# (score 1), which one enum value cannot express.
|
|
33
|
+
impact_score: int | None = Field(default=None, ge=0, le=10)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ReviewResult(BaseModel, frozen=True):
|
|
37
|
+
"""The full review: findings (empty = LGTM) plus a checked-aspects summary."""
|
|
38
|
+
|
|
39
|
+
findings: list[Finding]
|
|
40
|
+
summary: str
|
|
41
|
+
parse_failed: bool = False
|
|
42
|
+
# "off" = skeptic not configured; "ok" = every finding judged; "partial" =
|
|
43
|
+
# some judgement calls failed (see skeptic_judged/skeptic_total); "failed" =
|
|
44
|
+
# no finding was judged, single-pass results returned (spec §5.5 / #246
|
|
45
|
+
# §3.4 — never silent).
|
|
46
|
+
skeptic_status: Literal["off", "ok", "partial", "failed"] = "off"
|
|
47
|
+
skeptic_judged: int = 0
|
|
48
|
+
skeptic_total: int = 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def extract_json(text: str) -> str:
|
|
52
|
+
"""Return the JSON payload from an LLM response, stripping markdown fences.
|
|
53
|
+
|
|
54
|
+
The closing fence is matched as a real newline followed by ``` — raw
|
|
55
|
+
newlines cannot occur inside JSON strings, so embedded backticks in
|
|
56
|
+
field values are safe. Text after the closing fence is discarded.
|
|
57
|
+
"""
|
|
58
|
+
stripped = text.strip()
|
|
59
|
+
if not stripped.startswith("```"):
|
|
60
|
+
return stripped
|
|
61
|
+
newline = stripped.find("\n")
|
|
62
|
+
if newline == -1:
|
|
63
|
+
return ""
|
|
64
|
+
body = stripped[newline + 1 :]
|
|
65
|
+
closing = body.find("\n```")
|
|
66
|
+
if closing != -1:
|
|
67
|
+
return body[:closing].strip()
|
|
68
|
+
# No newline before the closing fence (e.g. `{...}```` on one line):
|
|
69
|
+
# valid JSON never ends with backticks, so stripping the suffix is safe.
|
|
70
|
+
return body.strip().removesuffix("```").strip()
|