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,133 @@
|
|
|
1
|
+
"""Posts a ReviewResult as one GitHub review with inline comments (spec §6)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from cgis.guardian.findings import Finding, ReviewResult
|
|
7
|
+
from cgis.guardian.render import render_inline_comment, render_review_body
|
|
8
|
+
from cgis.guardian.skeptic import visible_findings
|
|
9
|
+
|
|
10
|
+
#: Below this length a quote is too generic to substring-match safely — short
|
|
11
|
+
#: keywords/patterns (``)``, ``else:``, ``self.``, ``return``) would collide with
|
|
12
|
+
#: unrelated lines (``something_else:``, ``myself.foo``). Such a quote may only
|
|
13
|
+
#: anchor via an EXACT line equality, never a substring (#181 review). A real
|
|
14
|
+
#: anchor is a full statement, comfortably longer than this.
|
|
15
|
+
_MIN_SUBSTRING_ANCHOR = 10
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _anchored_line(finding: Finding, content: dict[int, str] | None) -> int | None:
|
|
19
|
+
"""Derive the inline line from the finding's verbatim quote, not the model's guess (#181).
|
|
20
|
+
|
|
21
|
+
Searches the file's RIGHT-side lines for the one matching the finding's
|
|
22
|
+
``anchor`` (falling back to ``evidence``) and returns its real number:
|
|
23
|
+
- no quote or no file content → keep the model's ``line`` (legacy behaviour);
|
|
24
|
+
- an **exact** stripped-line match wins; only if there is none do we fall back
|
|
25
|
+
to a substring match (``needle in line``), and only for quotes long enough
|
|
26
|
+
that a substring is meaningful — so a ``)`` or ``else:`` can never pull a
|
|
27
|
+
comment onto an unrelated trivial line;
|
|
28
|
+
- among the candidates: the model's ``line`` if it is one, else the nearest;
|
|
29
|
+
- quote present but located nowhere → ``None``, demoting to a body comment
|
|
30
|
+
instead of a confidently-wrong inline anchor.
|
|
31
|
+
|
|
32
|
+
A finding the model marked file-level (``line is None``) with no explicit
|
|
33
|
+
``anchor`` stays file-level: ``evidence`` is supporting text, not a
|
|
34
|
+
positional signal, so it must not promote a file-level note to an inline
|
|
35
|
+
comment at a coincidental textual match (#243 review).
|
|
36
|
+
"""
|
|
37
|
+
if finding.line is None and finding.anchor is None:
|
|
38
|
+
return None
|
|
39
|
+
quote = (finding.anchor or finding.evidence or "").strip()
|
|
40
|
+
if not content or not quote:
|
|
41
|
+
return finding.line
|
|
42
|
+
needle = next((seg.strip() for seg in quote.splitlines() if seg.strip()), "")
|
|
43
|
+
if not needle:
|
|
44
|
+
return finding.line
|
|
45
|
+
matches = [n for n, text in content.items() if text.strip() == needle]
|
|
46
|
+
if not matches and len(needle) >= _MIN_SUBSTRING_ANCHOR:
|
|
47
|
+
matches = [n for n, text in content.items() if needle in text.strip()]
|
|
48
|
+
if not matches:
|
|
49
|
+
return None
|
|
50
|
+
if finding.line is None:
|
|
51
|
+
return matches[0]
|
|
52
|
+
model_line = finding.line # bound local so the closure narrows it to int (mypy)
|
|
53
|
+
if model_line in matches:
|
|
54
|
+
return model_line
|
|
55
|
+
return min(matches, key=lambda n: (abs(n - model_line), n))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_review(
|
|
59
|
+
result: ReviewResult,
|
|
60
|
+
*,
|
|
61
|
+
diff_index: dict[str, set[int]],
|
|
62
|
+
skeptic_model: str | None,
|
|
63
|
+
footer: str = "",
|
|
64
|
+
diff_content: dict[str, dict[int, str]] | None = None,
|
|
65
|
+
threshold: int = 0,
|
|
66
|
+
) -> tuple[str, list[dict[str, object]]]:
|
|
67
|
+
"""Split findings into inline comments vs body text (pure, spec §6.2).
|
|
68
|
+
|
|
69
|
+
A finding whose line is commentable becomes an inline comment; line=None
|
|
70
|
+
or out-of-diff findings land in the review body. Nothing is lost. The
|
|
71
|
+
footer (model/tokens/coverage) is appended to the body: before inline
|
|
72
|
+
posting existed it always reached the PR via the fallback comment, but a
|
|
73
|
+
successful inline post skips that comment — so it must travel here too.
|
|
74
|
+
|
|
75
|
+
When ``diff_content`` is supplied, each finding's line is first re-anchored
|
|
76
|
+
from its verbatim quote (#181) so the comment lands on the real line rather
|
|
77
|
+
than the model's estimate; an unlocatable quote demotes to a body comment.
|
|
78
|
+
"""
|
|
79
|
+
content_by_file = diff_content if diff_content is not None else {}
|
|
80
|
+
inline: list[Finding] = []
|
|
81
|
+
outside: list[Finding] = []
|
|
82
|
+
for raw in visible_findings(result.findings, threshold):
|
|
83
|
+
line = _anchored_line(raw, content_by_file.get(raw.file))
|
|
84
|
+
finding = raw if line == raw.line else raw.model_copy(update={"line": line})
|
|
85
|
+
if finding.line is not None and finding.line in diff_index.get(finding.file, set()):
|
|
86
|
+
inline.append(finding)
|
|
87
|
+
else:
|
|
88
|
+
outside.append(finding)
|
|
89
|
+
comments: list[dict[str, object]] = [
|
|
90
|
+
{
|
|
91
|
+
"path": f.file,
|
|
92
|
+
"line": f.line,
|
|
93
|
+
"side": "RIGHT",
|
|
94
|
+
"body": render_inline_comment(f, skeptic_model=skeptic_model),
|
|
95
|
+
}
|
|
96
|
+
for f in inline
|
|
97
|
+
]
|
|
98
|
+
return render_review_body(result, outside=outside, threshold=threshold) + footer, comments
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def post_inline_review(
|
|
102
|
+
*,
|
|
103
|
+
repo: str,
|
|
104
|
+
pr: int,
|
|
105
|
+
result: ReviewResult,
|
|
106
|
+
diff_index: dict[str, set[int]],
|
|
107
|
+
skeptic_model: str | None,
|
|
108
|
+
footer: str = "",
|
|
109
|
+
diff_content: dict[str, dict[int, str]] | None = None,
|
|
110
|
+
threshold: int = 0,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""POST one COMMENT review via `gh api` (auto-auth in Actions, spec §6.4).
|
|
113
|
+
|
|
114
|
+
Always event=COMMENT, never REQUEST_CHANGES — guardian is an advisor,
|
|
115
|
+
not a gate. Raises CalledProcessError on API rejection; the caller
|
|
116
|
+
decides the fallback (spec §6.5).
|
|
117
|
+
"""
|
|
118
|
+
body, comments = build_review(
|
|
119
|
+
result,
|
|
120
|
+
diff_index=diff_index,
|
|
121
|
+
skeptic_model=skeptic_model,
|
|
122
|
+
footer=footer,
|
|
123
|
+
diff_content=diff_content,
|
|
124
|
+
threshold=threshold,
|
|
125
|
+
)
|
|
126
|
+
payload = {"event": "COMMENT", "body": body, "comments": comments}
|
|
127
|
+
subprocess.run(
|
|
128
|
+
["gh", "api", f"repos/{repo}/pulls/{pr}/reviews", "-X", "POST", "--input", "-"],
|
|
129
|
+
input=json.dumps(payload),
|
|
130
|
+
text=True,
|
|
131
|
+
check=True,
|
|
132
|
+
capture_output=True,
|
|
133
|
+
)
|
cgis/guardian/metrics.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Guardian review quality metrics: append-only JSONL log with precision tracking."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
_DEFAULT_METRICS_FILE = Path("guardian_metrics.jsonl")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SkepticRecord(BaseModel, frozen=True):
|
|
14
|
+
"""What the skeptic pass did, for one review's metrics row (#246).
|
|
15
|
+
|
|
16
|
+
Grouped rather than passed as four loose keywords: they are meaningless
|
|
17
|
+
apart — `judged`/`total` only interpret `status`, and `impact_threshold`
|
|
18
|
+
only interprets what the report then hid.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
model: str | None = None
|
|
22
|
+
status: str = "off"
|
|
23
|
+
judged: int = 0
|
|
24
|
+
total: int = 0
|
|
25
|
+
impact_threshold: int = 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def record_review(
|
|
29
|
+
*,
|
|
30
|
+
model: str,
|
|
31
|
+
pr: int | None,
|
|
32
|
+
prompt_tokens: int,
|
|
33
|
+
completion_tokens: int,
|
|
34
|
+
findings_total: int,
|
|
35
|
+
lgtm: bool,
|
|
36
|
+
parse_failed: bool = False,
|
|
37
|
+
skeptic: SkepticRecord | None = None,
|
|
38
|
+
chunk_count: int | None = None,
|
|
39
|
+
duration_s: float | None = None,
|
|
40
|
+
metrics_path: Path = _DEFAULT_METRICS_FILE,
|
|
41
|
+
) -> Path:
|
|
42
|
+
"""Append one review entry to the metrics JSONL file and return the path.
|
|
43
|
+
|
|
44
|
+
Counts come from the structured ReviewResult — no text parsing.
|
|
45
|
+
Note: lgtm is False on parse_failed runs even though findings_total == 0.
|
|
46
|
+
"""
|
|
47
|
+
skeptic = skeptic or SkepticRecord()
|
|
48
|
+
entry = {
|
|
49
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
50
|
+
"pr": pr,
|
|
51
|
+
"model": model,
|
|
52
|
+
"prompt_tokens": prompt_tokens,
|
|
53
|
+
"completion_tokens": completion_tokens,
|
|
54
|
+
"total_tokens": prompt_tokens + completion_tokens,
|
|
55
|
+
"findings_total": findings_total,
|
|
56
|
+
"findings_applied": None,
|
|
57
|
+
"lgtm": lgtm,
|
|
58
|
+
"parse_failed": parse_failed,
|
|
59
|
+
"skeptic_model": skeptic.model,
|
|
60
|
+
"skeptic_status": skeptic.status,
|
|
61
|
+
"skeptic_judged": skeptic.judged,
|
|
62
|
+
"skeptic_total": skeptic.total,
|
|
63
|
+
"impact_threshold": skeptic.impact_threshold,
|
|
64
|
+
"chunk_count": chunk_count,
|
|
65
|
+
# Wall-clock of the whole LLM phase (finder + skeptic). None on entries
|
|
66
|
+
# written before #275. Completed runs only — a failing review writes no
|
|
67
|
+
# record at all, so this data is survivorship-biased by construction.
|
|
68
|
+
"duration_s": duration_s,
|
|
69
|
+
}
|
|
70
|
+
with metrics_path.open("a", encoding="utf-8") as fh:
|
|
71
|
+
fh.write(json.dumps(entry) + "\n")
|
|
72
|
+
return metrics_path
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def rate_review(pr: int, applied: int, metrics_path: Path = _DEFAULT_METRICS_FILE) -> bool:
|
|
76
|
+
"""Set findings_applied for the most recent entry matching the given PR.
|
|
77
|
+
|
|
78
|
+
Returns True if an entry was updated, False if none found.
|
|
79
|
+
"""
|
|
80
|
+
if not metrics_path.exists():
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
lines = metrics_path.read_text(encoding="utf-8").splitlines()
|
|
84
|
+
updated = False
|
|
85
|
+
result: list[str] = []
|
|
86
|
+
for line in reversed(lines):
|
|
87
|
+
if not updated:
|
|
88
|
+
entry = json.loads(line)
|
|
89
|
+
if entry.get("pr") == pr and entry.get("findings_applied") is None:
|
|
90
|
+
entry["findings_applied"] = applied
|
|
91
|
+
line = json.dumps(entry) # noqa: PLW2901
|
|
92
|
+
updated = True
|
|
93
|
+
result.append(line)
|
|
94
|
+
|
|
95
|
+
if updated:
|
|
96
|
+
metrics_path.write_text("\n".join(reversed(result)) + "\n", encoding="utf-8")
|
|
97
|
+
return updated
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_reviews(metrics_path: Path = _DEFAULT_METRICS_FILE) -> list[dict[str, Any]]:
|
|
101
|
+
"""Return all review records from the metrics file, oldest first."""
|
|
102
|
+
if not metrics_path.exists():
|
|
103
|
+
return []
|
|
104
|
+
return [
|
|
105
|
+
json.loads(line)
|
|
106
|
+
for line in metrics_path.read_text(encoding="utf-8").splitlines()
|
|
107
|
+
if line.strip()
|
|
108
|
+
]
|
cgis/guardian/prompts.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Builds system and user prompts for the Guardian LLM reviewer."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PromptBuilder:
|
|
5
|
+
"""Constructs the system and user prompts."""
|
|
6
|
+
|
|
7
|
+
@staticmethod
|
|
8
|
+
def build_system_prompt() -> str:
|
|
9
|
+
"""Return the system prompt that establishes Guardian's reviewer persona."""
|
|
10
|
+
return (
|
|
11
|
+
"You are the CGIS Guardian finder — a Senior Software Architect hunting for "
|
|
12
|
+
"defects in a code review. You are the FIRST of two stages: a separate skeptic "
|
|
13
|
+
"verifier runs after you and removes false positives. Because of that division "
|
|
14
|
+
"of labour, your job is RECALL — surface every plausible real defect. A missed "
|
|
15
|
+
"bug is the expensive error here; a borderline finding is cheap, because the "
|
|
16
|
+
"skeptic filters it. Surface a finding whenever you can name a CONCRETE failure "
|
|
17
|
+
"scenario — a specific input, state, timing, or platform that makes the code "
|
|
18
|
+
"wrong. Do not self-censor because you are unsure: hand the uncertainty to the "
|
|
19
|
+
"skeptic with your reasoning, do not suppress it. "
|
|
20
|
+
"You prioritise: (1) Logic correctness — wrong output, crashes, data corruption; "
|
|
21
|
+
"(2) Library boundary contracts — convention mismatches at third-party API calls; "
|
|
22
|
+
"(3) Missing test coverage for real edge cases in the diff; "
|
|
23
|
+
"(4) Type safety violations that mypy strict would catch; "
|
|
24
|
+
"(5) Ontology compliance — wrong NodeType/EdgeType mappings corrupt the graph. "
|
|
25
|
+
"You still do NOT flag style preferences, naming conventions, or design "
|
|
26
|
+
"disagreements that have no concrete failure scenario. If the code is correct "
|
|
27
|
+
"and tested, say so."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def build_user_prompt(context: dict[str, str]) -> str:
|
|
32
|
+
"""Assemble the user-turn prompt from collected diff, CONTRIBUTING, ontology, and graph."""
|
|
33
|
+
diff = context.get("diff", "")
|
|
34
|
+
contributing = context.get("contributing", "")
|
|
35
|
+
ontology = context.get("ontology", "")
|
|
36
|
+
graph_context = context.get("graph_context", "")
|
|
37
|
+
|
|
38
|
+
graph_section = ""
|
|
39
|
+
if graph_context:
|
|
40
|
+
graph_section = f"""
|
|
41
|
+
### 4. STRUCTURAL IMPACT GRAPHS (from graph.db)
|
|
42
|
+
The following Mermaid diagrams show which modules depend on the changed files.
|
|
43
|
+
Use these to identify callers that may be broken or require updates.
|
|
44
|
+
|
|
45
|
+
{graph_context}
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
full_files = context.get("full_files", "")
|
|
49
|
+
full_files_section = ""
|
|
50
|
+
if full_files:
|
|
51
|
+
full_files_section = f"""
|
|
52
|
+
### 5. FULL FILE CONTENTS (HEAD)
|
|
53
|
+
Complete current text of the changed files (oversized files carry an explicit
|
|
54
|
+
omission note — treat a note as missing context, not missing code).
|
|
55
|
+
|
|
56
|
+
{full_files}
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
drift = context.get("drift", "")
|
|
60
|
+
drift_section = ""
|
|
61
|
+
if drift:
|
|
62
|
+
drift_section = f"""
|
|
63
|
+
### 6. ARCHITECTURAL DRIFT (motif-basis)
|
|
64
|
+
Per-domain drift vs the declared ideal pattern. A PR pushing a domain past its
|
|
65
|
+
tolerance (⚠) is an `ontology`-category finding. The quotient line is
|
|
66
|
+
observe-only — do NOT flag it.
|
|
67
|
+
|
|
68
|
+
{drift}
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
contributing_section = ""
|
|
72
|
+
if contributing:
|
|
73
|
+
contributing_section = f"""### 1. ENGINEERING STANDARDS (from CONTRIBUTING.md)
|
|
74
|
+
{contributing}
|
|
75
|
+
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
ontology_section = ""
|
|
79
|
+
if ontology:
|
|
80
|
+
ontology_section = f"""### 2. PROJECT ONTOLOGY (from docs/ontology/)
|
|
81
|
+
{ontology}
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
return f"""Review the following Pull Request diff for real defects.
|
|
86
|
+
|
|
87
|
+
{contributing_section}{ontology_section}### 3. CHANGES TO REVIEW (git diff)
|
|
88
|
+
{diff}
|
|
89
|
+
{graph_section}{full_files_section}{drift_section}
|
|
90
|
+
---
|
|
91
|
+
### HUNTING RULES — read before writing a single finding:
|
|
92
|
+
|
|
93
|
+
1. **Evidence first.** Quote the exact line(s) from the diff that the finding sits on.
|
|
94
|
+
The quoted text MUST appear verbatim in section 3 (it positions the inline comment).
|
|
95
|
+
If you cannot find the line, do not raise it.
|
|
96
|
+
|
|
97
|
+
2. **Surface on a nameable failure scenario.** Before writing a finding, ask yourself:
|
|
98
|
+
"Can I describe one concrete input, state, timing, or platform where this code
|
|
99
|
+
misbehaves?" If yes — REPORT it, and put your honest `confidence` in the field.
|
|
100
|
+
Confidence does NOT gate inclusion: a 40%-confident finding with a real failure
|
|
101
|
+
scenario is worth surfacing, because the skeptic verifier decides what to keep.
|
|
102
|
+
Only drop a candidate when you cannot construct ANY failure scenario for it.
|
|
103
|
+
|
|
104
|
+
3. **No ghost issues.** If the code already handles a case, acknowledge it and move on.
|
|
105
|
+
Do not flag something as missing when it is present (this is a wrong finding, not
|
|
106
|
+
an uncertain one — the skeptic cannot rescue precision from a fabricated claim).
|
|
107
|
+
|
|
108
|
+
4. **No invented rules.** Only cite standards explicitly written in CONTRIBUTING.md or the
|
|
109
|
+
ontology files provided above. Do not apply rules from outside this context.
|
|
110
|
+
|
|
111
|
+
5. **No finding cap.** Report every real candidate you find — more genuine findings is
|
|
112
|
+
strictly better here, since recall is your job and the skeptic trims the list. Order
|
|
113
|
+
them most-severe first. Zero is still a valid answer when the diff is clean.
|
|
114
|
+
|
|
115
|
+
6. **Reason per changed function before deciding.** For each function the diff touches,
|
|
116
|
+
briefly walk these before you write findings for it:
|
|
117
|
+
- What are its inputs and where do they come from (caller, config/YAML/JSON, env,
|
|
118
|
+
request)? Is any of them external/untrusted?
|
|
119
|
+
- What happens on the awkward inputs: empty / None / zero / a non-dict where a dict
|
|
120
|
+
is assumed / a scalar where an iterable is assumed / unsorted / duplicate / oversized?
|
|
121
|
+
- Did a deleted or changed line hold an invariant (a guard, a validation, an error
|
|
122
|
+
path)? Is it re-established?
|
|
123
|
+
Only after that walk do you decide what to surface. Skipping this walk is the main
|
|
124
|
+
reason subtle defects (unvalidated data, exact-equality, dropped guards) go unseen.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
### WHAT TO LOOK FOR (focus areas):
|
|
128
|
+
|
|
129
|
+
**Logic bugs** — inputs that produce wrong output, division by zero, off-by-one errors,
|
|
130
|
+
incorrect algorithm behaviour. Think: empty collections, None values, boundary conditions.
|
|
131
|
+
|
|
132
|
+
**Unvalidated external data** — a value read from config/YAML/JSON, env, or a request is
|
|
133
|
+
used as a `dict`/`list`/`set`/iterable (subscripted, iterated, passed to `set()`/`dict()`,
|
|
134
|
+
`.items()`, `for x in value`) without first checking its type or presence. A YAML key the
|
|
135
|
+
author expects to be a mapping can legally be a scalar or a list; a `value or {{}}` idiom
|
|
136
|
+
catches `None` but lets a non-dict truthy value through to operations that then misbehave
|
|
137
|
+
silently rather than erroring. Flag each such use that lacks a type/shape guard.
|
|
138
|
+
|
|
139
|
+
**Exact-equality on floats / money** — bare `==` or `!=` comparing floating-point or
|
|
140
|
+
Decimal values, *including in test assertions* (`assert x == 0.3`). Floating-point
|
|
141
|
+
rounding makes these flaky or wrong; they should use a tolerance compare. Check changed
|
|
142
|
+
test files for this too.
|
|
143
|
+
|
|
144
|
+
**Missing test coverage** — code paths in the diff that have no test. Focus on edge cases
|
|
145
|
+
that could silently return wrong results (not just "coverage for coverage's sake").
|
|
146
|
+
|
|
147
|
+
**Type safety** — implicit `Any`, missing return type annotations, unsafe casts, Pydantic
|
|
148
|
+
models constructed with wrong field types.
|
|
149
|
+
|
|
150
|
+
**Library boundary contracts** — for each call into a third-party library, verify that
|
|
151
|
+
the data passed in matches the library's expected convention:
|
|
152
|
+
- Units and coordinate systems (e.g. center vs top-left, row/col vs x/y,
|
|
153
|
+
naive vs timezone-aware datetimes)
|
|
154
|
+
- Ordering assumptions (e.g. does array order affect rendering or sort stability?)
|
|
155
|
+
- Ownership and mutation (e.g. does the library mutate its input?)
|
|
156
|
+
Trace the value from where it's produced to where it's consumed across the library call.
|
|
157
|
+
If the producing code and consuming code have different assumptions, that's a bug.
|
|
158
|
+
|
|
159
|
+
**Ontology compliance** — wrong NodeType/EdgeType assignments, FQNs not derived from file
|
|
160
|
+
paths, unresolved calls not using `raw_call:` prefix.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
### WORKED EXAMPLES (how the per-function walk turns into a finding):
|
|
164
|
+
|
|
165
|
+
These show the kind of subtle, borderline defect that is easy to skip but worth surfacing.
|
|
166
|
+
Do not look for these exact lines — learn the *pattern* and apply it to the diff above.
|
|
167
|
+
|
|
168
|
+
Example A — unvalidated config value used as a mapping:
|
|
169
|
+
```
|
|
170
|
+
def layers_for(self, name: str) -> set[str]:
|
|
171
|
+
cfg = self._patterns.get(name) # cfg comes from a YAML file
|
|
172
|
+
return set(cfg) # <-- assumes cfg is iterable-of-str
|
|
173
|
+
```
|
|
174
|
+
Walk: input `cfg` is external (YAML); the author assumes a list/mapping, but YAML lets
|
|
175
|
+
that key be a scalar (`name: layered`) → `set("layered")` silently yields `{{'l','a',...}}`,
|
|
176
|
+
not an error. → FINDING: `set(cfg)` lacks a type/shape guard on external config data.
|
|
177
|
+
(confidence ~50 — borderline, but it has a concrete failure scenario, so surface it.)
|
|
178
|
+
|
|
179
|
+
Example B — exact-equality on floats in a test:
|
|
180
|
+
```
|
|
181
|
+
def test_drift_score():
|
|
182
|
+
assert scorer.score(fp) == 0.3 # <-- bare == on a float result
|
|
183
|
+
```
|
|
184
|
+
Walk: `score()` returns a computed float; `== 0.3` is exact float equality → rounding can
|
|
185
|
+
make this assert flaky/false. → FINDING: float compared with bare `==` in a test; use a
|
|
186
|
+
tolerance (`pytest.approx` / `math.isclose`). (confidence ~55.)
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
### OUTPUT FORMAT:
|
|
190
|
+
|
|
191
|
+
Return ONLY a JSON object — no prose, no markdown fences — with this exact shape:
|
|
192
|
+
|
|
193
|
+
{{"findings": [{{"file": "src/path/to/file.py", "line": 123,
|
|
194
|
+
"anchor": "<the exact source line this finding sits on, copied verbatim from section 3>",
|
|
195
|
+
"severity": "critical|major|minor",
|
|
196
|
+
"category": "logic|contract|tests|types|ontology",
|
|
197
|
+
"title": "short headline",
|
|
198
|
+
"evidence": "<verbatim quote from the diff>",
|
|
199
|
+
"problem": "one sentence.", "fix": "concrete suggestion.",
|
|
200
|
+
"confidence": 85}}],
|
|
201
|
+
"summary": "2-3 most important things you checked and found correct."}}
|
|
202
|
+
|
|
203
|
+
Rules:
|
|
204
|
+
- "severity": exactly one of "critical", "major", "minor". "confidence" is an integer 0-100.
|
|
205
|
+
- "category" maps to the focus areas: logic = Logic Bug, contract = Library Contract,
|
|
206
|
+
tests = Test Coverage, types = Type Safety, ontology = Ontology.
|
|
207
|
+
- "line" is the line number in the HEAD version of the file, or null for file-level findings.
|
|
208
|
+
- "anchor" is the single exact line of code the finding refers to, copied VERBATIM from the
|
|
209
|
+
diff in section 3 (no paraphrasing, no `+`/`-` marker). It is used to position the inline
|
|
210
|
+
comment deterministically — if your "line" guess is off, a correct "anchor" still lands the
|
|
211
|
+
comment on the right line. Use null only for genuinely file-level findings.
|
|
212
|
+
- "confidence" is your honest 0-100 estimate; it does NOT gate inclusion — the skeptic
|
|
213
|
+
verifier uses it to prioritise, so report findings below 80 too.
|
|
214
|
+
- no finding cap; report every real candidate, most-severe first; an empty list means LGTM.
|
|
215
|
+
- "summary" is mandatory; for an LGTM it lists what you checked and found correct.
|
|
216
|
+
|
|
217
|
+
Example LGTM response: {{"findings": [], "summary": "Checked diff for logic and types; ok."}}"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LLM provider implementations for Guardian."""
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Abstract base class for LLM provider implementations."""
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import asyncio
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import structlog
|
|
9
|
+
from pydantic import BaseModel, computed_field
|
|
10
|
+
|
|
11
|
+
log = structlog.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
#: Request timeout in SECONDS. Mistral and Gemini take milliseconds and Ollama
|
|
14
|
+
#: takes seconds, so each provider converts at its own call site — the unit is
|
|
15
|
+
#: visible where the conversion happens rather than buried in a constant's name.
|
|
16
|
+
DEFAULT_REQUEST_TIMEOUT = 180.0
|
|
17
|
+
|
|
18
|
+
#: Total attempts per call, including the first.
|
|
19
|
+
MAX_ATTEMPTS = 3
|
|
20
|
+
|
|
21
|
+
#: Backoff base: sleeps are BACKOFF_BASE ** attempt — 2 s, then 4 s.
|
|
22
|
+
BACKOFF_BASE = 2.0
|
|
23
|
+
|
|
24
|
+
#: Retried transport failures. Deliberately the same set the Mistral SDK itself
|
|
25
|
+
#: retries (mistralai/client/utils/retries.py) rather than a list invented here.
|
|
26
|
+
#: google-genai reraises httpx exceptions too, and httpx.NetworkError is the
|
|
27
|
+
#: parent of ConnectError, so this covers every provider.
|
|
28
|
+
RETRYABLE_EXCEPTIONS: tuple[type[Exception], ...] = (
|
|
29
|
+
httpx.TimeoutException,
|
|
30
|
+
httpx.NetworkError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProviderUsage(BaseModel, frozen=True):
|
|
35
|
+
"""Token usage reported by the LLM after a single generate_content call."""
|
|
36
|
+
|
|
37
|
+
prompt_tokens: int = 0
|
|
38
|
+
completion_tokens: int = 0
|
|
39
|
+
|
|
40
|
+
@computed_field # type: ignore[prop-decorator]
|
|
41
|
+
@property
|
|
42
|
+
def total_tokens(self) -> int:
|
|
43
|
+
"""Total tokens consumed by this LLM request, used for cost tracking."""
|
|
44
|
+
return self.prompt_tokens + self.completion_tokens
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BaseProvider(abc.ABC):
|
|
48
|
+
"""Abstract base class for LLM providers."""
|
|
49
|
+
|
|
50
|
+
def __init__(self) -> None:
|
|
51
|
+
"""Initialise usage counters to zero.
|
|
52
|
+
|
|
53
|
+
last_usage reflects the most recent LLM call; cumulative_usage sums
|
|
54
|
+
every call this provider instance has made (a chunked review makes
|
|
55
|
+
N finder calls — and even a single-pass review makes 2 on a parse
|
|
56
|
+
retry, whose first call last_usage used to silently drop).
|
|
57
|
+
"""
|
|
58
|
+
self.last_usage: ProviderUsage = ProviderUsage()
|
|
59
|
+
self.cumulative_usage: ProviderUsage = ProviderUsage()
|
|
60
|
+
|
|
61
|
+
def _record_usage(self, usage: ProviderUsage) -> None:
|
|
62
|
+
"""Record one call's token usage: set last_usage, add to cumulative."""
|
|
63
|
+
self.last_usage = usage
|
|
64
|
+
self.cumulative_usage = ProviderUsage(
|
|
65
|
+
prompt_tokens=self.cumulative_usage.prompt_tokens + usage.prompt_tokens,
|
|
66
|
+
completion_tokens=self.cumulative_usage.completion_tokens + usage.completion_tokens,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def _sleep(self, seconds: float) -> None:
|
|
70
|
+
"""Wait between retries. Overridden in tests so they do not actually wait."""
|
|
71
|
+
await asyncio.sleep(seconds)
|
|
72
|
+
|
|
73
|
+
async def _retry(self, call: Callable[[], Awaitable[str]]) -> str:
|
|
74
|
+
"""Run call, retrying transient transport failures with exponential backoff.
|
|
75
|
+
|
|
76
|
+
Retries only RETRYABLE_EXCEPTIONS: an auth or validation failure must
|
|
77
|
+
fail on the first attempt rather than burn MAX_ATTEMPTS calls. The final
|
|
78
|
+
exception propagates unchanged so callers' existing degradation paths
|
|
79
|
+
still see a real error (#275).
|
|
80
|
+
"""
|
|
81
|
+
for attempt in range(1, MAX_ATTEMPTS + 1):
|
|
82
|
+
try:
|
|
83
|
+
return await call()
|
|
84
|
+
except RETRYABLE_EXCEPTIONS as exc:
|
|
85
|
+
if attempt == MAX_ATTEMPTS:
|
|
86
|
+
log.warning(
|
|
87
|
+
"Provider call failed; retries exhausted.",
|
|
88
|
+
attempts=MAX_ATTEMPTS,
|
|
89
|
+
error=repr(exc),
|
|
90
|
+
)
|
|
91
|
+
raise
|
|
92
|
+
delay = BACKOFF_BASE**attempt
|
|
93
|
+
log.warning(
|
|
94
|
+
"Provider call failed; retrying.",
|
|
95
|
+
attempt=attempt,
|
|
96
|
+
of=MAX_ATTEMPTS,
|
|
97
|
+
delay_s=delay,
|
|
98
|
+
error=repr(exc),
|
|
99
|
+
)
|
|
100
|
+
await self._sleep(delay)
|
|
101
|
+
# Unreachable: the loop either returns or raises on the last attempt.
|
|
102
|
+
raise AssertionError
|
|
103
|
+
|
|
104
|
+
@abc.abstractmethod
|
|
105
|
+
async def generate_content(self, system_prompt: str, user_prompt: str) -> str:
|
|
106
|
+
"""Send a prompt to the LLM and return the text response."""
|
|
107
|
+
|
|
108
|
+
@abc.abstractmethod
|
|
109
|
+
async def generate_structured(
|
|
110
|
+
self, system_prompt: str, user_prompt: str, schema: type[BaseModel]
|
|
111
|
+
) -> str:
|
|
112
|
+
"""Send a prompt requesting JSON conforming to schema; return raw JSON text."""
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Google Gemini LLM provider for Guardian."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from cgis.guardian.providers.base import DEFAULT_REQUEST_TIMEOUT, BaseProvider, ProviderUsage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GeminiProvider(BaseProvider):
|
|
9
|
+
"""Google Gemini provider. Requires: uv sync --group guardian"""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
api_key: str,
|
|
14
|
+
model_name: str = "gemini-2.5-flash",
|
|
15
|
+
timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Store credentials and the request timeout; google-genai is imported lazily.
|
|
18
|
+
|
|
19
|
+
HttpOptions.timeout is documented in MILLISECONDS — the same unit trap
|
|
20
|
+
as Mistral, and the opposite of Ollama's seconds.
|
|
21
|
+
"""
|
|
22
|
+
super().__init__()
|
|
23
|
+
self._api_key = api_key
|
|
24
|
+
self._model_name = model_name
|
|
25
|
+
self._timeout_ms = int(timeout * 1000)
|
|
26
|
+
|
|
27
|
+
async def _generate(
|
|
28
|
+
self, system_prompt: str, user_prompt: str, schema: type[BaseModel] | None
|
|
29
|
+
) -> str:
|
|
30
|
+
"""Shared transport: one generate_content call, optional JSON mode."""
|
|
31
|
+
_install_hint = "google-genai is required. Install with: uv sync --group guardian"
|
|
32
|
+
try:
|
|
33
|
+
from google import genai # noqa: PLC0415
|
|
34
|
+
from google.genai import types # noqa: PLC0415
|
|
35
|
+
except ImportError as exc:
|
|
36
|
+
raise ImportError(_install_hint) from exc
|
|
37
|
+
config_kwargs: dict[str, object] = {"system_instruction": system_prompt}
|
|
38
|
+
if schema is not None:
|
|
39
|
+
config_kwargs["response_mime_type"] = "application/json"
|
|
40
|
+
config_kwargs["response_schema"] = schema
|
|
41
|
+
client = genai.Client(
|
|
42
|
+
api_key=self._api_key,
|
|
43
|
+
http_options=types.HttpOptions(timeout=self._timeout_ms),
|
|
44
|
+
)
|
|
45
|
+
try:
|
|
46
|
+
response = await client.aio.models.generate_content(
|
|
47
|
+
model=self._model_name,
|
|
48
|
+
contents=user_prompt,
|
|
49
|
+
config=types.GenerateContentConfig(**config_kwargs),
|
|
50
|
+
)
|
|
51
|
+
meta = getattr(response, "usage_metadata", None)
|
|
52
|
+
if meta is not None:
|
|
53
|
+
self._record_usage(
|
|
54
|
+
ProviderUsage(
|
|
55
|
+
prompt_tokens=getattr(meta, "prompt_token_count", 0),
|
|
56
|
+
completion_tokens=getattr(meta, "candidates_token_count", 0),
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
return str(response.text)
|
|
60
|
+
finally:
|
|
61
|
+
# genai.Client opens BOTH a sync and an async httpx pool on
|
|
62
|
+
# construction, and each close covers only its own half — the SDK
|
|
63
|
+
# docstrings say so explicitly in both directions. Mistral and Ollama
|
|
64
|
+
# get this from `async with`; Gemini's client is not an async context
|
|
65
|
+
# manager, so it is spelled out here (#283). With the retry from #275
|
|
66
|
+
# calling this up to MAX_ATTEMPTS times, leaking would compound.
|
|
67
|
+
try:
|
|
68
|
+
await client.aio.aclose()
|
|
69
|
+
finally:
|
|
70
|
+
# Nested so the sync pool is released even if the async teardown
|
|
71
|
+
# itself throws — two flat statements would skip it, which is the
|
|
72
|
+
# exact class of leak this change exists to remove (found in review).
|
|
73
|
+
client.close()
|
|
74
|
+
|
|
75
|
+
async def generate_content(self, system_prompt: str, user_prompt: str) -> str:
|
|
76
|
+
"""Send prompts to Gemini and return the text response."""
|
|
77
|
+
return await self._retry(lambda: self._generate(system_prompt, user_prompt, None))
|
|
78
|
+
|
|
79
|
+
async def generate_structured(
|
|
80
|
+
self, system_prompt: str, user_prompt: str, schema: type[BaseModel]
|
|
81
|
+
) -> str:
|
|
82
|
+
"""Send prompts in native JSON mode constrained by schema."""
|
|
83
|
+
return await self._retry(lambda: self._generate(system_prompt, user_prompt, schema))
|