codegraph-brain 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cgis/__init__.py +10 -0
- cgis/__main__.py +16 -0
- cgis/api/.gitkeep +0 -0
- cgis/api/__init__.py +1 -0
- cgis/api/mcp_server.py +550 -0
- cgis/cli.py +1516 -0
- cgis/core/.gitkeep +0 -0
- cgis/core/models.py +140 -0
- cgis/extractors/.gitkeep +0 -0
- cgis/extractors/_python_ast.py +194 -0
- cgis/extractors/_python_classes.py +126 -0
- cgis/extractors/_python_functions.py +312 -0
- cgis/extractors/_python_imports.py +188 -0
- cgis/extractors/_python_types.py +84 -0
- cgis/extractors/base.py +42 -0
- cgis/extractors/python_extractor.py +235 -0
- cgis/extractors/typescript_extractor.py +310 -0
- cgis/guardian/__init__.py +1 -0
- cgis/guardian/bench.py +199 -0
- cgis/guardian/chunked.py +240 -0
- cgis/guardian/chunker.py +148 -0
- cgis/guardian/collector.py +309 -0
- cgis/guardian/core.py +120 -0
- cgis/guardian/diff_index.py +151 -0
- cgis/guardian/findings.py +70 -0
- cgis/guardian/github_poster.py +133 -0
- cgis/guardian/metrics.py +108 -0
- cgis/guardian/prompts.py +217 -0
- cgis/guardian/providers/__init__.py +1 -0
- cgis/guardian/providers/base.py +112 -0
- cgis/guardian/providers/gemini.py +83 -0
- cgis/guardian/providers/mistral.py +83 -0
- cgis/guardian/providers/ollama.py +99 -0
- cgis/guardian/recording.py +82 -0
- cgis/guardian/render.py +107 -0
- cgis/guardian/runner.py +295 -0
- cgis/guardian/skeptic.py +208 -0
- cgis/pipeline.py +252 -0
- cgis/py.typed +0 -0
- cgis/query/analysis/__init__.py +0 -0
- cgis/query/analysis/analyzer.py +241 -0
- cgis/query/analysis/anomaly.py +34 -0
- cgis/query/analysis/cohesion.py +277 -0
- cgis/query/analysis/health.py +128 -0
- cgis/query/analysis/suggest_service.py +221 -0
- cgis/query/context/__init__.py +0 -0
- cgis/query/context/audit.py +134 -0
- cgis/query/context/context_service.py +129 -0
- cgis/query/context/prompt.py +184 -0
- cgis/query/context/snippet.py +96 -0
- cgis/query/drift/__init__.py +0 -0
- cgis/query/drift/_scc.py +90 -0
- cgis/query/drift/drift.py +867 -0
- cgis/query/drift/drift_service.py +217 -0
- cgis/query/drift/fingerprint.py +255 -0
- cgis/query/drift/fractal.py +292 -0
- cgis/query/drift/ontology_init.py +470 -0
- cgis/query/drift/quotient.py +75 -0
- cgis/query/drift/triads.py +234 -0
- cgis/query/engine.py +170 -0
- cgis/query/fqn.py +65 -0
- cgis/query/render/__init__.py +0 -0
- cgis/query/render/graph_json.py +42 -0
- cgis/query/render/mermaid.py +235 -0
- cgis/query/render/metrics.py +346 -0
- cgis/resolver/.gitkeep +0 -0
- cgis/resolver/__init__.py +1 -0
- cgis/resolver/engine.py +145 -0
- cgis/resolver/indices.py +184 -0
- cgis/resolver/symbols.py +179 -0
- cgis/resolver/uplift.py +263 -0
- cgis/storage/.gitkeep +0 -0
- cgis/storage/sqlite_store.py +589 -0
- codegraph_brain-0.6.0.dist-info/METADATA +240 -0
- codegraph_brain-0.6.0.dist-info/RECORD +78 -0
- codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
- codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
- codegraph_brain-0.6.0.dist-info/licenses/LICENSE +21 -0
cgis/guardian/skeptic.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Skeptic pass: one judgement per finding on two axes (spec §5, amended by #246).
|
|
2
|
+
|
|
3
|
+
Each finding gets its own LLM call carrying only its file's hunks, and comes
|
|
4
|
+
back with a ``verdict`` (is the claim true) and an ``impact_score`` 0-10 (does
|
|
5
|
+
it matter). The batch call this replaced put every finding in one window and
|
|
6
|
+
addressed verdicts by list index; it could neither rank nor survive a partial
|
|
7
|
+
failure.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
import structlog
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
from cgis.guardian.diff_index import split_diff_by_file
|
|
18
|
+
from cgis.guardian.findings import Finding, extract_json
|
|
19
|
+
from cgis.guardian.providers.base import BaseProvider
|
|
20
|
+
|
|
21
|
+
log = structlog.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_UNCERTAIN_MULTIPLIER = 0.9 # an 'uncertain' verdict discounts confidence as a
|
|
24
|
+
# ranking signal only — it NEVER refutes. The recall-lean finder emits genuine
|
|
25
|
+
# low-confidence findings on purpose; only an explicit 'refuted' verdict drops one.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FindingJudgement(BaseModel, frozen=True):
|
|
29
|
+
"""One skeptic ruling on one finding, on two orthogonal axes (#246 spec §3.1).
|
|
30
|
+
|
|
31
|
+
``verdict`` answers "is this claim true" and only an explicit 'refuted'
|
|
32
|
+
drops the finding. ``impact_score`` answers "does it matter" and only hides
|
|
33
|
+
below a threshold. There is no index: a judgement belongs to the call that
|
|
34
|
+
produced it, which is why the batch API's index-mapping failure modes
|
|
35
|
+
(out-of-range, duplicate) cannot occur here.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
verdict: Literal["confirmed", "refuted", "uncertain"]
|
|
39
|
+
impact_score: int = Field(ge=0, le=10)
|
|
40
|
+
rationale: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Confirm-by-default stance. The original refute-by-default wording over-killed
|
|
44
|
+
# in benchmarks: a gemini-3.5-flash skeptic refuted 7/7 finder findings,
|
|
45
|
+
# including 2 ground-truth matches on PR 122 (gate allows at most 1 lost match).
|
|
46
|
+
# The finder is now recall-lean (no confidence gate, no cap), so it surfaces
|
|
47
|
+
# genuine low-confidence findings BY DESIGN and leans on this pass for precision —
|
|
48
|
+
# the skeptic must cut hallucinations without re-introducing the gate it removed.
|
|
49
|
+
SKEPTIC_SYSTEM_PROMPT = (
|
|
50
|
+
"You are a skeptical senior reviewer double-checking another reviewer's findings. "
|
|
51
|
+
"That reviewer optimises for RECALL: it deliberately surfaces plausible, sometimes "
|
|
52
|
+
"low-confidence candidates and relies on you to remove only the ones that are wrong. "
|
|
53
|
+
"For each finding, verify the quoted evidence against the diff and judge whether the "
|
|
54
|
+
"claimed defect is real. Refute a finding ONLY when you can point to concrete evidence "
|
|
55
|
+
"that it is wrong: the quoted code does not appear in the diff, the case is already "
|
|
56
|
+
"handled, or the claim misreads what the code does. Do NOT refute a finding merely for "
|
|
57
|
+
"being low-confidence, speculative, or a judgement call — if it is plausible and you "
|
|
58
|
+
"cannot disprove it, mark it 'confirmed' or 'uncertain' (both are kept), never 'refuted'."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
IMPACT_RUBRIC = """
|
|
63
|
+
Also rate how much this finding MATTERS, independent of whether it is true,
|
|
64
|
+
as impact_score 0-10:
|
|
65
|
+
|
|
66
|
+
- 0-2 true but not actionable: style, taste, "consider X for explicitness",
|
|
67
|
+
or restating something the project's tooling already enforces.
|
|
68
|
+
If `ruff`, `ruff format` or `mypy --strict` would catch it, score <= 2:
|
|
69
|
+
those run as mandatory gates in this repo, so such issues are already
|
|
70
|
+
covered.
|
|
71
|
+
- 3-5 a minor real issue: local clarity or robustness, no behaviour change.
|
|
72
|
+
- 6-8 a real defect with a concrete failure path in this diff.
|
|
73
|
+
- 9-10 a broken contract, a security hole, or data loss.
|
|
74
|
+
|
|
75
|
+
A true finding can score 0. Scoring is NOT a second chance to refute: judge
|
|
76
|
+
truth and importance independently."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_judgement_prompt(finding: Finding, hunks: str) -> str:
|
|
80
|
+
"""Assemble the user prompt judging ONE finding against its own file's hunks.
|
|
81
|
+
|
|
82
|
+
The finder's ``confidence`` and ``severity`` are deliberately omitted: both
|
|
83
|
+
are its own guess at what this pass re-derives independently, and showing
|
|
84
|
+
them anchors the judge on the claim it is meant to check (#246 §3.3).
|
|
85
|
+
"""
|
|
86
|
+
location = f"{finding.file}:{finding.line}" if finding.line is not None else finding.file
|
|
87
|
+
return f"""Another reviewer claims this diff contains a defect.
|
|
88
|
+
|
|
89
|
+
### THE CLAIM
|
|
90
|
+
Location: {location}
|
|
91
|
+
Title: {finding.title}
|
|
92
|
+
Quoted code: {finding.evidence}
|
|
93
|
+
Problem: {finding.problem}
|
|
94
|
+
Proposed fix: {finding.fix}
|
|
95
|
+
|
|
96
|
+
### THE DIFF HUNKS FOR THAT FILE
|
|
97
|
+
{hunks or "(no hunks available for this file)"}
|
|
98
|
+
|
|
99
|
+
### HOW TO JUDGE
|
|
100
|
+
Verify the quoted code against the hunks above. If the claim depends on code
|
|
101
|
+
that is NOT in these hunks, you cannot check it: that is grounds for
|
|
102
|
+
'uncertain', never for 'refuted'.
|
|
103
|
+
{IMPACT_RUBRIC}
|
|
104
|
+
|
|
105
|
+
### OUTPUT FORMAT
|
|
106
|
+
Return ONLY a JSON object:
|
|
107
|
+
{{"verdict": "confirmed|refuted|uncertain", "impact_score": 0, "rationale": "one sentence"}}"""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
DEFAULT_SKEPTIC_CONCURRENCY = 3
|
|
111
|
+
# Bounded because provider rate limits are the binding constraint, not local CPU:
|
|
112
|
+
# mistral's free tier caps tokens per MINUTE, and a local ollama skeptic
|
|
113
|
+
# serialises on one model instance anyway.
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def judge_finding(
|
|
117
|
+
provider: BaseProvider, finding: Finding, hunks: str
|
|
118
|
+
) -> FindingJudgement | None:
|
|
119
|
+
"""Judge one finding; None means this call failed (#246 §3.2).
|
|
120
|
+
|
|
121
|
+
Every failure mode — transport error, rate limit, unparseable output —
|
|
122
|
+
collapses to None so the caller keeps the finding unruled and visible. A
|
|
123
|
+
skeptic that cannot answer must never be able to silence a finding.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
raw = await provider.generate_structured(
|
|
127
|
+
SKEPTIC_SYSTEM_PROMPT, build_judgement_prompt(finding, hunks), FindingJudgement
|
|
128
|
+
)
|
|
129
|
+
return FindingJudgement.model_validate_json(extract_json(raw))
|
|
130
|
+
except Exception:
|
|
131
|
+
log.warning("Skeptic judgement failed; finding stays unruled.", file=finding.file)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def skeptic_status_for(judged: int, total: int) -> Literal["ok", "partial", "failed"]:
|
|
136
|
+
"""Map judged/total onto the reported skeptic status (#246 §3.4).
|
|
137
|
+
|
|
138
|
+
Public and shared: the chunked orchestrator reports the same statuses, and
|
|
139
|
+
two copies of this mapping would be free to drift apart.
|
|
140
|
+
"""
|
|
141
|
+
if judged == 0:
|
|
142
|
+
return "failed"
|
|
143
|
+
return "ok" if judged == total else "partial"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def judge_all(
|
|
147
|
+
provider: BaseProvider,
|
|
148
|
+
findings: list[Finding],
|
|
149
|
+
diff: str,
|
|
150
|
+
concurrency: int = DEFAULT_SKEPTIC_CONCURRENCY,
|
|
151
|
+
) -> list[FindingJudgement | None]:
|
|
152
|
+
"""Judge every finding concurrently, each against its own file's hunks.
|
|
153
|
+
|
|
154
|
+
Returns one entry per finding, positionally aligned, with None where that
|
|
155
|
+
finding's call failed — so a single failure costs one verdict instead of the
|
|
156
|
+
whole pass, which is what the batch call it replaces did.
|
|
157
|
+
"""
|
|
158
|
+
blocks = split_diff_by_file(diff)
|
|
159
|
+
semaphore = asyncio.Semaphore(max(1, concurrency))
|
|
160
|
+
|
|
161
|
+
async def _one(finding: Finding) -> FindingJudgement | None:
|
|
162
|
+
async with semaphore:
|
|
163
|
+
return await judge_finding(provider, finding, blocks.get(finding.file, ""))
|
|
164
|
+
|
|
165
|
+
return list(await asyncio.gather(*(_one(f) for f in findings)))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def apply_judgements(
|
|
169
|
+
findings: list[Finding], judgements: list[FindingJudgement | None]
|
|
170
|
+
) -> list[Finding]:
|
|
171
|
+
"""Merge per-finding judgements into new frozen copies, positionally (#246 §3.2).
|
|
172
|
+
|
|
173
|
+
``judgements[i]`` rules on ``findings[i]``; a ``None`` means that call failed
|
|
174
|
+
and the finding stays unruled — absence of a judgement is not a refutation.
|
|
175
|
+
'uncertain' discounts confidence x0.9 as a ranking signal only, identical to
|
|
176
|
+
the batch merge it replaces.
|
|
177
|
+
"""
|
|
178
|
+
merged: list[Finding] = []
|
|
179
|
+
for finding, judgement in zip(findings, judgements, strict=True):
|
|
180
|
+
if judgement is None:
|
|
181
|
+
merged.append(finding)
|
|
182
|
+
continue
|
|
183
|
+
update: dict[str, object] = {
|
|
184
|
+
"verdict": judgement.verdict,
|
|
185
|
+
"skeptic_note": judgement.rationale,
|
|
186
|
+
"impact_score": judgement.impact_score,
|
|
187
|
+
}
|
|
188
|
+
if judgement.verdict == "uncertain":
|
|
189
|
+
update["confidence"] = round(finding.confidence * _UNCERTAIN_MULTIPLIER)
|
|
190
|
+
merged.append(finding.model_copy(update=update))
|
|
191
|
+
return merged
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def visible_findings(findings: Iterable[Finding], threshold: int = 0) -> list[Finding]:
|
|
195
|
+
"""Findings that appear in the rendered report (#246 §3.2).
|
|
196
|
+
|
|
197
|
+
Hidden: anything refuted, and anything the skeptic scored below
|
|
198
|
+
``threshold``. An unjudged finding (``impact_score is None``) has no score to
|
|
199
|
+
compare and is always shown — a failed judgement call must never silence a
|
|
200
|
+
finding. Hidden findings stay in ``ReviewResult.findings`` so metrics and the
|
|
201
|
+
benchmark still see what was cut.
|
|
202
|
+
"""
|
|
203
|
+
return [
|
|
204
|
+
f
|
|
205
|
+
for f in findings
|
|
206
|
+
if f.verdict != "refuted"
|
|
207
|
+
and not (f.impact_score is not None and f.impact_score < threshold)
|
|
208
|
+
]
|
cgis/pipeline.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Implements Pipeline to orcestrate code traversal."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
12
|
+
|
|
13
|
+
from cgis.core.models import Edge, Node
|
|
14
|
+
from cgis.extractors.base import BaseExtractor
|
|
15
|
+
from cgis.resolver.engine import ResolverEngine
|
|
16
|
+
from cgis.resolver.uplift import SemanticUpliftEngine
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from cgis.storage.sqlite_store import SQLiteStore
|
|
20
|
+
|
|
21
|
+
logger = structlog.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IngestionPipeline:
|
|
25
|
+
"""Orchestrates the full Extract → Resolve → Store pipeline over a source tree."""
|
|
26
|
+
|
|
27
|
+
_extractors: Mapping[str, BaseExtractor]
|
|
28
|
+
_domains_config: str | None
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
extractors: Mapping[str, BaseExtractor],
|
|
33
|
+
domains_config: str | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Args:
|
|
37
|
+
extractors: Map of file extensions to their respective extractors.
|
|
38
|
+
e.g., {".py": PythonExtractor()}
|
|
39
|
+
domains_config: Optional path to a domains.yaml file. When provided,
|
|
40
|
+
the SemanticUpliftEngine runs after resolution.
|
|
41
|
+
"""
|
|
42
|
+
self._extractors = extractors
|
|
43
|
+
self._domains_config = domains_config
|
|
44
|
+
self._excluded = {"venv", ".venv", "__pycache__", "node_modules", "build", "dist"}
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _compute_hash(content: str) -> str:
|
|
48
|
+
"""Return the MD5 hex digest of the given source content string."""
|
|
49
|
+
return hashlib.md5(content.encode("utf-8"), usedforsecurity=False).hexdigest()
|
|
50
|
+
|
|
51
|
+
def run(
|
|
52
|
+
self,
|
|
53
|
+
repo_path: str,
|
|
54
|
+
store: "SQLiteStore | None" = None,
|
|
55
|
+
) -> tuple[list[Node], list[Edge], list[Edge]]:
|
|
56
|
+
"""
|
|
57
|
+
The main pipeline execution: Walk -> Extract -> Resolve.
|
|
58
|
+
|
|
59
|
+
When `store` is provided the pipeline runs in incremental mode:
|
|
60
|
+
unchanged files (same MD5) are skipped and their nodes are loaded
|
|
61
|
+
from the store for the resolver. Only changed/new files are
|
|
62
|
+
re-extracted and persisted. Stale files (removed from disk) are
|
|
63
|
+
cleaned up automatically.
|
|
64
|
+
"""
|
|
65
|
+
all_nodes: list[Node] = []
|
|
66
|
+
all_edges: list[Edge] = []
|
|
67
|
+
# file_path -> new hash, only for files that were re-extracted
|
|
68
|
+
changed_files: dict[str, str] = {}
|
|
69
|
+
found_file_paths: set[str] = set()
|
|
70
|
+
|
|
71
|
+
path = Path(repo_path)
|
|
72
|
+
if not path.exists():
|
|
73
|
+
msg = f"Path not found: {repo_path}"
|
|
74
|
+
raise FileNotFoundError(msg)
|
|
75
|
+
if not path.is_dir():
|
|
76
|
+
msg = f"Path is not a directory: {repo_path}"
|
|
77
|
+
raise NotADirectoryError(msg)
|
|
78
|
+
|
|
79
|
+
# Canonical workspace root: resolve symlinks + relative dots so that
|
|
80
|
+
# both `cgis ingest ./src` and `cgis ingest /abs/path/src` produce
|
|
81
|
+
# identical file_paths and FQNs in the database.
|
|
82
|
+
workspace_root = path.resolve()
|
|
83
|
+
|
|
84
|
+
with Progress(
|
|
85
|
+
SpinnerColumn(),
|
|
86
|
+
TextColumn("[progress.description]{task.description}"),
|
|
87
|
+
transient=True,
|
|
88
|
+
console=Console(stderr=True),
|
|
89
|
+
) as progress:
|
|
90
|
+
# Task 1: Extraction
|
|
91
|
+
extract_task = progress.add_task(description="Extracting code entities...", total=None)
|
|
92
|
+
|
|
93
|
+
for root, dirs, files in workspace_root.walk():
|
|
94
|
+
dirs[:] = [d for d in dirs if not d.startswith(".") and d not in self._excluded]
|
|
95
|
+
for file in files:
|
|
96
|
+
extractor = self._get_extractor(file)
|
|
97
|
+
if not extractor:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
full_path = root / file
|
|
101
|
+
try:
|
|
102
|
+
# Resolve symlinks before relativising so that a link
|
|
103
|
+
# pointing outside the workspace is caught and skipped.
|
|
104
|
+
rel_path_str = full_path.resolve().relative_to(workspace_root).as_posix()
|
|
105
|
+
except ValueError:
|
|
106
|
+
logger.warning("File outside workspace root, skipping", file=str(full_path))
|
|
107
|
+
continue
|
|
108
|
+
found_file_paths.add(rel_path_str)
|
|
109
|
+
|
|
110
|
+
self._process_file(
|
|
111
|
+
full_path,
|
|
112
|
+
rel_path_str,
|
|
113
|
+
extractor,
|
|
114
|
+
store,
|
|
115
|
+
all_nodes,
|
|
116
|
+
all_edges,
|
|
117
|
+
changed_files,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
progress.update(extract_task, advance=1)
|
|
121
|
+
|
|
122
|
+
# Incremental no-op short-circuit (#185): when nothing changed and
|
|
123
|
+
# nothing went stale, the persisted graph is already correct.
|
|
124
|
+
# Re-running the resolver + persistence + uplift would rebuild the
|
|
125
|
+
# whole graph from the DB for zero benefit, so skip them entirely.
|
|
126
|
+
if store is not None and self._is_noop_incremental(
|
|
127
|
+
store, changed_files, found_file_paths
|
|
128
|
+
):
|
|
129
|
+
logger.info("No changes detected — skipping resolution and persistence.")
|
|
130
|
+
return all_nodes, all_edges, []
|
|
131
|
+
|
|
132
|
+
# Task 2: Resolution
|
|
133
|
+
resolve_task = progress.add_task(description="Resolving semantic links...", total=None)
|
|
134
|
+
logger.info("Starting resolution phase...")
|
|
135
|
+
resolver = ResolverEngine(all_nodes, all_edges)
|
|
136
|
+
resolved_edges, virtual_nodes = resolver.resolve()
|
|
137
|
+
all_nodes.extend(virtual_nodes)
|
|
138
|
+
progress.update(resolve_task, advance=1)
|
|
139
|
+
logger.info(
|
|
140
|
+
"Resolution complete.",
|
|
141
|
+
edges=len(resolved_edges),
|
|
142
|
+
virtual_nodes=len(virtual_nodes),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if store is not None:
|
|
146
|
+
self._persist_incremental(
|
|
147
|
+
store, all_nodes, resolved_edges, changed_files, found_file_paths, virtual_nodes
|
|
148
|
+
)
|
|
149
|
+
logger.info("Running semantic uplift...")
|
|
150
|
+
SemanticUpliftEngine(store, self._domains_config).execute_uplift()
|
|
151
|
+
logger.info("Semantic uplift complete.")
|
|
152
|
+
|
|
153
|
+
return all_nodes, all_edges, resolved_edges
|
|
154
|
+
|
|
155
|
+
def _process_file(
|
|
156
|
+
self,
|
|
157
|
+
full_path: Path,
|
|
158
|
+
full_path_str: str,
|
|
159
|
+
extractor: BaseExtractor,
|
|
160
|
+
store: "SQLiteStore | None",
|
|
161
|
+
all_nodes: list[Node],
|
|
162
|
+
all_edges: list[Edge],
|
|
163
|
+
changed_files: dict[str, str],
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Extract nodes/edges from one file, applying hash-based skip when store is provided."""
|
|
166
|
+
try:
|
|
167
|
+
with full_path.open(encoding="utf-8") as f:
|
|
168
|
+
code = f.read()
|
|
169
|
+
|
|
170
|
+
if store is not None:
|
|
171
|
+
file_hash = self._compute_hash(code)
|
|
172
|
+
if store.get_file_hash(full_path_str) == file_hash:
|
|
173
|
+
all_nodes.extend(store.get_nodes_by_file(full_path_str))
|
|
174
|
+
return
|
|
175
|
+
changed_files[full_path_str] = file_hash
|
|
176
|
+
|
|
177
|
+
nodes, edges = extractor.parse(code, full_path_str)
|
|
178
|
+
if nodes:
|
|
179
|
+
logger.info("Parsed nodes from file", nodes=len(nodes), full_path=full_path_str)
|
|
180
|
+
all_nodes.extend(nodes)
|
|
181
|
+
all_edges.extend(edges)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.exception("Failed to parse file", full_path=full_path, error=str(e))
|
|
184
|
+
|
|
185
|
+
def _is_noop_incremental(
|
|
186
|
+
self, store: "SQLiteStore", changed_files: dict[str, str], found_file_paths: set[str]
|
|
187
|
+
) -> bool:
|
|
188
|
+
"""True when an incremental run can be skipped entirely.
|
|
189
|
+
|
|
190
|
+
Requires no re-extracted files and no stale files. A configured domains
|
|
191
|
+
ontology also disables the skip: ``domains.yaml`` can change independently
|
|
192
|
+
of the source tree, and the semantic uplift that applies it must re-run
|
|
193
|
+
to pick those changes up (it is not tracked in ``changed_files``).
|
|
194
|
+
|
|
195
|
+
``changed_files`` / ``domains_config`` are checked before the
|
|
196
|
+
``get_all_tracked_files`` DB query so it is only issued when it can
|
|
197
|
+
actually change the outcome.
|
|
198
|
+
"""
|
|
199
|
+
if changed_files or self._domains_config is not None:
|
|
200
|
+
return False
|
|
201
|
+
stale_files = store.get_all_tracked_files() - found_file_paths
|
|
202
|
+
return not stale_files
|
|
203
|
+
|
|
204
|
+
def _persist_incremental(
|
|
205
|
+
self,
|
|
206
|
+
store: "SQLiteStore",
|
|
207
|
+
all_nodes: list[Node],
|
|
208
|
+
resolved_edges: list[Edge],
|
|
209
|
+
changed_files: dict[str, str],
|
|
210
|
+
found_file_paths: set[str],
|
|
211
|
+
virtual_nodes: list[Node] | None = None,
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Persist only changed files and clean up stale ones in one transaction."""
|
|
214
|
+
nodes_by_file: dict[str, list[Node]] = {}
|
|
215
|
+
for node in all_nodes:
|
|
216
|
+
if node.file_path in changed_files:
|
|
217
|
+
nodes_by_file.setdefault(node.file_path, []).append(node)
|
|
218
|
+
|
|
219
|
+
# Map source node → file so structural edges (file_path=None) can be assigned
|
|
220
|
+
source_to_file: dict[str, str] = {
|
|
221
|
+
node.id: node.file_path for node in all_nodes if node.file_path in changed_files
|
|
222
|
+
}
|
|
223
|
+
edges_by_file: dict[str, list[Edge]] = {}
|
|
224
|
+
for edge in resolved_edges:
|
|
225
|
+
file_path = edge.file_path or source_to_file.get(edge.source)
|
|
226
|
+
if file_path and file_path in changed_files:
|
|
227
|
+
edges_by_file.setdefault(file_path, []).append(edge)
|
|
228
|
+
|
|
229
|
+
stale_files = store.get_all_tracked_files() - found_file_paths
|
|
230
|
+
store.save_incremental_batch(nodes_by_file, edges_by_file, changed_files, stale_files)
|
|
231
|
+
|
|
232
|
+
# Virtual nodes are upserted separately — never deleted — because in incremental
|
|
233
|
+
# mode only changed files' edges are re-resolved, so virtual_nodes is incomplete.
|
|
234
|
+
# Orphaned virtual nodes (no incoming edges) are harmless phantom data.
|
|
235
|
+
if virtual_nodes:
|
|
236
|
+
store.upsert_nodes(virtual_nodes)
|
|
237
|
+
|
|
238
|
+
for file_path in changed_files:
|
|
239
|
+
logger.info("Re-ingested changed file", file_path=file_path)
|
|
240
|
+
for stale_path in stale_files:
|
|
241
|
+
logger.info("Removed stale file from graph", file_path=stale_path)
|
|
242
|
+
|
|
243
|
+
_TEST_FILE_PATTERN = re.compile(r"\.(test|spec)\.(py|ts|tsx|js|jsx)$", re.IGNORECASE)
|
|
244
|
+
|
|
245
|
+
def _get_extractor(self, filename: str) -> BaseExtractor | None:
|
|
246
|
+
"""Return the registered extractor for the given filename, or None."""
|
|
247
|
+
if self._TEST_FILE_PATTERN.search(filename):
|
|
248
|
+
return None
|
|
249
|
+
for ext, extractor in self._extractors.items():
|
|
250
|
+
if filename.endswith(ext):
|
|
251
|
+
return extractor
|
|
252
|
+
return None
|
cgis/py.typed
ADDED
|
File without changes
|
|
File without changes
|