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.
Files changed (130) hide show
  1. codegraph/__init__.py +37 -0
  2. codegraph/agent.py +26 -0
  3. codegraph/agent_brain.py +2212 -0
  4. codegraph/agent_capabilities.py +4058 -0
  5. codegraph/agent_rules.py +531 -0
  6. codegraph/api_drift.py +455 -0
  7. codegraph/architecture.py +371 -0
  8. codegraph/async_queue/__init__.py +50 -0
  9. codegraph/async_queue/detectors/__init__.py +48 -0
  10. codegraph/async_queue/detectors/celery.py +160 -0
  11. codegraph/async_queue/detectors/kafka.py +168 -0
  12. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  13. codegraph/async_queue/detectors/redis.py +130 -0
  14. codegraph/async_queue/detectors/sqs.py +135 -0
  15. codegraph/async_queue/models.py +161 -0
  16. codegraph/async_queue/otel_models.py +74 -0
  17. codegraph/async_queue/otel_parser.py +337 -0
  18. codegraph/async_queue/reconciler.py +427 -0
  19. codegraph/async_queue/scanner.py +291 -0
  20. codegraph/async_queue/traversal.py +447 -0
  21. codegraph/audit.py +106 -0
  22. codegraph/binding_resolver.py +616 -0
  23. codegraph/branch_comparison.py +197 -0
  24. codegraph/cache.py +95 -0
  25. codegraph/change_impact.py +547 -0
  26. codegraph/cli.py +2436 -0
  27. codegraph/cli_output.py +205 -0
  28. codegraph/config.py +45 -0
  29. codegraph/constraints.py +238 -0
  30. codegraph/context.py +2182 -0
  31. codegraph/context_freshness.py +351 -0
  32. codegraph/database/__init__.py +72 -0
  33. codegraph/database/extractor.py +3651 -0
  34. codegraph/database/interrogation.py +1494 -0
  35. codegraph/database/models.py +310 -0
  36. codegraph/database/schema_drift.py +862 -0
  37. codegraph/epistemic.py +200 -0
  38. codegraph/errors.py +282 -0
  39. codegraph/evidence/__init__.py +15 -0
  40. codegraph/evidence/citations.py +418 -0
  41. codegraph/evidence_contract.py +486 -0
  42. codegraph/frameworks.py +960 -0
  43. codegraph/frameworks_nestjs.py +502 -0
  44. codegraph/frameworks_nextjs.py +337 -0
  45. codegraph/frameworks_react.py +457 -0
  46. codegraph/freshness.py +335 -0
  47. codegraph/frontend_html.py +109 -0
  48. codegraph/git.py +362 -0
  49. codegraph/git_state.py +545 -0
  50. codegraph/graph/__init__.py +46 -0
  51. codegraph/graph/models.py +56 -0
  52. codegraph/graph/traversal.py +1993 -0
  53. codegraph/indexing/__init__.py +24 -0
  54. codegraph/indexing/classifier.py +564 -0
  55. codegraph/indexing/indexer.py +2578 -0
  56. codegraph/indexing/models.py +427 -0
  57. codegraph/indexing/parser.py +3404 -0
  58. codegraph/indexing/pool.py +136 -0
  59. codegraph/indexing/scanner.py +211 -0
  60. codegraph/indexing/telemetry.py +326 -0
  61. codegraph/indexing/templates.py +418 -0
  62. codegraph/indexing/test_framework.py +116 -0
  63. codegraph/installer.py +1702 -0
  64. codegraph/interrogation.py +2355 -0
  65. codegraph/llm/__init__.py +3 -0
  66. codegraph/llm/base.py +15 -0
  67. codegraph/llm/context.py +20 -0
  68. codegraph/mcp/__init__.py +3 -0
  69. codegraph/mcp/server.py +1476 -0
  70. codegraph/mcp_diagnostics.py +882 -0
  71. codegraph/memory/__init__.py +3 -0
  72. codegraph/memory/store.py +46 -0
  73. codegraph/models.py +310 -0
  74. codegraph/monorepo.py +925 -0
  75. codegraph/observability.py +262 -0
  76. codegraph/optimizer.py +1014 -0
  77. codegraph/planner.py +417 -0
  78. codegraph/process_lifecycle.py +1153 -0
  79. codegraph/py.typed +1 -0
  80. codegraph/query_expansion.py +199 -0
  81. codegraph/ranking.py +408 -0
  82. codegraph/refactor/__init__.py +36 -0
  83. codegraph/refactor/models.py +114 -0
  84. codegraph/refactor/renamer.py +844 -0
  85. codegraph/refactor/transactions.py +414 -0
  86. codegraph/resolver.py +3968 -0
  87. codegraph/resources/__init__.py +46 -0
  88. codegraph/resources/cache.py +117 -0
  89. codegraph/resources/coalescer.py +83 -0
  90. codegraph/resources/debouncer.py +98 -0
  91. codegraph/resources/governor.py +219 -0
  92. codegraph/resources/monitor.py +102 -0
  93. codegraph/resources/policy.py +123 -0
  94. codegraph/retrieval_policy.py +320 -0
  95. codegraph/roblox.py +2174 -0
  96. codegraph/route_composer.py +363 -0
  97. codegraph/route_topology.py +172 -0
  98. codegraph/runtime/__init__.py +51 -0
  99. codegraph/runtime/collector.py +155 -0
  100. codegraph/runtime/db_watcher.py +191 -0
  101. codegraph/runtime/ingestor.py +692 -0
  102. codegraph/runtime/models.py +130 -0
  103. codegraph/runtime/port_inspector.py +304 -0
  104. codegraph/runtime/reconciliation.py +492 -0
  105. codegraph/runtime/runner.py +353 -0
  106. codegraph/runtime/trace_linker.py +175 -0
  107. codegraph/search/__init__.py +25 -0
  108. codegraph/search/hybrid.py +846 -0
  109. codegraph/search/semantic.py +28 -0
  110. codegraph/security/__init__.py +51 -0
  111. codegraph/security/paths.py +189 -0
  112. codegraph/security/redaction.py +633 -0
  113. codegraph/semantic_conflicts.py +270 -0
  114. codegraph/semantic_decorators.py +372 -0
  115. codegraph/structural_diff.py +542 -0
  116. codegraph/styles.py +144 -0
  117. codegraph/symbol_history.py +277 -0
  118. codegraph/target_resolver.py +348 -0
  119. codegraph/task.py +637 -0
  120. codegraph/tool_selection_eval.py +1158 -0
  121. codegraph/tsconfig.py +236 -0
  122. codegraph/ui/__init__.py +6 -0
  123. codegraph/ui/server.py +755 -0
  124. codegraph/watcher.py +323 -0
  125. codegraph_engine-3.0.0.dist-info/METADATA +859 -0
  126. codegraph_engine-3.0.0.dist-info/RECORD +130 -0
  127. codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
  128. codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
  129. codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
  130. 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
+ )