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
@@ -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()