@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.
@@ -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
- file_record["parser"] = adapter.parser_for_file(codebase, file_record)
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
- adapter_entities.extend(getattr(result, "entities", []) or [])
297
- adapter_flows.extend(getattr(result, "flows", []) or [])
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
- workspace_state = _workspace_state(root, files, changed, discovered_paths, registry.capabilities())
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
- semantic_entities, semantic_flows, feature_bindings = build_semantic_facts(root, files, symbols, relations)
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
- row = conn.execute("SELECT snapshot_id FROM analysis_snapshots WHERE snapshot_id=?", (snapshot_id,)).fetchone()
382
- if not row:
383
- errors.append(f"semantic schema missing snapshot {snapshot_id}")
384
- dangling = conn.execute("""
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
- """, (snapshot_id,)).fetchone()[0]
394
- if dangling:
395
- errors.append(f"semantic flows have {dangling} dangling source or target nodes")
396
- binding_dangling = conn.execute("""
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
- """, (snapshot_id,)).fetchone()[0]
403
- if binding_dangling:
404
- errors.append(f"feature bindings have {binding_dangling} dangling feature or entity nodes")
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, args.limit),
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, args.limit),
503
+ (like, like, like, limit),
449
504
  ).fetchall()
450
505
  for row in rows:
451
- print(json.dumps(dict(row), ensure_ascii=False, sort_keys=True))
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 = max(1, min(args.limit, 1000))
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 * FROM graph ORDER BY depth,from_id,relation_type,to_id LIMIT ?
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
- if args.change_type:
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["flow_type"])
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 = max(1, min(args.limit, 2000))
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]: print(json.dumps(dict(row), ensure_ascii=False, sort_keys=True))
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
- current_paths = sorted(_workspace_rel(root, item) for item in scan_files(root))
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
- files, symbols, relations, diagnostics = _load_tuple(output)
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))