codegraph-engine 3.0.0__1-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.
- codegraph/__init__.py +37 -0
- codegraph/agent.py +26 -0
- codegraph/agent_brain.py +2212 -0
- codegraph/agent_capabilities.py +4058 -0
- codegraph/agent_rules.py +531 -0
- codegraph/api_drift.py +455 -0
- codegraph/architecture.py +371 -0
- codegraph/async_queue/__init__.py +50 -0
- codegraph/async_queue/detectors/__init__.py +48 -0
- codegraph/async_queue/detectors/celery.py +160 -0
- codegraph/async_queue/detectors/kafka.py +168 -0
- codegraph/async_queue/detectors/rabbitmq.py +116 -0
- codegraph/async_queue/detectors/redis.py +130 -0
- codegraph/async_queue/detectors/sqs.py +135 -0
- codegraph/async_queue/models.py +161 -0
- codegraph/async_queue/otel_models.py +74 -0
- codegraph/async_queue/otel_parser.py +337 -0
- codegraph/async_queue/reconciler.py +427 -0
- codegraph/async_queue/scanner.py +291 -0
- codegraph/async_queue/traversal.py +447 -0
- codegraph/audit.py +106 -0
- codegraph/binding_resolver.py +616 -0
- codegraph/branch_comparison.py +197 -0
- codegraph/cache.py +95 -0
- codegraph/change_impact.py +547 -0
- codegraph/cli.py +2436 -0
- codegraph/cli_output.py +205 -0
- codegraph/config.py +45 -0
- codegraph/constraints.py +238 -0
- codegraph/context.py +2182 -0
- codegraph/context_freshness.py +351 -0
- codegraph/database/__init__.py +72 -0
- codegraph/database/extractor.py +3651 -0
- codegraph/database/interrogation.py +1494 -0
- codegraph/database/models.py +310 -0
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +200 -0
- codegraph/errors.py +282 -0
- codegraph/evidence/__init__.py +15 -0
- codegraph/evidence/citations.py +418 -0
- codegraph/evidence_contract.py +486 -0
- codegraph/frameworks.py +960 -0
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +335 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +362 -0
- codegraph/git_state.py +545 -0
- codegraph/graph/__init__.py +46 -0
- codegraph/graph/models.py +56 -0
- codegraph/graph/traversal.py +1993 -0
- codegraph/indexing/__init__.py +24 -0
- codegraph/indexing/classifier.py +564 -0
- codegraph/indexing/indexer.py +2578 -0
- codegraph/indexing/models.py +427 -0
- codegraph/indexing/parser.py +3404 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +211 -0
- codegraph/indexing/telemetry.py +326 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/indexing/test_framework.py +116 -0
- codegraph/installer.py +1702 -0
- codegraph/interrogation.py +2355 -0
- codegraph/llm/__init__.py +3 -0
- codegraph/llm/base.py +15 -0
- codegraph/llm/context.py +20 -0
- codegraph/mcp/__init__.py +3 -0
- codegraph/mcp/server.py +1476 -0
- codegraph/mcp_diagnostics.py +882 -0
- codegraph/memory/__init__.py +3 -0
- codegraph/memory/store.py +46 -0
- codegraph/models.py +310 -0
- codegraph/monorepo.py +925 -0
- codegraph/observability.py +262 -0
- codegraph/optimizer.py +1014 -0
- codegraph/planner.py +417 -0
- codegraph/process_lifecycle.py +1153 -0
- codegraph/py.typed +1 -0
- codegraph/query_expansion.py +199 -0
- codegraph/ranking.py +408 -0
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +414 -0
- codegraph/resolver.py +3968 -0
- codegraph/resources/__init__.py +46 -0
- codegraph/resources/cache.py +117 -0
- codegraph/resources/coalescer.py +83 -0
- codegraph/resources/debouncer.py +98 -0
- codegraph/resources/governor.py +219 -0
- codegraph/resources/monitor.py +102 -0
- codegraph/resources/policy.py +123 -0
- codegraph/retrieval_policy.py +320 -0
- codegraph/roblox.py +2174 -0
- codegraph/route_composer.py +363 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +51 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +692 -0
- codegraph/runtime/models.py +130 -0
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/reconciliation.py +492 -0
- codegraph/runtime/runner.py +353 -0
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/__init__.py +25 -0
- codegraph/search/hybrid.py +846 -0
- codegraph/search/semantic.py +28 -0
- codegraph/security/__init__.py +51 -0
- codegraph/security/paths.py +189 -0
- codegraph/security/redaction.py +633 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/semantic_decorators.py +372 -0
- codegraph/structural_diff.py +542 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +277 -0
- codegraph/target_resolver.py +348 -0
- codegraph/task.py +637 -0
- codegraph/tool_selection_eval.py +1158 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +755 -0
- codegraph/watcher.py +323 -0
- codegraph_engine-3.0.0.dist-info/METADATA +859 -0
- codegraph_engine-3.0.0.dist-info/RECORD +130 -0
- codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
- codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
- codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
- codegraph_engine-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
"""Structural Git Diff Engine.
|
|
2
|
+
|
|
3
|
+
Computes semantic and AST-level differences between Git revisions, branches,
|
|
4
|
+
or between working tree and indexed state.
|
|
5
|
+
|
|
6
|
+
Identifies:
|
|
7
|
+
- File-level additions, deletions, modifications, renames/moves
|
|
8
|
+
- Symbol-level additions, removals, signature and body modifications
|
|
9
|
+
- Relationship-level changes: calls, imports, framework routes, test links, DB models/queries
|
|
10
|
+
- Evidence-backed classifications: FACT, POSSIBLE, UNKNOWN
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import sqlite3
|
|
16
|
+
import subprocess
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from codegraph.indexing.parser import parse
|
|
22
|
+
from codegraph.indexing.scanner import LANGUAGES
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class RenamedFile:
|
|
27
|
+
old_path: str
|
|
28
|
+
new_path: str
|
|
29
|
+
similarity_score: float = 1.0
|
|
30
|
+
|
|
31
|
+
def as_dict(self) -> dict[str, Any]:
|
|
32
|
+
return {
|
|
33
|
+
"old_path": self.old_path,
|
|
34
|
+
"new_path": self.new_path,
|
|
35
|
+
"similarity_score": self.similarity_score,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ChangedSymbol:
|
|
41
|
+
name: str
|
|
42
|
+
qualified_name: str
|
|
43
|
+
path: str
|
|
44
|
+
kind: str
|
|
45
|
+
change_type: str # "ADDED" | "REMOVED" | "MODIFIED" | "MOVED" | "RENAMED"
|
|
46
|
+
old_path: str | None = None
|
|
47
|
+
old_name: str | None = None
|
|
48
|
+
old_line: int | None = None
|
|
49
|
+
new_line: int | None = None
|
|
50
|
+
confidence: str = "FACT" # FACT | POSSIBLE | UNKNOWN
|
|
51
|
+
evidence: str = ""
|
|
52
|
+
|
|
53
|
+
def as_dict(self) -> dict[str, Any]:
|
|
54
|
+
return {
|
|
55
|
+
"name": self.name,
|
|
56
|
+
"qualified_name": self.qualified_name,
|
|
57
|
+
"path": self.path,
|
|
58
|
+
"kind": self.kind,
|
|
59
|
+
"change_type": self.change_type,
|
|
60
|
+
"old_path": self.old_path,
|
|
61
|
+
"old_name": self.old_name,
|
|
62
|
+
"old_line": self.old_line,
|
|
63
|
+
"new_line": self.new_line,
|
|
64
|
+
"confidence": self.confidence,
|
|
65
|
+
"evidence": self.evidence,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ChangedRelationship:
|
|
71
|
+
relationship_type: str # "CALLS" | "IMPORTS" | "ROUTES_TO" | "TESTS" | "DB_MUTATION"
|
|
72
|
+
source: str
|
|
73
|
+
target: str
|
|
74
|
+
change_type: str # "ADDED" | "REMOVED"
|
|
75
|
+
file: str
|
|
76
|
+
confidence: str = "FACT"
|
|
77
|
+
|
|
78
|
+
def as_dict(self) -> dict[str, Any]:
|
|
79
|
+
return {
|
|
80
|
+
"relationship_type": self.relationship_type,
|
|
81
|
+
"source": self.source,
|
|
82
|
+
"target": self.target,
|
|
83
|
+
"change_type": self.change_type,
|
|
84
|
+
"file": self.file,
|
|
85
|
+
"confidence": self.confidence,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class ChangedRoute:
|
|
91
|
+
path: str
|
|
92
|
+
method: str
|
|
93
|
+
handler: str
|
|
94
|
+
framework: str
|
|
95
|
+
change_type: str # "ADDED" | "REMOVED" | "MODIFIED"
|
|
96
|
+
|
|
97
|
+
def as_dict(self) -> dict[str, Any]:
|
|
98
|
+
return {
|
|
99
|
+
"path": self.path,
|
|
100
|
+
"method": self.method,
|
|
101
|
+
"handler": self.handler,
|
|
102
|
+
"framework": self.framework,
|
|
103
|
+
"change_type": self.change_type,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class StructuralDiffReport:
|
|
109
|
+
base_ref: str
|
|
110
|
+
head_ref: str
|
|
111
|
+
added_files: list[str] = field(default_factory=list)
|
|
112
|
+
deleted_files: list[str] = field(default_factory=list)
|
|
113
|
+
modified_files: list[str] = field(default_factory=list)
|
|
114
|
+
renamed_files: list[RenamedFile] = field(default_factory=list)
|
|
115
|
+
added_symbols: list[ChangedSymbol] = field(default_factory=list)
|
|
116
|
+
removed_symbols: list[ChangedSymbol] = field(default_factory=list)
|
|
117
|
+
changed_symbols: list[ChangedSymbol] = field(default_factory=list)
|
|
118
|
+
changed_relationships: list[ChangedRelationship] = field(default_factory=list)
|
|
119
|
+
changed_routes: list[ChangedRoute] = field(default_factory=list)
|
|
120
|
+
affected_tests: list[str] = field(default_factory=list)
|
|
121
|
+
db_impact: list[dict[str, Any]] = field(default_factory=list)
|
|
122
|
+
total_file_changes: int = 0
|
|
123
|
+
total_symbol_changes: int = 0
|
|
124
|
+
|
|
125
|
+
def as_dict(self) -> dict[str, Any]:
|
|
126
|
+
return {
|
|
127
|
+
"base_ref": self.base_ref,
|
|
128
|
+
"head_ref": self.head_ref,
|
|
129
|
+
"added_files": self.added_files,
|
|
130
|
+
"deleted_files": self.deleted_files,
|
|
131
|
+
"modified_files": self.modified_files,
|
|
132
|
+
"renamed_files": [r.as_dict() for r in self.renamed_files],
|
|
133
|
+
"added_symbols": [s.as_dict() for s in self.added_symbols],
|
|
134
|
+
"removed_symbols": [s.as_dict() for s in self.removed_symbols],
|
|
135
|
+
"changed_symbols": [s.as_dict() for s in self.changed_symbols],
|
|
136
|
+
"changed_relationships": [r.as_dict() for r in self.changed_relationships],
|
|
137
|
+
"changed_routes": [r.as_dict() for r in self.changed_routes],
|
|
138
|
+
"affected_tests": self.affected_tests,
|
|
139
|
+
"db_impact": self.db_impact,
|
|
140
|
+
"total_file_changes": self.total_file_changes,
|
|
141
|
+
"total_symbol_changes": self.total_symbol_changes,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _run_git(repository: Path, *args: str, timeout: int = 15) -> str | None:
|
|
146
|
+
for arg in args:
|
|
147
|
+
if any(c in arg for c in (";", "|", "&", "`", "$", "\n", "\r")):
|
|
148
|
+
return None
|
|
149
|
+
try:
|
|
150
|
+
res = subprocess.run(
|
|
151
|
+
["git", *args],
|
|
152
|
+
cwd=str(repository),
|
|
153
|
+
capture_output=True,
|
|
154
|
+
text=True,
|
|
155
|
+
encoding="utf-8",
|
|
156
|
+
errors="replace",
|
|
157
|
+
timeout=timeout,
|
|
158
|
+
)
|
|
159
|
+
return res.stdout if res.returncode == 0 else None
|
|
160
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
_FILE_CONTENT_AT_REF_CACHE: dict[tuple[str, str, str], str | None] = {}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _get_file_content_at_ref(repository: Path, ref: str, file_path: str) -> str | None:
|
|
168
|
+
"""Read historical or current content of a file at a specific git ref or working tree."""
|
|
169
|
+
if ref == "WORKING_TREE":
|
|
170
|
+
abs_p = repository / file_path
|
|
171
|
+
if abs_p.exists() and abs_p.is_file():
|
|
172
|
+
try:
|
|
173
|
+
return abs_p.read_text(encoding="utf-8", errors="replace")
|
|
174
|
+
except OSError:
|
|
175
|
+
return None
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
cache_key = (str(repository), ref, file_path)
|
|
179
|
+
if cache_key in _FILE_CONTENT_AT_REF_CACHE:
|
|
180
|
+
return _FILE_CONTENT_AT_REF_CACHE[cache_key]
|
|
181
|
+
|
|
182
|
+
# Use git show <ref>:<path>
|
|
183
|
+
raw = _run_git(repository, "show", f"{ref}:{file_path}")
|
|
184
|
+
if len(_FILE_CONTENT_AT_REF_CACHE) > 1000:
|
|
185
|
+
_FILE_CONTENT_AT_REF_CACHE.clear()
|
|
186
|
+
_FILE_CONTENT_AT_REF_CACHE[cache_key] = raw
|
|
187
|
+
return raw
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _get_language(file_path: str) -> str | None:
|
|
191
|
+
ext = Path(file_path).suffix.lower()
|
|
192
|
+
return LANGUAGES.get(ext)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
_REF_PATTERN = re.compile(r"^[A-Za-z0-9_.~^/-]{1,100}$")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def compare_revisions(
|
|
199
|
+
repository: Path,
|
|
200
|
+
base: str = "HEAD~1",
|
|
201
|
+
head: str = "HEAD",
|
|
202
|
+
con: sqlite3.Connection | None = None,
|
|
203
|
+
max_symbols_per_file: int = 100,
|
|
204
|
+
) -> StructuralDiffReport:
|
|
205
|
+
"""Compute structural, AST-level difference between two revisions or working tree."""
|
|
206
|
+
if head != "WORKING_TREE" and not _REF_PATTERN.match(head):
|
|
207
|
+
return StructuralDiffReport(base_ref=base, head_ref=head)
|
|
208
|
+
if not _REF_PATTERN.match(base):
|
|
209
|
+
return StructuralDiffReport(base_ref=base, head_ref=head)
|
|
210
|
+
|
|
211
|
+
# Identical ref short-circuit (0ms latency)
|
|
212
|
+
if base == head and head != "WORKING_TREE":
|
|
213
|
+
return StructuralDiffReport(base_ref=base, head_ref=head)
|
|
214
|
+
|
|
215
|
+
# 1. Get raw diff stats with rename detection
|
|
216
|
+
if head == "WORKING_TREE":
|
|
217
|
+
raw_diff = _run_git(repository, "diff", "-M", "--name-status", base)
|
|
218
|
+
else:
|
|
219
|
+
raw_diff = _run_git(repository, "diff", "-M", "--name-status", base, head)
|
|
220
|
+
|
|
221
|
+
if raw_diff is None:
|
|
222
|
+
return StructuralDiffReport(base_ref=base, head_ref=head)
|
|
223
|
+
|
|
224
|
+
added_files: list[str] = []
|
|
225
|
+
deleted_files: list[str] = []
|
|
226
|
+
modified_files: list[str] = []
|
|
227
|
+
renamed_files: list[RenamedFile] = []
|
|
228
|
+
|
|
229
|
+
for line in raw_diff.splitlines():
|
|
230
|
+
if not line.strip():
|
|
231
|
+
continue
|
|
232
|
+
parts = line.split("\t")
|
|
233
|
+
if not parts:
|
|
234
|
+
continue
|
|
235
|
+
status_code = parts[0].strip()
|
|
236
|
+
if status_code.startswith("R") and len(parts) >= 3:
|
|
237
|
+
# Rename: R100 old_path new_path
|
|
238
|
+
score = 1.0
|
|
239
|
+
if len(status_code) > 1 and status_code[1:].isdigit():
|
|
240
|
+
score = float(status_code[1:]) / 100.0
|
|
241
|
+
renamed_files.append(RenamedFile(old_path=parts[1], new_path=parts[2], similarity_score=score))
|
|
242
|
+
elif status_code.startswith("A") and len(parts) >= 2:
|
|
243
|
+
added_files.append(parts[1])
|
|
244
|
+
elif status_code.startswith("D") and len(parts) >= 2:
|
|
245
|
+
deleted_files.append(parts[1])
|
|
246
|
+
elif status_code.startswith("M") and len(parts) >= 2:
|
|
247
|
+
modified_files.append(parts[1])
|
|
248
|
+
|
|
249
|
+
added_symbols: list[ChangedSymbol] = []
|
|
250
|
+
removed_symbols: list[ChangedSymbol] = []
|
|
251
|
+
changed_symbols: list[ChangedSymbol] = []
|
|
252
|
+
changed_relationships: list[ChangedRelationship] = []
|
|
253
|
+
changed_routes: list[ChangedRoute] = []
|
|
254
|
+
affected_tests: set[str] = set()
|
|
255
|
+
db_impact: list[dict[str, Any]] = []
|
|
256
|
+
|
|
257
|
+
# 2. Inspect renamed files
|
|
258
|
+
for r in renamed_files:
|
|
259
|
+
lang = _get_language(r.new_path)
|
|
260
|
+
if not lang:
|
|
261
|
+
continue
|
|
262
|
+
old_content = _get_file_content_at_ref(repository, base, r.old_path)
|
|
263
|
+
new_content = _get_file_content_at_ref(repository, head, r.new_path)
|
|
264
|
+
if old_content and new_content:
|
|
265
|
+
old_res = parse(old_content, lang, r.old_path)
|
|
266
|
+
new_res = parse(new_content, lang, r.new_path)
|
|
267
|
+
for s in new_res.symbols:
|
|
268
|
+
changed_symbols.append(
|
|
269
|
+
ChangedSymbol(
|
|
270
|
+
name=s.name,
|
|
271
|
+
qualified_name=s.qualified_name or s.name,
|
|
272
|
+
path=r.new_path,
|
|
273
|
+
kind=s.kind,
|
|
274
|
+
change_type="MOVED",
|
|
275
|
+
old_path=r.old_path,
|
|
276
|
+
old_name=s.name,
|
|
277
|
+
confidence="FACT",
|
|
278
|
+
evidence=f"File moved from {r.old_path} to {r.new_path} (similarity {r.similarity_score:.0%})",
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# 3. Inspect modified files at AST level
|
|
283
|
+
for m_path in modified_files:
|
|
284
|
+
lang = _get_language(m_path)
|
|
285
|
+
if not lang:
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
old_content = _get_file_content_at_ref(repository, base, m_path) or ""
|
|
289
|
+
new_content = _get_file_content_at_ref(repository, head, m_path) or ""
|
|
290
|
+
|
|
291
|
+
old_res = parse(old_content, lang, m_path)
|
|
292
|
+
new_res = parse(new_content, lang, m_path)
|
|
293
|
+
|
|
294
|
+
old_sym_map = {s.name: s for s in old_res.symbols}
|
|
295
|
+
new_sym_map = {s.name: s for s in new_res.symbols}
|
|
296
|
+
|
|
297
|
+
# Check added symbols in this file
|
|
298
|
+
for name, new_s in new_sym_map.items():
|
|
299
|
+
if name not in old_sym_map:
|
|
300
|
+
added_symbols.append(
|
|
301
|
+
ChangedSymbol(
|
|
302
|
+
name=name,
|
|
303
|
+
qualified_name=new_s.qualified_name or name,
|
|
304
|
+
path=m_path,
|
|
305
|
+
kind=new_s.kind,
|
|
306
|
+
change_type="ADDED",
|
|
307
|
+
new_line=new_s.start_line,
|
|
308
|
+
confidence="FACT",
|
|
309
|
+
evidence=f"Defined at {m_path}:{new_s.start_line}",
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# Check removed symbols in this file
|
|
314
|
+
for name, old_s in old_sym_map.items():
|
|
315
|
+
if name not in new_sym_map:
|
|
316
|
+
removed_symbols.append(
|
|
317
|
+
ChangedSymbol(
|
|
318
|
+
name=name,
|
|
319
|
+
qualified_name=old_s.qualified_name or name,
|
|
320
|
+
path=m_path,
|
|
321
|
+
kind=old_s.kind,
|
|
322
|
+
change_type="REMOVED",
|
|
323
|
+
old_line=old_s.start_line,
|
|
324
|
+
confidence="FACT",
|
|
325
|
+
evidence=f"Removed from {m_path} (was at line {old_s.start_line})",
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# Check modified symbols
|
|
330
|
+
for name, new_s in new_sym_map.items():
|
|
331
|
+
if name in old_sym_map:
|
|
332
|
+
old_s = old_sym_map[name]
|
|
333
|
+
# Compare body content hashes or line ranges
|
|
334
|
+
old_lines = old_content.splitlines()
|
|
335
|
+
new_lines = new_content.splitlines()
|
|
336
|
+
old_snippet = "\n".join(old_lines[max(0, old_s.start_line - 1) : old_s.end_line]).strip()
|
|
337
|
+
new_snippet = "\n".join(new_lines[max(0, new_s.start_line - 1) : new_s.end_line]).strip()
|
|
338
|
+
|
|
339
|
+
if old_snippet != new_snippet:
|
|
340
|
+
changed_symbols.append(
|
|
341
|
+
ChangedSymbol(
|
|
342
|
+
name=name,
|
|
343
|
+
qualified_name=new_s.qualified_name or name,
|
|
344
|
+
path=m_path,
|
|
345
|
+
kind=new_s.kind,
|
|
346
|
+
change_type="MODIFIED",
|
|
347
|
+
old_line=old_s.start_line,
|
|
348
|
+
new_line=new_s.start_line,
|
|
349
|
+
confidence="FACT",
|
|
350
|
+
evidence=f"Body modified at {m_path}:{new_s.start_line}-{new_s.end_line}",
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# Check framework routes diff
|
|
355
|
+
old_routes = {(r.route_path, r.http_method): r for r in old_res.routes}
|
|
356
|
+
new_routes = {(r.route_path, r.http_method): r for r in new_res.routes}
|
|
357
|
+
for (r_path, r_method), r_obj in new_routes.items():
|
|
358
|
+
if (r_path, r_method) not in old_routes:
|
|
359
|
+
changed_routes.append(
|
|
360
|
+
ChangedRoute(
|
|
361
|
+
path=r_path,
|
|
362
|
+
method=r_method,
|
|
363
|
+
handler=r_obj.handler_name or "",
|
|
364
|
+
framework=r_obj.framework or "",
|
|
365
|
+
change_type="ADDED",
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
elif old_routes[(r_path, r_method)].handler_name != r_obj.handler_name:
|
|
369
|
+
changed_routes.append(
|
|
370
|
+
ChangedRoute(
|
|
371
|
+
path=r_path,
|
|
372
|
+
method=r_method,
|
|
373
|
+
handler=r_obj.handler_name or "",
|
|
374
|
+
framework=r_obj.framework or "",
|
|
375
|
+
change_type="MODIFIED",
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
for (r_path, r_method), r_obj in old_routes.items():
|
|
379
|
+
if (r_path, r_method) not in new_routes:
|
|
380
|
+
changed_routes.append(
|
|
381
|
+
ChangedRoute(
|
|
382
|
+
path=r_path,
|
|
383
|
+
method=r_method,
|
|
384
|
+
handler=r_obj.handler_name or "",
|
|
385
|
+
framework=r_obj.framework or "",
|
|
386
|
+
change_type="REMOVED",
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# Check call graph relationships added / removed
|
|
391
|
+
old_calls = {c.callee for c in old_res.calls}
|
|
392
|
+
new_calls = {c.callee for c in new_res.calls}
|
|
393
|
+
for c in new_calls - old_calls:
|
|
394
|
+
changed_relationships.append(
|
|
395
|
+
ChangedRelationship(
|
|
396
|
+
relationship_type="CALLS",
|
|
397
|
+
source=m_path,
|
|
398
|
+
target=c,
|
|
399
|
+
change_type="ADDED",
|
|
400
|
+
file=m_path,
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
for c in old_calls - new_calls:
|
|
404
|
+
changed_relationships.append(
|
|
405
|
+
ChangedRelationship(
|
|
406
|
+
relationship_type="CALLS",
|
|
407
|
+
source=m_path,
|
|
408
|
+
target=c,
|
|
409
|
+
change_type="REMOVED",
|
|
410
|
+
file=m_path,
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# Detect test coverage links if test file
|
|
415
|
+
if "test" in m_path.lower():
|
|
416
|
+
affected_tests.add(m_path)
|
|
417
|
+
|
|
418
|
+
# 4. Check added files at AST level
|
|
419
|
+
for a_path in added_files:
|
|
420
|
+
lang = _get_language(a_path)
|
|
421
|
+
if not lang:
|
|
422
|
+
continue
|
|
423
|
+
content = _get_file_content_at_ref(repository, head, a_path) or ""
|
|
424
|
+
res = parse(content, lang, a_path)
|
|
425
|
+
for s in res.symbols:
|
|
426
|
+
added_symbols.append(
|
|
427
|
+
ChangedSymbol(
|
|
428
|
+
name=s.name,
|
|
429
|
+
qualified_name=s.qualified_name or s.name,
|
|
430
|
+
path=a_path,
|
|
431
|
+
kind=s.kind,
|
|
432
|
+
change_type="ADDED",
|
|
433
|
+
new_line=s.start_line,
|
|
434
|
+
confidence="FACT",
|
|
435
|
+
evidence=f"Defined in newly added file {a_path}:{s.start_line}",
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
for r_obj in res.routes:
|
|
439
|
+
changed_routes.append(
|
|
440
|
+
ChangedRoute(
|
|
441
|
+
path=r_obj.route_path,
|
|
442
|
+
method=r_obj.http_method,
|
|
443
|
+
handler=r_obj.handler_name or "",
|
|
444
|
+
framework=r_obj.framework or "",
|
|
445
|
+
change_type="ADDED",
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
if "test" in a_path.lower():
|
|
449
|
+
affected_tests.add(a_path)
|
|
450
|
+
|
|
451
|
+
# 5. Check deleted files
|
|
452
|
+
for d_path in deleted_files:
|
|
453
|
+
lang = _get_language(d_path)
|
|
454
|
+
if not lang:
|
|
455
|
+
continue
|
|
456
|
+
old_content = _get_file_content_at_ref(repository, base, d_path) or ""
|
|
457
|
+
res = parse(old_content, lang, d_path)
|
|
458
|
+
for s in res.symbols:
|
|
459
|
+
removed_symbols.append(
|
|
460
|
+
ChangedSymbol(
|
|
461
|
+
name=s.name,
|
|
462
|
+
qualified_name=s.qualified_name or s.name,
|
|
463
|
+
path=d_path,
|
|
464
|
+
kind=s.kind,
|
|
465
|
+
change_type="REMOVED",
|
|
466
|
+
old_line=s.start_line,
|
|
467
|
+
confidence="FACT",
|
|
468
|
+
evidence=f"Deleted with file {d_path}",
|
|
469
|
+
)
|
|
470
|
+
)
|
|
471
|
+
for r_obj in res.routes:
|
|
472
|
+
changed_routes.append(
|
|
473
|
+
ChangedRoute(
|
|
474
|
+
path=r_obj.route_path,
|
|
475
|
+
method=r_obj.http_method,
|
|
476
|
+
handler=r_obj.handler_name or "",
|
|
477
|
+
framework=r_obj.framework or "",
|
|
478
|
+
change_type="REMOVED",
|
|
479
|
+
)
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# 6. Database impact (if con is available)
|
|
483
|
+
if con is not None:
|
|
484
|
+
try:
|
|
485
|
+
for cs in changed_symbols + added_symbols + removed_symbols:
|
|
486
|
+
rows = con.execute(
|
|
487
|
+
"SELECT table_name, operation FROM database_queries WHERE symbol=? OR caller_symbol=?",
|
|
488
|
+
(cs.name, cs.name),
|
|
489
|
+
).fetchall()
|
|
490
|
+
for r in rows:
|
|
491
|
+
db_impact.append({
|
|
492
|
+
"symbol": cs.name,
|
|
493
|
+
"table": r[0],
|
|
494
|
+
"operation": r[1],
|
|
495
|
+
"change_type": cs.change_type,
|
|
496
|
+
})
|
|
497
|
+
except (sqlite3.OperationalError, sqlite3.DatabaseError):
|
|
498
|
+
pass
|
|
499
|
+
|
|
500
|
+
total_files = len(added_files) + len(deleted_files) + len(modified_files) + len(renamed_files)
|
|
501
|
+
total_symbols = len(added_symbols) + len(removed_symbols) + len(changed_symbols)
|
|
502
|
+
|
|
503
|
+
return StructuralDiffReport(
|
|
504
|
+
base_ref=base,
|
|
505
|
+
head_ref=head,
|
|
506
|
+
added_files=sorted(added_files),
|
|
507
|
+
deleted_files=sorted(deleted_files),
|
|
508
|
+
modified_files=sorted(modified_files),
|
|
509
|
+
renamed_files=renamed_files,
|
|
510
|
+
added_symbols=added_symbols,
|
|
511
|
+
removed_symbols=removed_symbols,
|
|
512
|
+
changed_symbols=changed_symbols,
|
|
513
|
+
changed_relationships=changed_relationships,
|
|
514
|
+
changed_routes=changed_routes,
|
|
515
|
+
affected_tests=sorted(affected_tests),
|
|
516
|
+
db_impact=db_impact,
|
|
517
|
+
total_file_changes=total_files,
|
|
518
|
+
total_symbol_changes=total_symbols,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def compare_branches(
|
|
523
|
+
repository: Path,
|
|
524
|
+
base_branch: str = "main",
|
|
525
|
+
head_branch: str = "HEAD",
|
|
526
|
+
con: sqlite3.Connection | None = None,
|
|
527
|
+
) -> StructuralDiffReport:
|
|
528
|
+
"""Compare two branches structurally using their merge-base or direct revision diff."""
|
|
529
|
+
if base_branch == head_branch:
|
|
530
|
+
return StructuralDiffReport(base_ref=base_branch, head_ref=head_branch)
|
|
531
|
+
# Find merge base if available
|
|
532
|
+
merge_base = _run_git(repository, "merge-base", base_branch, head_branch)
|
|
533
|
+
base = merge_base.strip() if merge_base else base_branch
|
|
534
|
+
return compare_revisions(repository, base=base, head=head_branch, con=con)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def compare_head_with_index(
|
|
538
|
+
repository: Path,
|
|
539
|
+
con: sqlite3.Connection | None = None,
|
|
540
|
+
) -> StructuralDiffReport:
|
|
541
|
+
"""Compare uncommitted working tree changes against current HEAD."""
|
|
542
|
+
return compare_revisions(repository, base="HEAD", head="WORKING_TREE", con=con)
|
codegraph/styles.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""CSS, SCSS, and CSS Modules Architecture and Selector Analyzer (Pillar 4).
|
|
2
|
+
|
|
3
|
+
Extracts deterministic facts from stylesheets:
|
|
4
|
+
- CSS class selectors (.btn-primary, .invoice-card, .header_title)
|
|
5
|
+
- CSS custom properties/variables (--primary-color, --spacing-md)
|
|
6
|
+
- CSS module boundaries
|
|
7
|
+
- Emits Symbol entities for style search and cross-component impact analysis.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from codegraph.indexing.models import Symbol
|
|
15
|
+
|
|
16
|
+
# CSS Class Selector pattern: .className { or .className:hover or .className.active
|
|
17
|
+
# Excludes decimals like .5s, .75em, .0
|
|
18
|
+
_CSS_CLASS_RE = re.compile(
|
|
19
|
+
r"""(?<![\w\.\-])\.([A-Za-z_][A-Za-z0-9_-]*)(?=[ \t\r\n\.:,\{>+~])"""
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# CSS Custom Property / Variable declaration: --primary-color: #0070f3;
|
|
23
|
+
_CSS_VAR_RE = re.compile(
|
|
24
|
+
r"""(--[A-Za-z_][A-Za-z0-9_-]*)\s*:"""
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Hex color detector to ignore #fff, #123456 as ID selectors
|
|
28
|
+
_HEX_COLOR_RE = re.compile(r"^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class CssClassDef:
|
|
33
|
+
name: str
|
|
34
|
+
file_path: str
|
|
35
|
+
line: int
|
|
36
|
+
is_module: bool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class CssVariableDef:
|
|
41
|
+
name: str
|
|
42
|
+
file_path: str
|
|
43
|
+
line: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class StyleFileResult:
|
|
48
|
+
classes: tuple[CssClassDef, ...] = ()
|
|
49
|
+
variables: tuple[CssVariableDef, ...] = ()
|
|
50
|
+
symbols: tuple[Symbol, ...] = ()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_style_file(file_path: str) -> bool:
|
|
54
|
+
"""Check if file is a CSS, SCSS, Sass, or Less stylesheet."""
|
|
55
|
+
lower = file_path.lower()
|
|
56
|
+
return lower.endswith((".css", ".scss", ".sass", ".less"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_style_file(content: str, file_path: str) -> StyleFileResult:
|
|
60
|
+
"""Extract CSS classes, custom properties, and symbols from stylesheet."""
|
|
61
|
+
if not is_style_file(file_path):
|
|
62
|
+
return StyleFileResult()
|
|
63
|
+
|
|
64
|
+
is_module = ".module." in file_path.lower()
|
|
65
|
+
classes: list[CssClassDef] = []
|
|
66
|
+
variables: list[CssVariableDef] = []
|
|
67
|
+
symbols: list[Symbol] = []
|
|
68
|
+
|
|
69
|
+
seen_classes: set[str] = set()
|
|
70
|
+
seen_vars: set[str] = set()
|
|
71
|
+
|
|
72
|
+
clean_file = file_path.replace("\\", "/").lstrip("./")
|
|
73
|
+
|
|
74
|
+
for idx, line in enumerate(content.splitlines()):
|
|
75
|
+
lineno = idx + 1
|
|
76
|
+
stripped = line.strip()
|
|
77
|
+
|
|
78
|
+
# Skip comment-only lines
|
|
79
|
+
if stripped.startswith("/*") or stripped.startswith("*") or stripped.startswith("//"):
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
# Extract CSS variables
|
|
83
|
+
for m_var in _CSS_VAR_RE.finditer(line):
|
|
84
|
+
v_name = m_var.group(1)
|
|
85
|
+
if v_name not in seen_vars:
|
|
86
|
+
seen_vars.add(v_name)
|
|
87
|
+
variables.append(CssVariableDef(name=v_name, file_path=clean_file, line=lineno))
|
|
88
|
+
canon_id = f"{clean_file}::{v_name}"
|
|
89
|
+
symbols.append(
|
|
90
|
+
Symbol(
|
|
91
|
+
id=canon_id,
|
|
92
|
+
canonical_id=canon_id,
|
|
93
|
+
name=v_name,
|
|
94
|
+
qualified_name=v_name,
|
|
95
|
+
kind="style_var",
|
|
96
|
+
language="css",
|
|
97
|
+
module=clean_file,
|
|
98
|
+
path=clean_file,
|
|
99
|
+
file_path=clean_file,
|
|
100
|
+
scope="",
|
|
101
|
+
start_line=lineno,
|
|
102
|
+
end_line=lineno,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Extract CSS classes
|
|
107
|
+
for m_cls in _CSS_CLASS_RE.finditer(line):
|
|
108
|
+
c_name = m_cls.group(1)
|
|
109
|
+
# Avoid pseudo-elements and animations
|
|
110
|
+
if c_name in ("hover", "focus", "active", "visited", "disabled", "first-child", "last-child"):
|
|
111
|
+
continue
|
|
112
|
+
if c_name not in seen_classes:
|
|
113
|
+
seen_classes.add(c_name)
|
|
114
|
+
classes.append(
|
|
115
|
+
CssClassDef(
|
|
116
|
+
name=c_name,
|
|
117
|
+
file_path=clean_file,
|
|
118
|
+
line=lineno,
|
|
119
|
+
is_module=is_module,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
canon_id = f"{clean_file}::{c_name}"
|
|
123
|
+
symbols.append(
|
|
124
|
+
Symbol(
|
|
125
|
+
id=canon_id,
|
|
126
|
+
canonical_id=canon_id,
|
|
127
|
+
name=f".{c_name}",
|
|
128
|
+
qualified_name=f".{c_name}",
|
|
129
|
+
kind="style_class",
|
|
130
|
+
language="css",
|
|
131
|
+
module=clean_file,
|
|
132
|
+
path=clean_file,
|
|
133
|
+
file_path=clean_file,
|
|
134
|
+
scope="",
|
|
135
|
+
start_line=lineno,
|
|
136
|
+
end_line=lineno,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return StyleFileResult(
|
|
141
|
+
classes=tuple(classes),
|
|
142
|
+
variables=tuple(variables),
|
|
143
|
+
symbols=tuple(symbols),
|
|
144
|
+
)
|