codegraph-engine 3.0.0__2-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 +868 -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
codegraph/freshness.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Repository Freshness & Dependency-Aware Invalidation Engine.
|
|
2
|
+
|
|
3
|
+
Tracks:
|
|
4
|
+
current Git HEAD, indexed Git HEAD, file hashes, parser version,
|
|
5
|
+
schema version, index generation, timestamps, and parse_failed state.
|
|
6
|
+
|
|
7
|
+
Provides dependency-aware invalidation so localized file updates only
|
|
8
|
+
invalidate affected symbols, references, graph edges, evidence, and cached
|
|
9
|
+
context packets rather than rebuilding the entire repository.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import shlex
|
|
15
|
+
import sqlite3
|
|
16
|
+
import subprocess
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FreshnessStatus(StrEnum):
|
|
23
|
+
FRESH = "FRESH"
|
|
24
|
+
STALE = "STALE"
|
|
25
|
+
PARTIALLY_STALE = "PARTIALLY_STALE"
|
|
26
|
+
UNKNOWN = "UNKNOWN"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class FreshnessReport:
|
|
31
|
+
status: FreshnessStatus
|
|
32
|
+
indexed_commit: str | None
|
|
33
|
+
current_commit: str | None
|
|
34
|
+
index_timestamp: int | None
|
|
35
|
+
modified_files: list[str] = field(default_factory=list)
|
|
36
|
+
deleted_files: list[str] = field(default_factory=list)
|
|
37
|
+
parse_failed_files: list[str] = field(default_factory=list)
|
|
38
|
+
parser_version: str | None = None
|
|
39
|
+
index_generation: int = 0
|
|
40
|
+
detail: str = ""
|
|
41
|
+
|
|
42
|
+
def as_dict(self) -> dict[str, object]:
|
|
43
|
+
return {
|
|
44
|
+
"status": self.status.value,
|
|
45
|
+
"indexed_commit": self.indexed_commit,
|
|
46
|
+
"current_commit": self.current_commit,
|
|
47
|
+
"index_timestamp": self.index_timestamp,
|
|
48
|
+
"modified_files": self.modified_files,
|
|
49
|
+
"deleted_files": self.deleted_files,
|
|
50
|
+
"parse_failed_files": self.parse_failed_files,
|
|
51
|
+
"parser_version": self.parser_version,
|
|
52
|
+
"index_generation": self.index_generation,
|
|
53
|
+
"detail": self.detail,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run_git(repository: Path, *args: str, timeout: int = 10) -> str | None:
|
|
58
|
+
"""Run an allowlisted read-only git command inside repository."""
|
|
59
|
+
if not shlex.split("git")[0]: # pragma: no cover
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
["git", *args],
|
|
64
|
+
cwd=str(repository),
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
encoding="utf-8",
|
|
68
|
+
errors="replace",
|
|
69
|
+
timeout=timeout,
|
|
70
|
+
)
|
|
71
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
72
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def current_commit(repository: Path) -> str | None:
|
|
77
|
+
"""Return the current HEAD commit SHA, or None if not a git repository."""
|
|
78
|
+
return _run_git(repository, "rev-parse", "HEAD")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def current_branch(repository: Path) -> str | None:
|
|
82
|
+
"""Return the current git branch name, or None."""
|
|
83
|
+
return _run_git(repository, "rev-parse", "--abbrev-ref", "HEAD")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _get_meta(con: sqlite3.Connection, key: str) -> str | None:
|
|
87
|
+
for table in ("metadata", "codegraph_meta"):
|
|
88
|
+
try:
|
|
89
|
+
row = con.execute(
|
|
90
|
+
f"SELECT value FROM {table} WHERE key=?", (key,)
|
|
91
|
+
).fetchone()
|
|
92
|
+
if row and row[0] != "":
|
|
93
|
+
return str(row[0])
|
|
94
|
+
except sqlite3.OperationalError:
|
|
95
|
+
continue
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def indexed_commit(con: sqlite3.Connection) -> str | None:
|
|
100
|
+
"""Read the commit SHA recorded when the index was last built."""
|
|
101
|
+
return _get_meta(con, "indexed_commit")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def index_timestamp(con: sqlite3.Connection) -> int | None:
|
|
105
|
+
"""Return unix timestamp of the last indexing run."""
|
|
106
|
+
val = _get_meta(con, "index_timestamp")
|
|
107
|
+
try:
|
|
108
|
+
return int(val) if val is not None else None
|
|
109
|
+
except ValueError:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def index_generation(con: sqlite3.Connection) -> int:
|
|
114
|
+
"""Return the monotonic index generation counter."""
|
|
115
|
+
val = _get_meta(con, "index_generation")
|
|
116
|
+
try:
|
|
117
|
+
return int(val) if val is not None else 0
|
|
118
|
+
except ValueError:
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def save_commit(con: sqlite3.Connection, commit: str | None, timestamp: int) -> None:
|
|
123
|
+
"""Persist commit SHA and timestamp into metadata tables."""
|
|
124
|
+
_ensure_meta_table(con)
|
|
125
|
+
for table in ("codegraph_meta", "metadata"):
|
|
126
|
+
for key, val in [
|
|
127
|
+
("indexed_commit", commit or ""),
|
|
128
|
+
("index_timestamp", str(timestamp)),
|
|
129
|
+
]:
|
|
130
|
+
try:
|
|
131
|
+
con.execute(
|
|
132
|
+
f"INSERT INTO {table}(key, value) VALUES(?,?) "
|
|
133
|
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
134
|
+
(key, val),
|
|
135
|
+
)
|
|
136
|
+
except sqlite3.OperationalError:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _ensure_meta_table(con: sqlite3.Connection) -> None:
|
|
141
|
+
con.execute(
|
|
142
|
+
"CREATE TABLE IF NOT EXISTS codegraph_meta "
|
|
143
|
+
"(key TEXT PRIMARY KEY, value TEXT NOT NULL)"
|
|
144
|
+
)
|
|
145
|
+
con.execute(
|
|
146
|
+
"CREATE TABLE IF NOT EXISTS metadata "
|
|
147
|
+
"(key TEXT PRIMARY KEY, value TEXT NOT NULL)"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def compute_invalidated_dependents(
|
|
152
|
+
con: sqlite3.Connection, changed_paths: set[str]
|
|
153
|
+
) -> dict[str, list[str]]:
|
|
154
|
+
"""Given a set of modified/deleted file paths, compute dependent files and symbols that must be invalidated."""
|
|
155
|
+
from codegraph.indexing.models import normalize_module
|
|
156
|
+
|
|
157
|
+
if not changed_paths:
|
|
158
|
+
return {
|
|
159
|
+
"files": [],
|
|
160
|
+
"modules": [],
|
|
161
|
+
"symbols": [],
|
|
162
|
+
"dependent_files": [],
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
changed_modules = {normalize_module(p) for p in changed_paths}
|
|
166
|
+
invalidated_symbols: set[str] = set()
|
|
167
|
+
dependent_files: set[str] = set()
|
|
168
|
+
|
|
169
|
+
for p in changed_paths:
|
|
170
|
+
try:
|
|
171
|
+
rows = con.execute(
|
|
172
|
+
"SELECT canonical_id, qualified_name FROM symbols WHERE path=?", (p,)
|
|
173
|
+
).fetchall()
|
|
174
|
+
for r in rows:
|
|
175
|
+
invalidated_symbols.add(str(r["canonical_id"] or r["qualified_name"]))
|
|
176
|
+
except sqlite3.OperationalError:
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
for mod in changed_modules:
|
|
180
|
+
try:
|
|
181
|
+
imp_rows = con.execute(
|
|
182
|
+
"SELECT DISTINCT source_path FROM imports "
|
|
183
|
+
"WHERE resolved_module=? OR imported_module=? OR module=? OR module LIKE ?",
|
|
184
|
+
(mod, mod, mod, f"%{mod.split('.')[-1]}"),
|
|
185
|
+
).fetchall()
|
|
186
|
+
for r in imp_rows:
|
|
187
|
+
if r["source_path"] not in changed_paths:
|
|
188
|
+
dependent_files.add(str(r["source_path"]))
|
|
189
|
+
except sqlite3.OperationalError:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
"files": sorted(changed_paths),
|
|
194
|
+
"modules": sorted(changed_modules),
|
|
195
|
+
"symbols": sorted(invalidated_symbols),
|
|
196
|
+
"dependent_files": sorted(dependent_files),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def check_freshness(
|
|
201
|
+
repository: Path, con: sqlite3.Connection
|
|
202
|
+
) -> FreshnessReport:
|
|
203
|
+
"""Compare index state against current filesystem state."""
|
|
204
|
+
_ensure_meta_table(con)
|
|
205
|
+
|
|
206
|
+
idx_commit = indexed_commit(con)
|
|
207
|
+
cur_commit = current_commit(repository)
|
|
208
|
+
idx_ts = index_timestamp(con)
|
|
209
|
+
parser_ver = _get_meta(con, "parser_version")
|
|
210
|
+
gen = index_generation(con)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
file_count = con.execute("SELECT count(*) FROM files").fetchone()[0]
|
|
214
|
+
except sqlite3.OperationalError:
|
|
215
|
+
return FreshnessReport(
|
|
216
|
+
status=FreshnessStatus.UNKNOWN,
|
|
217
|
+
indexed_commit=idx_commit,
|
|
218
|
+
current_commit=cur_commit,
|
|
219
|
+
index_timestamp=idx_ts,
|
|
220
|
+
parser_version=parser_ver,
|
|
221
|
+
index_generation=gen,
|
|
222
|
+
detail="No index found. Run `codegraph index` first.",
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if file_count == 0:
|
|
226
|
+
return FreshnessReport(
|
|
227
|
+
status=FreshnessStatus.UNKNOWN,
|
|
228
|
+
indexed_commit=idx_commit,
|
|
229
|
+
current_commit=cur_commit,
|
|
230
|
+
index_timestamp=idx_ts,
|
|
231
|
+
parser_version=parser_ver,
|
|
232
|
+
index_generation=gen,
|
|
233
|
+
detail="Index is empty. Run `codegraph index` first.",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
modified: list[str] = []
|
|
237
|
+
deleted: list[str] = []
|
|
238
|
+
parse_failed: list[str] = []
|
|
239
|
+
|
|
240
|
+
# Fast path 1: Git commit diff (branch switch or commit advance)
|
|
241
|
+
git_diff_handled = False
|
|
242
|
+
if cur_commit and idx_commit and cur_commit != idx_commit:
|
|
243
|
+
diff_out = _run_git(repository, "diff", "--name-status", idx_commit, cur_commit)
|
|
244
|
+
if diff_out is not None:
|
|
245
|
+
git_diff_handled = True
|
|
246
|
+
for line in diff_out.splitlines():
|
|
247
|
+
parts = line.strip().split(maxsplit=1)
|
|
248
|
+
if len(parts) == 2:
|
|
249
|
+
action, fpath = parts[0], parts[1]
|
|
250
|
+
if action.startswith("D"):
|
|
251
|
+
deleted.append(fpath)
|
|
252
|
+
else:
|
|
253
|
+
modified.append(fpath)
|
|
254
|
+
|
|
255
|
+
# Fast path 2: Git status when HEAD commit is unchanged
|
|
256
|
+
if not git_diff_handled and cur_commit and idx_commit and cur_commit == idx_commit:
|
|
257
|
+
status_out = _run_git(repository, "status", "--porcelain", "--untracked-files=no")
|
|
258
|
+
if status_out is not None and not status_out.strip():
|
|
259
|
+
# Working tree has zero tracked changes against HEAD
|
|
260
|
+
git_diff_handled = True
|
|
261
|
+
try:
|
|
262
|
+
pf_rows = con.execute("SELECT path FROM files WHERE status='parse_failed'").fetchall()
|
|
263
|
+
for r in pf_rows:
|
|
264
|
+
parse_failed.append(str(r[0]))
|
|
265
|
+
modified.append(str(r[0]))
|
|
266
|
+
except sqlite3.OperationalError:
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
if not git_diff_handled:
|
|
270
|
+
try:
|
|
271
|
+
rows = con.execute("SELECT path, hash, status, indexed_at FROM files").fetchall()
|
|
272
|
+
except sqlite3.OperationalError:
|
|
273
|
+
try:
|
|
274
|
+
rows = con.execute("SELECT path, hash, status, 0 AS indexed_at FROM files").fetchall()
|
|
275
|
+
except sqlite3.OperationalError:
|
|
276
|
+
rows = con.execute("SELECT path, hash, 'ok' AS status, 0 AS indexed_at FROM files").fetchall()
|
|
277
|
+
|
|
278
|
+
for row in rows:
|
|
279
|
+
rel = str(row["path"])
|
|
280
|
+
if row["status"] == "parse_failed":
|
|
281
|
+
parse_failed.append(rel)
|
|
282
|
+
if rel not in modified:
|
|
283
|
+
modified.append(rel)
|
|
284
|
+
abs_path = repository / rel
|
|
285
|
+
if not abs_path.exists():
|
|
286
|
+
deleted.append(rel)
|
|
287
|
+
else:
|
|
288
|
+
try:
|
|
289
|
+
st = abs_path.stat()
|
|
290
|
+
# Mtime check: skip reading & hashing files strictly older than indexing time
|
|
291
|
+
idx_at = int(row["indexed_at"] or 0)
|
|
292
|
+
if idx_at > 0 and int(st.st_mtime) < idx_at and row["status"] != "parse_failed":
|
|
293
|
+
continue
|
|
294
|
+
digest = hashlib.sha256(
|
|
295
|
+
abs_path.read_text(encoding="utf-8", errors="replace").encode()
|
|
296
|
+
).hexdigest()
|
|
297
|
+
if digest != row["hash"] and rel not in modified:
|
|
298
|
+
modified.append(rel)
|
|
299
|
+
except OSError:
|
|
300
|
+
if rel not in modified:
|
|
301
|
+
modified.append(rel)
|
|
302
|
+
|
|
303
|
+
changed = len(modified) + len(deleted)
|
|
304
|
+
if changed == 0 and not parse_failed and (idx_commit == cur_commit or cur_commit is None):
|
|
305
|
+
status = FreshnessStatus.FRESH
|
|
306
|
+
detail = "Index matches current repository state."
|
|
307
|
+
elif parse_failed and changed == len(parse_failed):
|
|
308
|
+
status = FreshnessStatus.PARTIALLY_STALE
|
|
309
|
+
detail = f"{len(parse_failed)} file(s) had syntax errors; last-known-good index retained (stale)."
|
|
310
|
+
elif changed == 0:
|
|
311
|
+
status = FreshnessStatus.PARTIALLY_STALE
|
|
312
|
+
detail = f"Commit changed ({_short(idx_commit)} → {_short(cur_commit)}) but no file hashes differ."
|
|
313
|
+
elif changed < file_count:
|
|
314
|
+
status = FreshnessStatus.PARTIALLY_STALE
|
|
315
|
+
detail = f"{changed} file(s) changed since indexing."
|
|
316
|
+
else:
|
|
317
|
+
status = FreshnessStatus.STALE
|
|
318
|
+
detail = f"All {file_count} indexed files may have changed. Re-index recommended."
|
|
319
|
+
|
|
320
|
+
return FreshnessReport(
|
|
321
|
+
status=status,
|
|
322
|
+
indexed_commit=idx_commit,
|
|
323
|
+
current_commit=cur_commit,
|
|
324
|
+
index_timestamp=idx_ts,
|
|
325
|
+
modified_files=modified,
|
|
326
|
+
deleted_files=deleted,
|
|
327
|
+
parse_failed_files=parse_failed,
|
|
328
|
+
parser_version=parser_ver,
|
|
329
|
+
index_generation=gen,
|
|
330
|
+
detail=detail,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _short(sha: str | None) -> str:
|
|
335
|
+
return sha[:8] if sha else "unknown"
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""HTML Template and Web Asset Linkage Analyzer (Pillar 4).
|
|
2
|
+
|
|
3
|
+
Extracts deterministic facts from HTML documents:
|
|
4
|
+
- Script bundle inclusions (<script src="..."> -> LOADS_SCRIPT)
|
|
5
|
+
- Stylesheet links (<link rel="stylesheet" href="..."> -> LOADS_STYLESHEET)
|
|
6
|
+
- DOM mount containers (<div id="root">, <div id="app">)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from codegraph.indexing.models import BindingRef, Symbol
|
|
14
|
+
|
|
15
|
+
_SCRIPT_SRC_RE = re.compile(
|
|
16
|
+
r"""<script\b[^>]*\bsrc\s*=\s*['"]([^'"]+)['"]""",
|
|
17
|
+
re.IGNORECASE,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_LINK_STYLESHEET_RE = re.compile(
|
|
21
|
+
r"""<link\b[^>]*\bhref\s*=\s*['"]([^'"]+)['"][^>]*\brel\s*=\s*['"]stylesheet['"]|<link\b[^>]*\brel\s*=\s*['"]stylesheet['"][^>]*\bhref\s*=\s*['"]([^'"]+)['"]""",
|
|
22
|
+
re.IGNORECASE,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_ELEMENT_ID_RE = re.compile(
|
|
26
|
+
r"""<([A-Za-z0-9_-]+)\b[^>]*\bid\s*=\s*['"]([^'"]+)['"]""",
|
|
27
|
+
re.IGNORECASE,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class HtmlParseResult:
|
|
33
|
+
symbols: tuple[Symbol, ...] = ()
|
|
34
|
+
bindings: tuple[BindingRef, ...] = ()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_html_file(file_path: str) -> bool:
|
|
38
|
+
"""Check if file is an HTML document or template."""
|
|
39
|
+
lower = file_path.lower()
|
|
40
|
+
return lower.endswith((".html", ".htm"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_html_file(content: str, file_path: str) -> HtmlParseResult:
|
|
44
|
+
"""Extract script, stylesheet, and DOM mount container references from HTML."""
|
|
45
|
+
if not is_html_file(file_path):
|
|
46
|
+
return HtmlParseResult()
|
|
47
|
+
|
|
48
|
+
clean_file = file_path.replace("\\", "/").lstrip("./")
|
|
49
|
+
symbols: list[Symbol] = []
|
|
50
|
+
bindings: list[BindingRef] = []
|
|
51
|
+
|
|
52
|
+
for idx, line in enumerate(content.splitlines()):
|
|
53
|
+
lineno = idx + 1
|
|
54
|
+
|
|
55
|
+
# <script src="...">
|
|
56
|
+
for m_script in _SCRIPT_SRC_RE.finditer(line):
|
|
57
|
+
src_val = m_script.group(1).lstrip("/")
|
|
58
|
+
bindings.append(
|
|
59
|
+
BindingRef(
|
|
60
|
+
target_name=src_val,
|
|
61
|
+
file_path=clean_file,
|
|
62
|
+
line=lineno,
|
|
63
|
+
scope="",
|
|
64
|
+
expr_kind="HTML_LOADS_SCRIPT",
|
|
65
|
+
source_expr=clean_file,
|
|
66
|
+
base_expr="LOADS_SCRIPT",
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# <link rel="stylesheet" href="...">
|
|
71
|
+
for m_link in _LINK_STYLESHEET_RE.finditer(line):
|
|
72
|
+
href_val = (m_link.group(1) or m_link.group(2)).lstrip("/")
|
|
73
|
+
bindings.append(
|
|
74
|
+
BindingRef(
|
|
75
|
+
target_name=href_val,
|
|
76
|
+
file_path=clean_file,
|
|
77
|
+
line=lineno,
|
|
78
|
+
scope="",
|
|
79
|
+
expr_kind="HTML_LOADS_STYLESHEET",
|
|
80
|
+
source_expr=clean_file,
|
|
81
|
+
base_expr="LOADS_STYLESHEET",
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# <div id="...">
|
|
86
|
+
for m_elem in _ELEMENT_ID_RE.finditer(line):
|
|
87
|
+
elem_id = m_elem.group(2)
|
|
88
|
+
canon_id = f"{clean_file}#{elem_id}"
|
|
89
|
+
symbols.append(
|
|
90
|
+
Symbol(
|
|
91
|
+
id=canon_id,
|
|
92
|
+
canonical_id=canon_id,
|
|
93
|
+
name=f"#{elem_id}",
|
|
94
|
+
qualified_name=f"#{elem_id}",
|
|
95
|
+
kind="dom_element",
|
|
96
|
+
language="html",
|
|
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
|
+
return HtmlParseResult(
|
|
107
|
+
symbols=tuple(symbols),
|
|
108
|
+
bindings=tuple(bindings),
|
|
109
|
+
)
|