@guanlin4924/devflow-pi 0.1.77 → 0.1.79
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.
- package/package.json +9 -2
- package/plugins/devflow-intel/extensions/devflow-intel.ts +12 -1
- package/plugins/devflow-intel/skills/code-index-query/SKILL.md +8 -0
- package/plugins/devflow-intel/skills/workflow-code-index/SKILL.md +5 -0
- package/plugins/devflow-intel/skills/workflow-code-index/references/adapter-contract.md +8 -0
- package/plugins/devflow-intel/skills/workflow-code-index/references/data-model.md +1 -1
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/capabilities.py +26 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/generic_text.py +3 -1
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/helpers/tree_sitter_wasm_helper.mjs +50 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/parser-capabilities.json +491 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/registry.py +20 -31
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/tree_sitter_wasm.py +253 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +173 -32
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/semantic.py +93 -12
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/writers/sqlite_writer.py +26 -13
- package/src/constants.ts +1 -1
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Optional Tree-sitter WASM adapter.
|
|
2
|
+
|
|
3
|
+
The indexer remains usable without a grammar bundle. In that case this
|
|
4
|
+
adapter deliberately returns the configured fallback and a diagnostic; it
|
|
5
|
+
never labels lexical facts as Tree-sitter facts. A grammar is supplied by the
|
|
6
|
+
runtime (or DEVFLOW_TREE_SITTER_WASM_GRAMMAR_<LANG>) so the npm package does
|
|
7
|
+
not silently depend on a machine-specific native installation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, Tuple
|
|
17
|
+
|
|
18
|
+
from adapters.base import Adapter, ParseResult
|
|
19
|
+
from adapters.capabilities import load_capability_manifest
|
|
20
|
+
from adapters.generic_text import _relation, _symbol
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
HELPER = Path(__file__).resolve().parent / "helpers" / "tree_sitter_wasm_helper.mjs"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TreeSitterWasmAdapter(Adapter):
|
|
27
|
+
name = "tree-sitter-wasm"
|
|
28
|
+
|
|
29
|
+
def __init__(self, language: str, fallback: Adapter):
|
|
30
|
+
self.language = language
|
|
31
|
+
self.fallback = fallback
|
|
32
|
+
self.declaration = load_capability_manifest()["languages"].get(language, {})
|
|
33
|
+
self._availability: Tuple[bool, str] | None = None
|
|
34
|
+
|
|
35
|
+
def version(self) -> str:
|
|
36
|
+
return str(self.declaration.get("adapter_version", 1))
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def parser_id(self) -> str:
|
|
40
|
+
grammar = self.declaration.get("grammar", {}).get("id", self.language)
|
|
41
|
+
return f"tree-sitter-wasm:{grammar}@{self.version()}"
|
|
42
|
+
|
|
43
|
+
def parser_for_file(self, codebase, file_record) -> str:
|
|
44
|
+
available, _ = self._availability_for(codebase)
|
|
45
|
+
return self.parser_id if available else self.fallback.parser_for_file(codebase, file_record)
|
|
46
|
+
|
|
47
|
+
def check_available(self, codebase=None) -> Tuple[bool, str]:
|
|
48
|
+
grammar = self._grammar_path(codebase)
|
|
49
|
+
if not grammar:
|
|
50
|
+
return False, "Tree-sitter WASM grammar is not configured"
|
|
51
|
+
pin = self._grammar_pin()
|
|
52
|
+
if not pin["version"] or not pin["sha256"]:
|
|
53
|
+
return False, "Tree-sitter WASM grammar is unpinned; configure version and sha256"
|
|
54
|
+
actual_hash = self._sha256(grammar)
|
|
55
|
+
expected_hash = pin["sha256"].removeprefix("sha256:").lower()
|
|
56
|
+
if actual_hash != expected_hash:
|
|
57
|
+
return False, f"Tree-sitter WASM grammar hash mismatch: expected {expected_hash}, got {actual_hash}"
|
|
58
|
+
runtime = self._runtime()
|
|
59
|
+
if runtime is None:
|
|
60
|
+
return False, "Node/Bun runtime is not available"
|
|
61
|
+
if not self._module_available(runtime):
|
|
62
|
+
return False, "web-tree-sitter module is not importable"
|
|
63
|
+
if not HELPER.is_file():
|
|
64
|
+
return False, "Tree-sitter WASM helper is missing"
|
|
65
|
+
return True, f"Tree-sitter WASM grammar available: {grammar} ({pin['version']}, sha256:{actual_hash})"
|
|
66
|
+
|
|
67
|
+
def capability(self) -> Dict:
|
|
68
|
+
available, reason = self._availability_for(None)
|
|
69
|
+
grammar = dict(self.declaration.get("grammar", {}))
|
|
70
|
+
return {
|
|
71
|
+
"parser": self.parser_id,
|
|
72
|
+
"strategy": "tree-sitter-wasm",
|
|
73
|
+
"native": False,
|
|
74
|
+
"available": available,
|
|
75
|
+
"status": "implemented" if available else "parser_unavailable",
|
|
76
|
+
"support_level": "supported" if available else ("unpinned" if self._grammar_path(None) else "parser_unavailable"),
|
|
77
|
+
"pin": self._grammar_pin(),
|
|
78
|
+
"reason": reason,
|
|
79
|
+
"grammar": grammar,
|
|
80
|
+
"fallback": self.fallback.parser_id,
|
|
81
|
+
"scope": "Tree-sitter WASM syntax tree; framework semantics remain a separate linker stage",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
def parse_file(self, codebase, file_record, text) -> ParseResult:
|
|
85
|
+
if file_record.get("language") != self.language:
|
|
86
|
+
return self.fallback.parse_file(codebase, file_record, text)
|
|
87
|
+
available, reason = self._availability_for(codebase)
|
|
88
|
+
if not available:
|
|
89
|
+
result = self.fallback.parse_file(codebase, file_record, text)
|
|
90
|
+
result.diagnostics.append({
|
|
91
|
+
"codebase": codebase.name,
|
|
92
|
+
"path": file_record["path"],
|
|
93
|
+
"line": 1,
|
|
94
|
+
"severity": "warning",
|
|
95
|
+
"kind": "parser_unavailable",
|
|
96
|
+
"message": f"Tree-sitter WASM unavailable for {self.language}; fallback used: {reason}",
|
|
97
|
+
"parser": self.parser_id,
|
|
98
|
+
"metadata": {"support_level": "parser_unavailable", "fallback_parser": self.fallback.parser_id},
|
|
99
|
+
})
|
|
100
|
+
return result
|
|
101
|
+
try:
|
|
102
|
+
payload = self._parse(codebase, file_record, text)
|
|
103
|
+
except Exception as error:
|
|
104
|
+
result = self.fallback.parse_file(codebase, file_record, text)
|
|
105
|
+
result.diagnostics.append({
|
|
106
|
+
"codebase": codebase.name, "path": file_record["path"], "line": 1,
|
|
107
|
+
"severity": "error", "kind": "parse_failed",
|
|
108
|
+
"message": f"Tree-sitter WASM parse failed; fallback used: {error}",
|
|
109
|
+
"parser": self.parser_id,
|
|
110
|
+
})
|
|
111
|
+
return result
|
|
112
|
+
return self._convert(codebase, file_record, text, payload)
|
|
113
|
+
|
|
114
|
+
def _availability_for(self, codebase=None):
|
|
115
|
+
# A grammar/runtime is global for a scan. Codebase-specific grammar
|
|
116
|
+
# overrides are intentionally checked only when parsing that codebase.
|
|
117
|
+
if codebase is not None:
|
|
118
|
+
return self.check_available(codebase)
|
|
119
|
+
if self._availability is None:
|
|
120
|
+
self._availability = self.check_available(None)
|
|
121
|
+
return self._availability
|
|
122
|
+
|
|
123
|
+
def _grammar_path(self, codebase=None):
|
|
124
|
+
key = f"DEVFLOW_TREE_SITTER_WASM_GRAMMAR_{self.language.upper().replace('-', '_')}"
|
|
125
|
+
configured = os.environ.get(key) or self.declaration.get("grammar", {}).get("path")
|
|
126
|
+
if configured:
|
|
127
|
+
path = Path(configured)
|
|
128
|
+
if not path.is_absolute() and codebase is not None:
|
|
129
|
+
path = Path(codebase.root) / path
|
|
130
|
+
return str(path.resolve()) if path.is_file() else None
|
|
131
|
+
package = self.declaration.get("grammar", {}).get("package")
|
|
132
|
+
package_path = self._resolve_package_grammar(package) if package else None
|
|
133
|
+
return package_path
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _resolve_package_grammar(package):
|
|
137
|
+
"""Resolve a declared npm grammar without downloading at runtime.
|
|
138
|
+
|
|
139
|
+
The package is an installation-time dependency. Searching the
|
|
140
|
+
workspace and adapter ancestors keeps this working from both a
|
|
141
|
+
checkout and an installed plugin while ensuring no network fallback
|
|
142
|
+
can occur during indexing.
|
|
143
|
+
"""
|
|
144
|
+
if not package:
|
|
145
|
+
return None
|
|
146
|
+
roots = [Path.cwd(), Path(__file__).resolve()]
|
|
147
|
+
seen = set()
|
|
148
|
+
for root in roots:
|
|
149
|
+
for candidate in [root, *root.parents]:
|
|
150
|
+
package_root = candidate / "node_modules" / package
|
|
151
|
+
candidates = [package_root / "grammar.wasm"]
|
|
152
|
+
if package_root.is_dir():
|
|
153
|
+
candidates.extend(sorted(package_root.glob("*.wasm")))
|
|
154
|
+
for node_modules in candidates:
|
|
155
|
+
key = str(node_modules)
|
|
156
|
+
if key in seen:
|
|
157
|
+
continue
|
|
158
|
+
seen.add(key)
|
|
159
|
+
if node_modules.is_file():
|
|
160
|
+
return str(node_modules.resolve())
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
def _grammar_pin(self):
|
|
164
|
+
grammar = self.declaration.get("grammar", {})
|
|
165
|
+
prefix = f"DEVFLOW_TREE_SITTER_GRAMMAR_{self.language.upper().replace('-', '_')}"
|
|
166
|
+
return {
|
|
167
|
+
"version": os.environ.get(f"{prefix}_VERSION") or grammar.get("version"),
|
|
168
|
+
"sha256": os.environ.get(f"{prefix}_SHA256") or grammar.get("sha256"),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _sha256(path):
|
|
173
|
+
digest = hashlib.sha256()
|
|
174
|
+
with Path(path).open("rb") as handle:
|
|
175
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
176
|
+
digest.update(chunk)
|
|
177
|
+
return digest.hexdigest()
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _module_available(runtime):
|
|
181
|
+
# Probe the module before claiming availability. The helper import is
|
|
182
|
+
# deliberately not the first place this is discovered: otherwise the
|
|
183
|
+
# snapshot would advertise a parser which only fails per file.
|
|
184
|
+
try:
|
|
185
|
+
probe = subprocess.run(
|
|
186
|
+
[runtime, "--input-type=module", "-e",
|
|
187
|
+
"import('web-tree-sitter').then(()=>process.exit(0)).catch(()=>process.exit(1))"],
|
|
188
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
189
|
+
timeout=5, check=False,
|
|
190
|
+
)
|
|
191
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
192
|
+
return False
|
|
193
|
+
return probe.returncode == 0
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _runtime():
|
|
197
|
+
configured = os.environ.get("DEVFLOW_TREE_SITTER_WASM_RUNTIME")
|
|
198
|
+
if configured:
|
|
199
|
+
return configured if shutil.which(configured) else None
|
|
200
|
+
return shutil.which("bun") or shutil.which("node")
|
|
201
|
+
|
|
202
|
+
def _parse(self, codebase, record, text):
|
|
203
|
+
runtime = self._runtime()
|
|
204
|
+
grammar = self._grammar_path(codebase)
|
|
205
|
+
max_bytes = int(os.environ.get("DEVFLOW_TREE_SITTER_WASM_MAX_BYTES", str(2 * 1024 * 1024)))
|
|
206
|
+
source_bytes = len(text.encode("utf-8"))
|
|
207
|
+
if source_bytes > max_bytes:
|
|
208
|
+
raise RuntimeError(f"source exceeds Tree-sitter WASM limit ({source_bytes} > {max_bytes} bytes)")
|
|
209
|
+
timeout = float(os.environ.get("DEVFLOW_TREE_SITTER_WASM_TIMEOUT", "10"))
|
|
210
|
+
proc = subprocess.run(
|
|
211
|
+
[runtime, str(HELPER), grammar],
|
|
212
|
+
input=json.dumps({"path": record["path"], "source": text}, ensure_ascii=False) + "\n",
|
|
213
|
+
text=True, encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
214
|
+
timeout=timeout, check=False,
|
|
215
|
+
)
|
|
216
|
+
if proc.returncode != 0:
|
|
217
|
+
raise RuntimeError(proc.stderr.strip()[:500] or f"helper exited {proc.returncode}")
|
|
218
|
+
payload = json.loads(proc.stdout.splitlines()[-1])
|
|
219
|
+
if not payload.get("ok"):
|
|
220
|
+
raise RuntimeError(payload.get("error") or "Tree-sitter helper returned no result")
|
|
221
|
+
return payload
|
|
222
|
+
|
|
223
|
+
def _convert(self, codebase, record, text, payload):
|
|
224
|
+
path, content_hash = record["path"], record["content_hash"]
|
|
225
|
+
file_symbol = _symbol(codebase.name, record["language"], "file", Path(path).name, path, 1,
|
|
226
|
+
max(1, len(text.splitlines())), content_hash, qualified_name=path,
|
|
227
|
+
parser=self.parser_id)
|
|
228
|
+
symbols = [file_symbol]
|
|
229
|
+
by_key = {"": file_symbol}
|
|
230
|
+
for item in payload.get("declarations", []):
|
|
231
|
+
parent = by_key.get(str(item.get("parent") or ""), file_symbol)
|
|
232
|
+
symbol = _symbol(codebase.name, record["language"], item.get("kind", "symbol"),
|
|
233
|
+
item.get("name", "anonymous"), path, max(1, int(item.get("line_start", 1))),
|
|
234
|
+
max(1, int(item.get("line_end", item.get("line_start", 1)))), content_hash,
|
|
235
|
+
qualified_name=item.get("qualified_name") or f"{parent['qualified_name']}::{item.get('name', 'anonymous')}",
|
|
236
|
+
parser=self.parser_id)
|
|
237
|
+
symbol["container_id"] = parent["id"]
|
|
238
|
+
symbol["metadata"].update({"parse_status": payload.get("parse_status", "complete"), "grammar": self.declaration.get("grammar", {})})
|
|
239
|
+
symbols.append(symbol)
|
|
240
|
+
by_key[str(item.get("key"))] = symbol
|
|
241
|
+
relations = []
|
|
242
|
+
for symbol in symbols[1:]:
|
|
243
|
+
relations.append(_relation(symbol["container_id"], "contains", path, symbol["line_start"], self.parser_id, "exact", to_id=symbol["id"]))
|
|
244
|
+
for item in payload.get("calls", []):
|
|
245
|
+
owner = by_key.get(str(item.get("owner") or ""), file_symbol)
|
|
246
|
+
relations.append(_relation(owner["id"], "calls", path, max(1, int(item.get("line", 1))), self.parser_id,
|
|
247
|
+
"heuristic", to_external=f"call:{item.get('target', 'unknown')}"))
|
|
248
|
+
diagnostics = list(payload.get("diagnostics", []))
|
|
249
|
+
if payload.get("parse_status") != "complete":
|
|
250
|
+
diagnostics.append({"codebase": codebase.name, "path": path, "line": 1, "severity": "warning",
|
|
251
|
+
"kind": "parse_partial", "message": "Tree-sitter grammar produced a partial tree",
|
|
252
|
+
"parser": self.parser_id, "metadata": payload.get("metadata", {})})
|
|
253
|
+
return ParseResult(symbols=symbols, relations=relations, diagnostics=diagnostics, actual_parser=self.parser_id)
|
|
@@ -22,6 +22,7 @@ if str(SCRIPT_DIR) not in sys.path:
|
|
|
22
22
|
sys.path.insert(0, str(SCRIPT_DIR))
|
|
23
23
|
|
|
24
24
|
from adapters.generic_text import language_for_path
|
|
25
|
+
from adapters.capabilities import confidence_for_parser
|
|
25
26
|
from adapters.registry import AdapterRegistry
|
|
26
27
|
from semantic import SEMANTIC_EXTRACTOR_VERSION, build_semantic_facts
|
|
27
28
|
from core.discovery import discover_codebases
|
|
@@ -64,7 +65,17 @@ def _publish_snapshot(staging: Path, output: Path, counts: Dict[str, int], works
|
|
|
64
65
|
generation = generation or str(uuid.uuid4())
|
|
65
66
|
immutable_relative = f"snapshots/{generation}"
|
|
66
67
|
snapshot = {
|
|
68
|
+
# Keep the public snapshot schema stable while exposing the richer
|
|
69
|
+
# SQLite schema separately. Older consumers only know schemaVersion=1.
|
|
67
70
|
"schemaVersion": 1,
|
|
71
|
+
"indexSchemaVersion": 3,
|
|
72
|
+
"capabilities": {
|
|
73
|
+
"structural": True,
|
|
74
|
+
"semantic": True,
|
|
75
|
+
"fts": True,
|
|
76
|
+
"relations": True,
|
|
77
|
+
"provenance": True,
|
|
78
|
+
},
|
|
68
79
|
"generation": generation,
|
|
69
80
|
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
|
70
81
|
"counts": counts,
|
|
@@ -188,11 +199,13 @@ def main(argv=None) -> int:
|
|
|
188
199
|
|
|
189
200
|
validate = sub.add_parser("validate", help="Validate JSONL schema.")
|
|
190
201
|
validate.add_argument("--index", default=".planning/code-index", help="Index directory.")
|
|
202
|
+
validate.add_argument("--snapshot", help="Immutable generation id.")
|
|
191
203
|
|
|
192
204
|
audit = sub.add_parser("audit", help="Audit stale files and relation integrity.")
|
|
193
205
|
audit.add_argument("--root", default=".", help="Project root.")
|
|
194
206
|
audit.add_argument("--index", default=".planning/code-index", help="Index directory.")
|
|
195
207
|
audit.add_argument("--skill-root", default=".skills", help="Optional skill root for project-binding checks.")
|
|
208
|
+
audit.add_argument("--snapshot", help="Immutable generation id.")
|
|
196
209
|
|
|
197
210
|
query = sub.add_parser("query", help="Search symbols using SQLite FTS or LIKE fallback.")
|
|
198
211
|
query.add_argument("--index", default=".planning/code-index", help="Index directory.")
|
|
@@ -286,15 +299,27 @@ def command_scan(args) -> int:
|
|
|
286
299
|
"parser": "pending",
|
|
287
300
|
}
|
|
288
301
|
adapter = registry.adapter_for_file(codebase, file_record)
|
|
289
|
-
|
|
302
|
+
parser_for_file = getattr(adapter, "parser_for_file", None)
|
|
303
|
+
file_record["parser"] = parser_for_file(codebase, file_record) if parser_for_file else adapter.parser_id
|
|
290
304
|
result = adapter.parse_file(codebase, file_record, text)
|
|
291
305
|
file_record["parser"] = result.actual_parser or file_record["parser"]
|
|
292
306
|
files.append(file_record)
|
|
293
307
|
symbols.extend(result.symbols)
|
|
294
308
|
relations.extend(result.relations)
|
|
295
309
|
diagnostics.extend(result.diagnostics)
|
|
296
|
-
|
|
297
|
-
|
|
310
|
+
for entity in getattr(result, "entities", []) or []:
|
|
311
|
+
entity = dict(entity)
|
|
312
|
+
entity.setdefault("extractor", file_record["parser"])
|
|
313
|
+
entity.setdefault("confidence", confidence_for_parser(file_record["parser"], "symbol"))
|
|
314
|
+
entity.setdefault("metadata", {})
|
|
315
|
+
adapter_entities.append(entity)
|
|
316
|
+
for flow in getattr(result, "flows", []) or []:
|
|
317
|
+
flow = dict(flow)
|
|
318
|
+
flow.setdefault("extractor", file_record["parser"])
|
|
319
|
+
flow.setdefault("confidence", "heuristic")
|
|
320
|
+
flow.setdefault("evidence_level", "syntax")
|
|
321
|
+
flow.setdefault("metadata", {})
|
|
322
|
+
adapter_flows.append(flow)
|
|
298
323
|
parsed += 1
|
|
299
324
|
if parsed == 1 or parsed % 250 == 0 or parsed == len(all_files):
|
|
300
325
|
progress.emit("parse", f"parsed {parsed}/{len(all_files)} files in {codebase.name}", codebase=codebase.name, parsed=parsed, total=len(all_files))
|
|
@@ -316,12 +341,25 @@ def command_scan(args) -> int:
|
|
|
316
341
|
staging = Path(tempfile.mkdtemp(prefix=f".{output.name}.build-", dir=str(output.parent)))
|
|
317
342
|
try:
|
|
318
343
|
generation = str(uuid.uuid4())
|
|
319
|
-
|
|
344
|
+
registry_capabilities = getattr(registry, "capabilities", None)
|
|
345
|
+
workspace_state = _workspace_state(
|
|
346
|
+
root, files, changed, discovered_paths,
|
|
347
|
+
registry_capabilities() if registry_capabilities else {},
|
|
348
|
+
)
|
|
320
349
|
workspace_state["generatedAt"] = datetime.now(timezone.utc).isoformat()
|
|
350
|
+
workspace_state["scanScope"] = {
|
|
351
|
+
"exclude": sorted(set(args.exclude)),
|
|
352
|
+
"codebases": [
|
|
353
|
+
{"name": item.name, "path": str(item.root)} for item in codebases
|
|
354
|
+
],
|
|
355
|
+
}
|
|
321
356
|
workspace_state["semanticExtractor"] = SEMANTIC_EXTRACTOR_VERSION
|
|
322
357
|
workspace_state["extractorSetHash"] = _hash_lines([*workspace_state.get("extractors", []), SEMANTIC_EXTRACTOR_VERSION])
|
|
323
358
|
progress.emit("semantic", "building semantic facts")
|
|
324
|
-
|
|
359
|
+
semantic_stats = {}
|
|
360
|
+
semantic_entities, semantic_flows, feature_bindings = build_semantic_facts(root, files, symbols, relations, stats=semantic_stats)
|
|
361
|
+
workspace_state["semanticScan"] = semantic_stats
|
|
362
|
+
progress.emit("semantic", "semantic facts completed", **semantic_stats)
|
|
325
363
|
semantic_entities.extend(adapter_entities)
|
|
326
364
|
semantic_flows.extend(adapter_flows)
|
|
327
365
|
write_jsonl(staging / INDEX_FILES["files"], files)
|
|
@@ -369,19 +407,27 @@ def command_scan(args) -> int:
|
|
|
369
407
|
|
|
370
408
|
def command_validate(args) -> int:
|
|
371
409
|
output = Path(args.index).resolve()
|
|
372
|
-
selected = _selected_generation(output, None)
|
|
410
|
+
selected = _selected_generation(output, getattr(args, "snapshot", None))
|
|
373
411
|
errors = []
|
|
374
412
|
try:
|
|
375
413
|
snapshot = _load_snapshot(selected)
|
|
414
|
+
errors.extend(_validate_snapshot_bundle(selected, snapshot))
|
|
376
415
|
files, symbols, relations, diagnostics = _load_tuple(selected)
|
|
377
416
|
errors.extend(validate_index(files, symbols, relations, diagnostics))
|
|
378
417
|
conn = sqlite3.connect(str(selected / "index.sqlite"))
|
|
379
418
|
try:
|
|
380
419
|
snapshot_id = snapshot.get("generation")
|
|
381
|
-
|
|
382
|
-
if
|
|
383
|
-
|
|
384
|
-
|
|
420
|
+
tables = _table_names(conn)
|
|
421
|
+
if "analysis_snapshots" in tables:
|
|
422
|
+
row = conn.execute("SELECT snapshot_id FROM analysis_snapshots WHERE snapshot_id=?", (snapshot_id,)).fetchone()
|
|
423
|
+
if not row:
|
|
424
|
+
errors.append(f"snapshot metadata missing generation {snapshot_id}")
|
|
425
|
+
semantic_tables = {"semantic_flows", "entity_versions", "feature_bindings", "coverage_metrics"}
|
|
426
|
+
if not semantic_tables.issubset(tables):
|
|
427
|
+
# Structural-only historical snapshots remain valid.
|
|
428
|
+
snapshot.setdefault("capabilities", {})["semantic"] = False
|
|
429
|
+
else:
|
|
430
|
+
dangling = conn.execute("""
|
|
385
431
|
SELECT COUNT(*) FROM semantic_flows f
|
|
386
432
|
WHERE f.snapshot_id=? AND (
|
|
387
433
|
(NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=f.snapshot_id AND e.logical_id=f.source_id)
|
|
@@ -390,18 +436,18 @@ def command_validate(args) -> int:
|
|
|
390
436
|
(NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=f.snapshot_id AND e.logical_id=f.target_id)
|
|
391
437
|
AND NOT EXISTS (SELECT 1 FROM symbols s WHERE s.id=f.target_id))
|
|
392
438
|
)
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
439
|
+
""", (snapshot_id,)).fetchone()[0]
|
|
440
|
+
if dangling:
|
|
441
|
+
errors.append(f"semantic flows have {dangling} dangling source or target nodes")
|
|
442
|
+
binding_dangling = conn.execute("""
|
|
397
443
|
SELECT COUNT(*) FROM feature_bindings b
|
|
398
444
|
WHERE b.snapshot_id=? AND (
|
|
399
445
|
NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=b.snapshot_id AND e.logical_id=b.feature_id)
|
|
400
446
|
OR NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=b.snapshot_id AND e.logical_id=b.entity_id)
|
|
401
447
|
)
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
448
|
+
""", (snapshot_id,)).fetchone()[0]
|
|
449
|
+
if binding_dangling:
|
|
450
|
+
errors.append(f"feature bindings have {binding_dangling} dangling feature or entity nodes")
|
|
405
451
|
finally:
|
|
406
452
|
conn.close()
|
|
407
453
|
except (OSError, sqlite3.Error, ValueError) as exc:
|
|
@@ -418,6 +464,11 @@ def command_query(args) -> int:
|
|
|
418
464
|
print(f"Missing index: {index}", file=sys.stderr)
|
|
419
465
|
return 1
|
|
420
466
|
snapshot = _load_snapshot(selected)
|
|
467
|
+
bundle_errors = _validate_snapshot_bundle(selected, snapshot)
|
|
468
|
+
if bundle_errors:
|
|
469
|
+
print(bundle_errors[0], file=sys.stderr)
|
|
470
|
+
return 1
|
|
471
|
+
limit = _bounded_limit(getattr(args, "limit", 20), 1000)
|
|
421
472
|
files = read_jsonl(selected / INDEX_FILES["files"])
|
|
422
473
|
if not getattr(args, "snapshot", None) and not _query_is_fresh(output.parent.parent, snapshot, files):
|
|
423
474
|
print("Stale code index: refresh required before query.", file=sys.stderr)
|
|
@@ -425,30 +476,38 @@ def command_query(args) -> int:
|
|
|
425
476
|
conn = sqlite3.connect(str(index))
|
|
426
477
|
conn.row_factory = sqlite3.Row
|
|
427
478
|
try:
|
|
479
|
+
symbol_columns = {row[1] for row in conn.execute("PRAGMA table_info(symbols)")}
|
|
480
|
+
provenance = ", parser, content_hash, metadata_json" if {"parser", "content_hash", "metadata_json"}.issubset(symbol_columns) else ", NULL AS parser, NULL AS content_hash, NULL AS metadata_json"
|
|
428
481
|
try:
|
|
429
482
|
rows = conn.execute(
|
|
430
|
-
"""
|
|
431
|
-
SELECT id, codebase, kind, name, qualified_name, path, line_start, line_end
|
|
483
|
+
f"""
|
|
484
|
+
SELECT id, codebase, kind, name, qualified_name, path, line_start, line_end,
|
|
485
|
+
{provenance[2:]}
|
|
432
486
|
FROM symbols_fts
|
|
433
487
|
JOIN symbols USING(id)
|
|
434
488
|
WHERE symbols_fts MATCH ?
|
|
435
489
|
LIMIT ?
|
|
436
490
|
""",
|
|
437
|
-
(args.term,
|
|
491
|
+
(args.term, limit),
|
|
438
492
|
).fetchall()
|
|
439
493
|
except sqlite3.OperationalError:
|
|
440
494
|
like = f"%{args.term}%"
|
|
441
495
|
rows = conn.execute(
|
|
442
|
-
"""
|
|
443
|
-
SELECT id, codebase, kind, name, qualified_name, path, line_start, line_end
|
|
496
|
+
f"""
|
|
497
|
+
SELECT id, codebase, kind, name, qualified_name, path, line_start, line_end,
|
|
498
|
+
{provenance[2:]}
|
|
444
499
|
FROM symbols
|
|
445
500
|
WHERE name LIKE ? OR qualified_name LIKE ? OR path LIKE ?
|
|
446
501
|
LIMIT ?
|
|
447
502
|
""",
|
|
448
|
-
(like, like, like,
|
|
503
|
+
(like, like, like, limit),
|
|
449
504
|
).fetchall()
|
|
450
505
|
for row in rows:
|
|
451
|
-
|
|
506
|
+
item = dict(row)
|
|
507
|
+
item["snapshot"] = snapshot.get("generation")
|
|
508
|
+
item["parser"] = item.get("parser") or "unknown"
|
|
509
|
+
item["confidence"] = item.get("confidence") or confidence_for_parser(item["parser"], "symbol")
|
|
510
|
+
print(json.dumps(item, ensure_ascii=False, sort_keys=True))
|
|
452
511
|
finally:
|
|
453
512
|
conn.close()
|
|
454
513
|
return 0
|
|
@@ -456,14 +515,22 @@ def command_query(args) -> int:
|
|
|
456
515
|
|
|
457
516
|
def command_relations(args) -> int:
|
|
458
517
|
output = Path(args.index).resolve()
|
|
459
|
-
depth = max(0, min(args.depth, 20)); limit =
|
|
518
|
+
depth = max(0, min(args.depth, 20)); limit = _bounded_limit(args.limit, 1000)
|
|
460
519
|
with _index_lock(output, exclusive=False):
|
|
461
520
|
selected = _selected_generation(output, getattr(args, "snapshot", None))
|
|
462
521
|
snapshot = _load_snapshot(selected); files = read_jsonl(selected / INDEX_FILES["files"])
|
|
522
|
+
bundle_errors = _validate_snapshot_bundle(selected, snapshot)
|
|
523
|
+
if bundle_errors:
|
|
524
|
+
print(bundle_errors[0], file=sys.stderr); return 1
|
|
463
525
|
if not getattr(args, "snapshot", None) and not _query_is_fresh(output.parent.parent, snapshot, files):
|
|
464
526
|
print("Stale code index: refresh required before query.", file=sys.stderr); return 1
|
|
465
527
|
conn = sqlite3.connect(str(selected / "index.sqlite")); conn.row_factory = sqlite3.Row
|
|
466
528
|
try:
|
|
529
|
+
symbol_columns = {row[1] for row in conn.execute("PRAGMA table_info(symbols)")}
|
|
530
|
+
parser_expr = "s.parser" if "parser" in symbol_columns else "NULL"
|
|
531
|
+
hash_expr = "s.content_hash" if "content_hash" in symbol_columns else "NULL"
|
|
532
|
+
to_parser_expr = "t.parser" if "parser" in symbol_columns else "NULL"
|
|
533
|
+
to_hash_expr = "t.content_hash" if "content_hash" in symbol_columns else "NULL"
|
|
467
534
|
if depth == 0:
|
|
468
535
|
print(json.dumps({"_meta": {"snapshot": snapshot.get("generation"), "depthLimit": 0, "resultLimit": limit, "truncated": False}}, ensure_ascii=False, sort_keys=True))
|
|
469
536
|
return 0
|
|
@@ -476,14 +543,21 @@ def command_relations(args) -> int:
|
|
|
476
543
|
SELECT r.from_id,r.relation_type,r.to_id,r.to_external,r.evidence_path,r.evidence_line,r.confidence,g.depth+1,g.visited || COALESCE(r.to_id,'') || ','
|
|
477
544
|
FROM graph g JOIN relations r ON r.from_id=g.to_id
|
|
478
545
|
WHERE g.depth < ? AND r.to_id IS NOT NULL {confidence} AND instr(g.visited, ',' || r.to_id || ',')=0
|
|
479
|
-
) SELECT
|
|
546
|
+
) SELECT graph.*, s.path AS from_path, s.name AS from_name, {parser_expr} AS from_parser,
|
|
547
|
+
{hash_expr} AS from_content_hash, t.path AS to_path, t.name AS to_name,
|
|
548
|
+
{to_parser_expr} AS to_parser, {to_hash_expr} AS to_content_hash
|
|
549
|
+
FROM graph
|
|
550
|
+
LEFT JOIN symbols s ON s.id=graph.from_id
|
|
551
|
+
LEFT JOIN symbols t ON t.id=graph.to_id
|
|
552
|
+
ORDER BY depth,from_id,relation_type,to_id LIMIT ?
|
|
480
553
|
""", (args.object, depth, limit + 1)).fetchall()
|
|
481
554
|
truncated = len(rows) > limit
|
|
482
555
|
for row in rows[:limit]:
|
|
483
556
|
item = dict(row)
|
|
484
|
-
|
|
557
|
+
item["snapshot"] = snapshot.get("generation")
|
|
558
|
+
if getattr(args, "change_type", None):
|
|
485
559
|
item["impact_kind"] = args.change_type
|
|
486
|
-
item["severity"] = _impact_severity(args.change_type, item
|
|
560
|
+
item["severity"] = _impact_severity(args.change_type, item.get("flow_type", "relation"))
|
|
487
561
|
print(json.dumps(item, ensure_ascii=False, sort_keys=True))
|
|
488
562
|
print(json.dumps({"_meta": {"snapshot": snapshot.get("generation"), "depthLimit": depth, "resultLimit": limit, "truncated": truncated}}, ensure_ascii=False, sort_keys=True))
|
|
489
563
|
finally: conn.close()
|
|
@@ -503,10 +577,13 @@ def _impact_severity(change_type: str, flow_type: str) -> str:
|
|
|
503
577
|
|
|
504
578
|
def command_impact(args) -> int:
|
|
505
579
|
output = Path(args.index).resolve()
|
|
506
|
-
depth = max(0, min(args.depth, 20)); limit =
|
|
580
|
+
depth = max(0, min(args.depth, 20)); limit = _bounded_limit(args.limit, 2000)
|
|
507
581
|
with _index_lock(output, exclusive=False):
|
|
508
582
|
selected = _selected_generation(output, getattr(args, "snapshot", None))
|
|
509
583
|
snapshot = _load_snapshot(selected)
|
|
584
|
+
bundle_errors = _validate_snapshot_bundle(selected, snapshot)
|
|
585
|
+
if bundle_errors:
|
|
586
|
+
print(bundle_errors[0], file=sys.stderr); return 1
|
|
510
587
|
if not getattr(args, "snapshot", None):
|
|
511
588
|
files = read_jsonl(selected / INDEX_FILES["files"])
|
|
512
589
|
if not _query_is_fresh(output.parent.parent, snapshot, files):
|
|
@@ -514,6 +591,11 @@ def command_impact(args) -> int:
|
|
|
514
591
|
return 1
|
|
515
592
|
conn = sqlite3.connect(str(selected / "index.sqlite")); conn.row_factory = sqlite3.Row
|
|
516
593
|
try:
|
|
594
|
+
tables = _table_names(conn)
|
|
595
|
+
semantic_tables = {"semantic_flows", "entity_versions", "feature_bindings", "coverage_metrics"}
|
|
596
|
+
if not semantic_tables.issubset(tables):
|
|
597
|
+
print(json.dumps({"_meta": {"snapshot": snapshot.get("generation"), "status": "unavailable", "reason": "semantic_capability_unavailable", "coverage": {"status": "unknown"}, "truncated": False}}, ensure_ascii=False, sort_keys=True))
|
|
598
|
+
return 0
|
|
517
599
|
rows = conn.execute("""
|
|
518
600
|
WITH RECURSIVE graph(node_id, flow_type, source_path, line_start, confidence, depth, visited) AS (
|
|
519
601
|
SELECT CASE WHEN source_id=? THEN target_id ELSE source_id END, flow_type, source_path, line_start, confidence, 1, ',' || ? || ','
|
|
@@ -525,7 +607,12 @@ def command_impact(args) -> int:
|
|
|
525
607
|
WHERE g.depth < ? AND instr(g.visited, ',' || CASE WHEN f.source_id=g.node_id THEN f.target_id ELSE f.source_id END || ',')=0
|
|
526
608
|
) SELECT * FROM graph LIMIT ?
|
|
527
609
|
""", (args.entity, args.entity, snapshot.get("generation"), args.entity, args.entity, snapshot.get("generation"), depth, limit + 1)).fetchall()
|
|
528
|
-
for row in rows[:limit]:
|
|
610
|
+
for row in rows[:limit]:
|
|
611
|
+
item = dict(row); item["snapshot"] = snapshot.get("generation")
|
|
612
|
+
if getattr(args, "change_type", None):
|
|
613
|
+
item["impact_kind"] = args.change_type
|
|
614
|
+
item["severity"] = _impact_severity(args.change_type, item.get("flow_type", "relation"))
|
|
615
|
+
print(json.dumps(item, ensure_ascii=False, sort_keys=True))
|
|
529
616
|
coverage_row = conn.execute("SELECT * FROM coverage_metrics WHERE snapshot_id=?", (snapshot.get("generation"),)).fetchone()
|
|
530
617
|
coverage = dict(coverage_row) if coverage_row else {"status": "unknown"}
|
|
531
618
|
print(json.dumps({"_meta": {"snapshot": snapshot.get("generation"), "coverage": coverage, "depthLimit": depth, "truncated": len(rows) > limit}}, ensure_ascii=False, sort_keys=True))
|
|
@@ -536,6 +623,13 @@ def command_impact(args) -> int:
|
|
|
536
623
|
|
|
537
624
|
def _selected_generation(output: Path, generation: str | None) -> Path:
|
|
538
625
|
if not generation:
|
|
626
|
+
root_snapshot = _load_snapshot(output)
|
|
627
|
+
current = root_snapshot.get("generation") if root_snapshot else None
|
|
628
|
+
if current and re.fullmatch(r"[0-9a-f-]{36}", str(current), re.I):
|
|
629
|
+
selected = output / "snapshots" / str(current)
|
|
630
|
+
if selected.is_dir():
|
|
631
|
+
return selected
|
|
632
|
+
# Legacy indexes predating immutable snapshots remain readable.
|
|
539
633
|
return output
|
|
540
634
|
if not re.fullmatch(r"[0-9a-f-]{36}", generation, re.I):
|
|
541
635
|
raise ValueError("invalid snapshot generation")
|
|
@@ -553,6 +647,44 @@ def _load_snapshot(output: Path) -> Dict[str, object]:
|
|
|
553
647
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
554
648
|
|
|
555
649
|
|
|
650
|
+
def _bounded_limit(value, maximum: int) -> int:
|
|
651
|
+
try:
|
|
652
|
+
value = int(value)
|
|
653
|
+
except (TypeError, ValueError):
|
|
654
|
+
value = 20
|
|
655
|
+
return max(1, min(value, maximum))
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _table_names(conn: sqlite3.Connection) -> set[str]:
|
|
659
|
+
return {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type IN ('table','virtual table')")}
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _validate_snapshot_bundle(selected: Path, snapshot: Dict[str, object]) -> List[str]:
|
|
663
|
+
errors: List[str] = []
|
|
664
|
+
generation = snapshot.get("generation")
|
|
665
|
+
if not generation:
|
|
666
|
+
return [f"snapshot metadata missing generation in {selected}"]
|
|
667
|
+
required = [SNAPSHOT_FILE, "index.sqlite", *INDEX_FILES.values()]
|
|
668
|
+
for name in required:
|
|
669
|
+
if not (selected / name).is_file():
|
|
670
|
+
errors.append(f"snapshot {generation} missing artifact {name}")
|
|
671
|
+
index = selected / "index.sqlite"
|
|
672
|
+
if index.is_file():
|
|
673
|
+
try:
|
|
674
|
+
conn = sqlite3.connect(str(index))
|
|
675
|
+
try:
|
|
676
|
+
tables = _table_names(conn)
|
|
677
|
+
if "analysis_snapshots" in tables:
|
|
678
|
+
row = conn.execute("SELECT snapshot_id FROM analysis_snapshots LIMIT 1").fetchone()
|
|
679
|
+
if row and row[0] != generation:
|
|
680
|
+
errors.append(f"snapshot generation mismatch: metadata={generation}, sqlite={row[0]}")
|
|
681
|
+
finally:
|
|
682
|
+
conn.close()
|
|
683
|
+
except sqlite3.Error as exc:
|
|
684
|
+
errors.append(f"invalid sqlite snapshot {generation}: {exc}")
|
|
685
|
+
return errors
|
|
686
|
+
|
|
687
|
+
|
|
556
688
|
def _query_is_fresh(root: Path, snapshot: Dict[str, object], files: List[dict]) -> bool:
|
|
557
689
|
workspace = snapshot.get("workspace") if isinstance(snapshot, dict) else None
|
|
558
690
|
if not isinstance(workspace, dict):
|
|
@@ -571,7 +703,9 @@ def _query_is_fresh(root: Path, snapshot: Dict[str, object], files: List[dict])
|
|
|
571
703
|
return False
|
|
572
704
|
indexed_paths = sorted(record["path"] for record in files)
|
|
573
705
|
if not any(path.startswith("../") for path in indexed_paths):
|
|
574
|
-
|
|
706
|
+
scope = workspace.get("scanScope") if isinstance(workspace.get("scanScope"), dict) else {}
|
|
707
|
+
excludes = scope.get("exclude", []) if isinstance(scope, dict) else []
|
|
708
|
+
current_paths = sorted(_workspace_rel(root, item) for item in scan_files(root, excludes=excludes))
|
|
575
709
|
expected_paths_hash = workspace.get("discoveredPathsHash") or workspace.get("indexedPathsHash")
|
|
576
710
|
if _hash_lines(current_paths) != expected_paths_hash:
|
|
577
711
|
return False
|
|
@@ -583,7 +717,14 @@ def command_audit(args) -> int:
|
|
|
583
717
|
root = Path(args.root).resolve()
|
|
584
718
|
output = Path(args.index).resolve()
|
|
585
719
|
with _index_lock(output, exclusive=True):
|
|
586
|
-
|
|
720
|
+
selected = _selected_generation(output, getattr(args, "snapshot", None))
|
|
721
|
+
snapshot = _load_snapshot(selected)
|
|
722
|
+
bundle_errors = _validate_snapshot_bundle(selected, snapshot)
|
|
723
|
+
if bundle_errors:
|
|
724
|
+
for error in bundle_errors:
|
|
725
|
+
print(error, file=sys.stderr)
|
|
726
|
+
return 1
|
|
727
|
+
files, symbols, relations, diagnostics = _load_tuple(selected)
|
|
587
728
|
findings = []
|
|
588
729
|
findings.extend(audit_stale(root, files))
|
|
589
730
|
findings.extend(audit_relations(symbols, relations))
|