@guanlin4924/devflow-pi 0.1.79 → 0.1.81

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.
@@ -27,7 +27,10 @@ from adapters.registry import AdapterRegistry
27
27
  from semantic import SEMANTIC_EXTRACTOR_VERSION, build_semantic_facts
28
28
  from core.discovery import discover_codebases
29
29
  from core.hashing import file_sha256
30
- from core.scanner import scan_files
30
+ from core.scanner import scan_files, DEFAULT_EXCLUDES
31
+ from hybrid_query import execute as execute_plan, read_connection, validate_plan
32
+ from domain_registry import import_bundle
33
+ from source_content import content_integrity
31
34
  from validators.project_binding_check import audit_project_binding
32
35
  from validators.relation_check import audit_relations
33
36
  from validators.schema_validate import validate_index
@@ -68,13 +71,16 @@ def _publish_snapshot(staging: Path, output: Path, counts: Dict[str, int], works
68
71
  # Keep the public snapshot schema stable while exposing the richer
69
72
  # SQLite schema separately. Older consumers only know schemaVersion=1.
70
73
  "schemaVersion": 1,
71
- "indexSchemaVersion": 3,
74
+ "indexSchemaVersion": 4 if workspace_state.get("contentSearch") else 3,
72
75
  "capabilities": {
73
76
  "structural": True,
74
77
  "semantic": True,
75
78
  "fts": True,
76
79
  "relations": True,
77
80
  "provenance": True,
81
+ "contentSearch": workspace_state.get("contentSearch", False),
82
+ "domainMapping": {"version": 1, "storage": "external_registry"},
83
+ "queryPlan": {"version": 1},
78
84
  },
79
85
  "generation": generation,
80
86
  "generatedAt": datetime.now(timezone.utc).isoformat(),
@@ -195,17 +201,26 @@ def main(argv=None) -> int:
195
201
  scan.add_argument("--output", default=".planning/code-index", help="Index output directory.")
196
202
  scan.add_argument("--codebase", action="append", default=[], help="Explicit codebase: name:path[:type]. Can repeat.")
197
203
  scan.add_argument("--changed-files", nargs="*", default=[], help="Optional changed files for incremental refresh.")
204
+ scan.add_argument("--include", action="append", default=[], help="Repository-relative include glob. Can repeat.")
205
+ scan.add_argument("--max-file-size", type=int, default=1500000)
198
206
  scan.add_argument("--exclude", action="append", default=[], help="Additional directory names to exclude.")
199
207
 
200
- validate = sub.add_parser("validate", help="Validate JSONL schema.")
208
+ validate = sub.add_parser("validate", help="Validate an index in staged levels.")
201
209
  validate.add_argument("--index", default=".planning/code-index", help="Index directory.")
202
210
  validate.add_argument("--snapshot", help="Immutable generation id.")
211
+ validate.add_argument(
212
+ "--level",
213
+ choices=("fast", "publish", "full"),
214
+ default="publish",
215
+ help="fast bundle checks, publish gate checks, or full JSONL/semantic audit",
216
+ )
203
217
 
204
218
  audit = sub.add_parser("audit", help="Audit stale files and relation integrity.")
205
219
  audit.add_argument("--root", default=".", help="Project root.")
206
220
  audit.add_argument("--index", default=".planning/code-index", help="Index directory.")
207
221
  audit.add_argument("--skill-root", default=".skills", help="Optional skill root for project-binding checks.")
208
222
  audit.add_argument("--snapshot", help="Immutable generation id.")
223
+ audit.add_argument("--level", choices=("publish", "full"), default="publish")
209
224
 
210
225
  query = sub.add_parser("query", help="Search symbols using SQLite FTS or LIKE fallback.")
211
226
  query.add_argument("--index", default=".planning/code-index", help="Index directory.")
@@ -220,6 +235,7 @@ def main(argv=None) -> int:
220
235
  relations.add_argument("--limit", type=int, default=100)
221
236
  relations.add_argument("--snapshot")
222
237
  relations.add_argument("--include-heuristic", action="store_true")
238
+ relations.add_argument("--direction", choices=["incoming", "outgoing", "both"], default="outgoing")
223
239
 
224
240
  impact = sub.add_parser("impact", help="Traverse cross-layer semantic facts in a fixed snapshot.")
225
241
  impact.add_argument("--index", default=".planning/code-index")
@@ -229,7 +245,28 @@ def main(argv=None) -> int:
229
245
  impact.add_argument("--snapshot")
230
246
  impact.add_argument("--change-type", choices=["remove_field", "rename_field", "change_type", "optional_to_required", "required_to_optional", "change_nullability", "change_enum"], help="Optional proposed change classification.")
231
247
 
248
+ for action in ("search-content", "resolve-domain", "query-plan", "domain-import"):
249
+ operation = sub.add_parser(action)
250
+ operation.add_argument("--index", default=".planning/code-index")
251
+ operation.add_argument("--snapshot")
252
+ operation.add_argument("--project-id", default="")
253
+ operation.add_argument("--limit", type=int, default=20)
254
+ operation.add_argument("--repository", action="append", default=[])
255
+ operation.add_argument("--path-prefix", default="")
256
+ operation.add_argument("--mapping-revision", default="approved-current")
257
+ if action == "search-content":
258
+ operation.add_argument("--language")
259
+ operation.add_argument("--match-kind", choices=["literal", "substring", "identifier", "exact_phrase"], default="literal")
260
+ if action == "query-plan":
261
+ operation.add_argument("--plan", required=True, help="Structured QueryPlan JSON, never SQL")
262
+ elif action == "domain-import":
263
+ operation.add_argument("--bundle", required=True, help="JSON mapping bundle file")
264
+ operation.add_argument("--expected-revision", help="Expected approved current revision for CAS")
265
+ elif action in {"search-content", "resolve-domain"}:
266
+ operation.add_argument("--term", required=True)
232
267
  args = parser.parse_args(argv)
268
+ if args.command in {"search-content", "resolve-domain", "query-plan", "domain-import"}:
269
+ return command_structured(args)
233
270
  if args.command == "scan":
234
271
  return command_scan(args)
235
272
  if args.command == "validate":
@@ -248,6 +285,10 @@ def main(argv=None) -> int:
248
285
  def command_scan(args) -> int:
249
286
  root = Path(args.root).resolve()
250
287
  output = (root / args.output).resolve() if not Path(args.output).is_absolute() else Path(args.output).resolve()
288
+ includes = getattr(args, "include", [])
289
+ max_file_size = getattr(args, "max_file_size", 1500000)
290
+ if not 1 <= max_file_size <= 50000000:
291
+ raise ValueError("max-file-size must be 1..50000000")
251
292
  progress = _Progress(output)
252
293
  progress.emit("start", "scan started", root=str(root), output=str(output), codebases=args.codebase)
253
294
  with _index_lock(output, exclusive=True):
@@ -256,6 +297,10 @@ def command_scan(args) -> int:
256
297
  registry = AdapterRegistry()
257
298
  changed = _normalize_changed_files(root, args.changed_files)
258
299
  existing = _load_existing(output)
300
+ previous_scope = _load_snapshot(output).get("workspace", {}).get("scanScope", {})
301
+ previous_roots = {(item.get("name"), item.get("path")) for item in previous_scope.get("codebases", [])}
302
+ if changed and (set(previous_scope.get("exclude", [])) != DEFAULT_EXCLUDES | set(args.exclude) or previous_roots != {(item.name, str(item.root)) for item in codebases} or previous_scope.get("includes", []) != sorted(set(includes)) or previous_scope.get("maxFileSize", 1500000) != max_file_size):
303
+ changed = set() # Scope changes require a complete rebuild, never reuse out-of-scope rows.
259
304
  secondary_changed = _incoming_evidence_files(existing, changed)
260
305
  changed |= secondary_changed
261
306
 
@@ -270,7 +315,7 @@ def command_scan(args) -> int:
270
315
  diagnostics.append(_diagnostic(codebase.name, "", 1, "warning", "skipped_file", f"codebase root does not exist: {codebase.root}", "discovery@1"))
271
316
  continue
272
317
  progress.emit("discover", f"discovering {codebase.name}", codebase=codebase.name, path=str(codebase.root))
273
- all_files = scan_files(codebase.root, excludes=args.exclude)
318
+ all_files = scan_files(codebase.root, excludes=args.exclude, includes=includes, max_bytes=max_file_size)
274
319
  progress.emit("discover", f"discovered {len(all_files)} files in {codebase.name}", codebase=codebase.name, files=len(all_files))
275
320
  discovered_paths.update(_workspace_rel(root, path) for path in all_files)
276
321
  if changed:
@@ -348,11 +393,17 @@ def command_scan(args) -> int:
348
393
  )
349
394
  workspace_state["generatedAt"] = datetime.now(timezone.utc).isoformat()
350
395
  workspace_state["scanScope"] = {
351
- "exclude": sorted(set(args.exclude)),
396
+ "exclude": sorted(DEFAULT_EXCLUDES | set(args.exclude)),
397
+ "includes": sorted(set(includes)),
398
+ "generatedPolicy": "exclude_known_build_directories",
399
+ "dependencyPolicy": "exclude_known_dependency_directories",
400
+ "maxFileSize": max_file_size,
352
401
  "codebases": [
353
- {"name": item.name, "path": str(item.root)} for item in codebases
402
+ {"name": item.name, "path": str(item.root), "workspacePath": _workspace_rel(root, item.root)} for item in codebases
354
403
  ],
355
404
  }
405
+ workspace_state["scopeHash"] = _hash_lines([json.dumps(workspace_state["scanScope"], sort_keys=True)])
406
+ workspace_state["root"] = str(root)
356
407
  workspace_state["semanticExtractor"] = SEMANTIC_EXTRACTOR_VERSION
357
408
  workspace_state["extractorSetHash"] = _hash_lines([*workspace_state.get("extractors", []), SEMANTIC_EXTRACTOR_VERSION])
358
409
  progress.emit("semantic", "building semantic facts")
@@ -374,6 +425,7 @@ def command_scan(args) -> int:
374
425
  semantic_entities=semantic_entities,
375
426
  semantic_flows=semantic_flows,
376
427
  feature_bindings=feature_bindings,
428
+ source_root=root,
377
429
  )
378
430
  progress.emit("documents", "writing index projections")
379
431
  write_markdown(staging, files, symbols, relations, diagnostics)
@@ -405,58 +457,198 @@ def command_scan(args) -> int:
405
457
  return 0
406
458
 
407
459
 
408
- def command_validate(args) -> int:
409
- output = Path(args.index).resolve()
410
- selected = _selected_generation(output, getattr(args, "snapshot", None))
411
- errors = []
460
+ def _validate_publish_gate(selected: Path, snapshot: Dict[str, object]) -> List[str]:
461
+ """Run bounded checks required before publishing a generation.
462
+
463
+ This deliberately uses SQLite for referential checks. The previous
464
+ implementation loaded every JSONL sidecar before it could decide whether
465
+ a snapshot was publishable, which made large projects spend minutes at
466
+ 100% CPU in a validation process.
467
+ """
468
+ errors = _validate_snapshot_bundle(selected, snapshot)
469
+ if errors:
470
+ return errors
471
+ index = selected / "index.sqlite"
412
472
  try:
413
- snapshot = _load_snapshot(selected)
414
- errors.extend(_validate_snapshot_bundle(selected, snapshot))
415
- files, symbols, relations, diagnostics = _load_tuple(selected)
416
- errors.extend(validate_index(files, symbols, relations, diagnostics))
417
- conn = sqlite3.connect(str(selected / "index.sqlite"))
473
+ conn = sqlite3.connect(str(index))
418
474
  try:
475
+ quick = conn.execute("PRAGMA quick_check").fetchone()
476
+ if not quick or quick[0] != "ok":
477
+ errors.append(f"sqlite quick_check failed: {quick[0] if quick else 'no result'}")
478
+ if snapshot.get("capabilities", {}).get("contentSearch"):
479
+ content_tables = _table_names(conn)
480
+ for table in ("source_artifacts", "source_content_chunks", "snapshot_metadata"):
481
+ if table not in content_tables:
482
+ errors.append(f"content capability missing table {table}")
483
+ content_capability = snapshot["capabilities"]["contentSearch"]
484
+ if isinstance(content_capability, dict) and content_capability.get("tokenizer") == "trigram" and "source_content_fts" not in content_tables:
485
+ errors.append("trigram content capability missing FTS table")
486
+ if {"source_content_chunks", "snapshot_metadata"}.issubset(content_tables):
487
+ declared = conn.execute("SELECT value FROM snapshot_metadata WHERE key='contentSearch'").fetchone()
488
+ integrity = conn.execute("SELECT value FROM snapshot_metadata WHERE key='contentIntegrity'").fetchone()
489
+ if declared is None or json.loads(declared[0]) != content_capability:
490
+ errors.append("content capability metadata mismatch")
491
+ actual = content_integrity(conn)
492
+ if integrity is None or json.loads(integrity[0]) != actual or actual.get("missing_fts_rowids", 0):
493
+ errors.append("content/FTS rowid, count or integrity mismatch")
494
+ if {"source_content_chunks", "code_files", "source_artifacts"}.issubset(content_tables):
495
+ invalid = conn.execute("SELECT count(*) FROM source_content_chunks c LEFT JOIN code_files f ON f.id=c.code_file_id LEFT JOIN source_artifacts a ON a.content_hash=c.content_hash WHERE f.id IS NULL OR a.content_hash IS NULL OR c.content_hash<>f.content_hash OR c.code_generation<>?", (snapshot.get("generation"),)).fetchone()[0]
496
+ if invalid:
497
+ errors.append(f"content index has {invalid} invalid chunk references")
419
498
  snapshot_id = snapshot.get("generation")
420
499
  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:
500
+ required = {"analysis_snapshots", "code_files", "symbols", "relations", "diagnostics"}
501
+ missing = sorted(required - tables)
502
+ if missing:
503
+ errors.append(f"index missing required tables: {', '.join(missing)}")
504
+ elif not conn.execute("SELECT 1 FROM analysis_snapshots WHERE snapshot_id=? LIMIT 1", (snapshot_id,)).fetchone():
505
+ errors.append(f"snapshot metadata missing generation {snapshot_id}")
506
+
507
+ if {"relations", "symbols"}.issubset(tables):
508
+ dangling = conn.execute("""
509
+ SELECT COUNT(*) FROM relations r
510
+ LEFT JOIN symbols s ON s.id=r.from_id
511
+ WHERE s.id IS NULL
512
+ """).fetchone()[0]
513
+ if dangling:
514
+ errors.append(f"relations have {dangling} missing source symbols")
430
515
  dangling = conn.execute("""
431
- SELECT COUNT(*) FROM semantic_flows f
432
- WHERE f.snapshot_id=? AND (
433
- (NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=f.snapshot_id AND e.logical_id=f.source_id)
434
- AND NOT EXISTS (SELECT 1 FROM symbols s WHERE s.id=f.source_id))
435
- OR
436
- (NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=f.snapshot_id AND e.logical_id=f.target_id)
437
- AND NOT EXISTS (SELECT 1 FROM symbols s WHERE s.id=f.target_id))
438
- )
439
- """, (snapshot_id,)).fetchone()[0]
516
+ SELECT COUNT(*) FROM relations r
517
+ LEFT JOIN symbols s ON s.id=r.to_id
518
+ WHERE r.to_id IS NOT NULL AND s.id IS NULL AND (r.to_external IS NULL OR r.to_external='')
519
+ """).fetchone()[0]
440
520
  if dangling:
441
- errors.append(f"semantic flows have {dangling} dangling source or target nodes")
442
- binding_dangling = conn.execute("""
443
- SELECT COUNT(*) FROM feature_bindings b
444
- WHERE b.snapshot_id=? AND (
445
- NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=b.snapshot_id AND e.logical_id=b.feature_id)
446
- OR NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=b.snapshot_id AND e.logical_id=b.entity_id)
447
- )
448
- """, (snapshot_id,)).fetchone()[0]
449
- if binding_dangling:
450
- errors.append(f"feature bindings have {binding_dangling} dangling feature or entity nodes")
521
+ errors.append(f"relations have {dangling} missing target symbols")
451
522
  finally:
452
523
  conn.close()
524
+ except (OSError, sqlite3.Error) as exc:
525
+ errors.append(str(exc))
526
+ return errors
527
+
528
+
529
+ def _validate_semantic_graph(conn: sqlite3.Connection, snapshot_id: str, tables: set[str]) -> List[str]:
530
+ errors: List[str] = []
531
+ semantic_tables = {"semantic_flows", "entity_versions", "feature_bindings", "coverage_metrics"}
532
+ if not semantic_tables.issubset(tables):
533
+ missing = ", ".join(sorted(semantic_tables - tables))
534
+ errors.append(f"full validation unavailable: missing semantic tables: {missing}")
535
+ return errors
536
+ dangling = conn.execute("""
537
+ SELECT COUNT(*) FROM semantic_flows f
538
+ WHERE f.snapshot_id=? AND (
539
+ (NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=f.snapshot_id AND e.logical_id=f.source_id)
540
+ AND NOT EXISTS (SELECT 1 FROM symbols s WHERE s.id=f.source_id))
541
+ OR
542
+ (NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=f.snapshot_id AND e.logical_id=f.target_id)
543
+ AND NOT EXISTS (SELECT 1 FROM symbols s WHERE s.id=f.target_id))
544
+ )
545
+ """, (snapshot_id,)).fetchone()[0]
546
+ if dangling:
547
+ errors.append(f"semantic flows have {dangling} dangling source or target nodes")
548
+ binding_dangling = conn.execute("""
549
+ SELECT COUNT(*) FROM feature_bindings b
550
+ WHERE b.snapshot_id=? AND (
551
+ NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=b.snapshot_id AND e.logical_id=b.feature_id)
552
+ OR NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=b.snapshot_id AND e.logical_id=b.entity_id)
553
+ )
554
+ """, (snapshot_id,)).fetchone()[0]
555
+ if binding_dangling:
556
+ errors.append(f"feature bindings have {binding_dangling} dangling feature or entity nodes")
557
+ return errors
558
+
559
+
560
+ def command_validate(args) -> int:
561
+ output = Path(args.index).resolve()
562
+ selected = _selected_generation(output, getattr(args, "snapshot", None))
563
+ level = getattr(args, "level", "publish")
564
+ errors: List[str] = []
565
+ try:
566
+ snapshot = _load_snapshot(selected)
567
+ print(f"[code-index][validate] level={level} snapshot={snapshot.get('generation', 'unknown')}", file=sys.stderr, flush=True)
568
+ if level == "fast":
569
+ errors.extend(_validate_snapshot_bundle(selected, snapshot))
570
+ else:
571
+ errors.extend(_validate_publish_gate(selected, snapshot))
572
+ if level == "full" and not errors:
573
+ print("[code-index][validate] full JSONL schema validation", file=sys.stderr, flush=True)
574
+ files, symbols, relations, diagnostics = _load_tuple(selected)
575
+ errors.extend(validate_index(files, symbols, relations, diagnostics))
576
+ if not errors:
577
+ conn = sqlite3.connect(str(selected / "index.sqlite"))
578
+ try:
579
+ errors.extend(_validate_semantic_graph(conn, str(snapshot.get("generation")), _table_names(conn)))
580
+ finally:
581
+ conn.close()
453
582
  except (OSError, sqlite3.Error, ValueError) as exc:
454
583
  errors.append(str(exc))
455
584
  for error in errors:
456
585
  print(error, file=sys.stderr)
586
+ if not errors:
587
+ print(f"Index validation passed ({level}).")
457
588
  return 1 if errors else 0
589
+ def command_structured(args) -> int:
590
+ output = Path(args.index).resolve()
591
+ try:
592
+ if args.command == "query-plan":
593
+ if len(args.plan) > 32000:
594
+ raise ValueError("QueryPlan exceeds 32KB")
595
+ raw_plan = json.loads(args.plan)
596
+ if "limit" not in raw_plan:
597
+ raw_plan["limit"] = getattr(args, "limit", 20)
598
+ plan = validate_plan(raw_plan)
599
+ else:
600
+ plan = {"featureTerms": [args.term] if hasattr(args, "term") else [],
601
+ "phases": ["search_content", "verify_evidence"] if args.command == "search-content" else ["resolve_domain"],
602
+ "limit": args.limit, "repositoryFilter": getattr(args, "repository", []),
603
+ "pathPrefix": getattr(args, "path_prefix", ""), "mappingRevision": getattr(args, "mapping_revision", "approved-current"),
604
+ "language": getattr(args, "language", None), "matchKind": getattr(args, "match_kind", "literal")}
605
+ plan = validate_plan(plan)
606
+ requested = getattr(args, "snapshot", None)
607
+ planned = plan.get("codeGeneration")
608
+ if planned and planned != "current":
609
+ if requested and requested != planned:
610
+ raise ValueError("conflicting snapshot and codeGeneration")
611
+ requested = planned
612
+ with _index_lock(output, exclusive=False):
613
+ selected = _selected_generation(output, requested)
614
+ snapshot = _load_snapshot(selected)
615
+ errors = _validate_snapshot_bundle(selected, snapshot)
616
+ if errors:
617
+ raise ValueError(errors[0])
618
+ if not requested and not _query_is_fresh(Path(snapshot.get("workspace", {}).get("root", output.parent.parent)), snapshot, read_jsonl(selected / INDEX_FILES["files"])):
619
+ print(json.dumps({"_meta": {"status": "stale_index", "snapshot": snapshot.get("generation")}}))
620
+ return 1
621
+ conn = read_connection(selected / "index.sqlite")
622
+ try:
623
+ if snapshot.get("workspace", {}).get("scopeHash"):
624
+ scope_json = json.dumps(snapshot["workspace"]["scanScope"], sort_keys=True)
625
+ if _hash_lines([scope_json]) != snapshot["workspace"]["scopeHash"]:
626
+ raise ValueError("scan scope hash mismatch")
627
+ stored = conn.execute("SELECT value FROM snapshot_metadata WHERE key='scanScope'").fetchone()
628
+ if stored is None or json.loads(stored[0]) != snapshot["workspace"]["scanScope"]:
629
+ raise ValueError("scan scope metadata mismatch")
630
+ registry = output.parent / "domain-registry.sqlite"
631
+ if args.command == "domain-import":
632
+ bundle_path = Path(args.bundle)
633
+ if bundle_path.stat().st_size > 2_000_000:
634
+ raise ValueError("mapping bundle exceeds 2MB")
635
+ result = import_bundle(registry, args.project_id, json.loads(bundle_path.read_text()), getattr(args, "expected_revision", None), conn, snapshot["generation"])
636
+ else:
637
+ result = execute_plan(conn, snapshot, plan, registry, getattr(args, "project_id", ""))
638
+ print(json.dumps(result, ensure_ascii=False, sort_keys=True))
639
+ finally:
640
+ conn.close()
641
+ return 0
642
+ except (ValueError, TypeError, KeyError, OSError, sqlite3.Error) as exc:
643
+ status = "partial" if isinstance(exc, sqlite3.OperationalError) and "interrupt" in str(exc) else "invalid_query"
644
+ print(json.dumps({"_meta": {"status": status, "reason": str(exc)}}))
645
+ return 1
646
+
647
+
458
648
  def command_query(args) -> int:
459
649
  output = Path(args.index).resolve()
650
+ if hasattr(args, "term") and (not args.term or len(args.term) > 256 or "\x00" in args.term):
651
+ raise ValueError("term must contain 1..256 characters")
460
652
  with _index_lock(output, exclusive=False):
461
653
  selected = _selected_generation(output, getattr(args, "snapshot", None))
462
654
  index = selected / "index.sqlite"
@@ -473,8 +665,7 @@ def command_query(args) -> int:
473
665
  if not getattr(args, "snapshot", None) and not _query_is_fresh(output.parent.parent, snapshot, files):
474
666
  print("Stale code index: refresh required before query.", file=sys.stderr)
475
667
  return 1
476
- conn = sqlite3.connect(str(index))
477
- conn.row_factory = sqlite3.Row
668
+ conn = read_connection(index)
478
669
  try:
479
670
  symbol_columns = {row[1] for row in conn.execute("PRAGMA table_info(symbols)")}
480
671
  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"
@@ -488,16 +679,16 @@ def command_query(args) -> int:
488
679
  WHERE symbols_fts MATCH ?
489
680
  LIMIT ?
490
681
  """,
491
- (args.term, limit),
682
+ ('"' + args.term.replace('"', '""') + '"', limit),
492
683
  ).fetchall()
493
684
  except sqlite3.OperationalError:
494
- like = f"%{args.term}%"
685
+ like = args.term
495
686
  rows = conn.execute(
496
687
  f"""
497
688
  SELECT id, codebase, kind, name, qualified_name, path, line_start, line_end,
498
689
  {provenance[2:]}
499
690
  FROM symbols
500
- WHERE name LIKE ? OR qualified_name LIKE ? OR path LIKE ?
691
+ WHERE instr(lower(name),lower(?))>0 OR instr(lower(qualified_name),lower(?))>0 OR instr(lower(path),lower(?))>0
501
692
  LIMIT ?
502
693
  """,
503
694
  (like, like, like, limit),
@@ -514,6 +705,10 @@ def command_query(args) -> int:
514
705
 
515
706
 
516
707
  def command_relations(args) -> int:
708
+ if getattr(args, "direction", "outgoing") != "outgoing":
709
+ args.plan = json.dumps({"seedEntityIds": [args.object], "phases": ["traverse_relations", "verify_evidence"], "direction": args.direction, "depth": min(args.depth, 5), "limit": min(args.limit, 100), "includeHeuristic": args.include_heuristic})
710
+ args.command = "query-plan"
711
+ return command_structured(args)
517
712
  output = Path(args.index).resolve()
518
713
  depth = max(0, min(args.depth, 20)); limit = _bounded_limit(args.limit, 1000)
519
714
  with _index_lock(output, exclusive=False):
@@ -524,7 +719,7 @@ def command_relations(args) -> int:
524
719
  print(bundle_errors[0], file=sys.stderr); return 1
525
720
  if not getattr(args, "snapshot", None) and not _query_is_fresh(output.parent.parent, snapshot, files):
526
721
  print("Stale code index: refresh required before query.", file=sys.stderr); return 1
527
- conn = sqlite3.connect(str(selected / "index.sqlite")); conn.row_factory = sqlite3.Row
722
+ conn = read_connection(selected / "index.sqlite")
528
723
  try:
529
724
  symbol_columns = {row[1] for row in conn.execute("PRAGMA table_info(symbols)")}
530
725
  parser_expr = "s.parser" if "parser" in symbol_columns else "NULL"
@@ -555,6 +750,10 @@ def command_relations(args) -> int:
555
750
  for row in rows[:limit]:
556
751
  item = dict(row)
557
752
  item["snapshot"] = snapshot.get("generation")
753
+ relation_parser = conn.execute("SELECT parser FROM relations WHERE from_id=? AND relation_type=? AND evidence_path=? AND evidence_line=? LIMIT 1", (item["from_id"], item["relation_type"], item["evidence_path"], item["evidence_line"])).fetchone()
754
+ item["relation_parser"] = relation_parser[0] if relation_parser else "unknown"
755
+ item["source_file_parser"] = item.get("from_parser")
756
+ item["provenance"] = {"relation_parser": item["relation_parser"], "source_file_parser": item["source_file_parser"], "snapshot": snapshot.get("generation")}
558
757
  if getattr(args, "change_type", None):
559
758
  item["impact_kind"] = args.change_type
560
759
  item["severity"] = _impact_severity(args.change_type, item.get("flow_type", "relation"))
@@ -589,7 +788,7 @@ def command_impact(args) -> int:
589
788
  if not _query_is_fresh(output.parent.parent, snapshot, files):
590
789
  print("Stale code index: refresh required before impact query.", file=sys.stderr)
591
790
  return 1
592
- conn = sqlite3.connect(str(selected / "index.sqlite")); conn.row_factory = sqlite3.Row
791
+ conn = read_connection(selected / "index.sqlite")
593
792
  try:
594
793
  tables = _table_names(conn)
595
794
  semantic_tables = {"semantic_flows", "entity_versions", "feature_bindings", "coverage_metrics"}
@@ -625,21 +824,37 @@ def _selected_generation(output: Path, generation: str | None) -> Path:
625
824
  if not generation:
626
825
  root_snapshot = _load_snapshot(output)
627
826
  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
827
+ if current is not None:
828
+ if not re.fullmatch(r"[0-9a-f-]{36}", str(current), re.I):
829
+ raise ValueError(f"invalid current snapshot generation: {current}")
830
+ current = str(current)
831
+ _validate_generation_pointer(output, root_snapshot, current)
832
+ selected = output / "snapshots" / current
833
+ if not selected.is_dir():
834
+ raise ValueError(f"current snapshot directory is missing: {selected}")
835
+ selected_snapshot = _load_snapshot(selected)
836
+ _validate_generation_pointer(selected, selected_snapshot, current)
837
+ return selected
632
838
  # Legacy indexes predating immutable snapshots remain readable.
633
839
  return output
634
840
  if not re.fullmatch(r"[0-9a-f-]{36}", generation, re.I):
635
841
  raise ValueError("invalid snapshot generation")
636
842
  selected = output / "snapshots" / generation
637
843
  snapshot = _load_snapshot(selected)
638
- if snapshot.get("generation") != generation:
639
- raise ValueError(f"unknown snapshot generation: {generation}")
844
+ _validate_generation_pointer(selected, snapshot, generation)
640
845
  return selected
641
846
 
642
847
 
848
+ def _validate_generation_pointer(selected: Path, snapshot: Dict[str, object], expected: str) -> None:
849
+ if snapshot.get("generation") != expected:
850
+ raise ValueError(f"snapshot generation mismatch: expected={expected}, metadata={snapshot.get('generation')}")
851
+ expected_path = f"snapshots/{expected}"
852
+ if snapshot.get("immutablePath") != expected_path:
853
+ raise ValueError(
854
+ f"snapshot immutablePath mismatch: expected={expected_path}, metadata={snapshot.get('immutablePath')}"
855
+ )
856
+
857
+
643
858
  def _load_snapshot(output: Path) -> Dict[str, object]:
644
859
  path = output / SNAPSHOT_FILE
645
860
  if not path.exists():
@@ -664,6 +879,11 @@ def _validate_snapshot_bundle(selected: Path, snapshot: Dict[str, object]) -> Li
664
879
  generation = snapshot.get("generation")
665
880
  if not generation:
666
881
  return [f"snapshot metadata missing generation in {selected}"]
882
+ if selected.parent.name == "snapshots":
883
+ try:
884
+ _validate_generation_pointer(selected, snapshot, selected.name)
885
+ except ValueError as exc:
886
+ errors.append(str(exc))
667
887
  required = [SNAPSHOT_FILE, "index.sqlite", *INDEX_FILES.values()]
668
888
  for name in required:
669
889
  if not (selected / name).is_file():
@@ -705,7 +925,9 @@ def _query_is_fresh(root: Path, snapshot: Dict[str, object], files: List[dict])
705
925
  if not any(path.startswith("../") for path in indexed_paths):
706
926
  scope = workspace.get("scanScope") if isinstance(workspace.get("scanScope"), dict) else {}
707
927
  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))
928
+ repositories = scope.get("codebases", [])
929
+ roots = [Path(item["path"]) for item in repositories] if repositories else [root]
930
+ current_paths = sorted({_workspace_rel(root, item) for repository in roots for item in scan_files(repository, excludes=excludes, max_bytes=scope.get("maxFileSize", 1500000), includes=scope.get("includes", []))})
709
931
  expected_paths_hash = workspace.get("discoveredPathsHash") or workspace.get("indexedPathsHash")
710
932
  if _hash_lines(current_paths) != expected_paths_hash:
711
933
  return False
@@ -724,10 +946,39 @@ def command_audit(args) -> int:
724
946
  for error in bundle_errors:
725
947
  print(error, file=sys.stderr)
726
948
  return 1
727
- files, symbols, relations, diagnostics = _load_tuple(selected)
728
949
  findings = []
729
- findings.extend(audit_stale(root, files))
730
- findings.extend(audit_relations(symbols, relations))
950
+ level = getattr(args, "level", "publish")
951
+ if level == "full":
952
+ files, symbols, relations, diagnostics = _load_tuple(selected)
953
+ findings.extend(audit_stale(root, files))
954
+ findings.extend(audit_relations(symbols, relations))
955
+ else:
956
+ # The publish audit is intentionally bounded and SQL-backed. A
957
+ # full stale/content audit remains available explicitly via
958
+ # --level full and should not block initialization publication.
959
+ try:
960
+ conn = sqlite3.connect(str(selected / "index.sqlite"))
961
+ try:
962
+ tables = _table_names(conn)
963
+ if {"relations", "symbols"}.issubset(tables):
964
+ row = conn.execute("""
965
+ SELECT COUNT(*) FROM relations r
966
+ LEFT JOIN symbols s ON s.id=r.from_id
967
+ WHERE s.id IS NULL
968
+ """).fetchone()
969
+ if row and row[0]:
970
+ findings.append({"severity": "error", "kind": "dangling_relation", "message": f"{row[0]} relations have missing source symbols"})
971
+ row = conn.execute("""
972
+ SELECT COUNT(*) FROM relations r
973
+ LEFT JOIN symbols s ON s.id=r.to_id
974
+ WHERE r.to_id IS NOT NULL AND s.id IS NULL AND (r.to_external IS NULL OR r.to_external='')
975
+ """).fetchone()
976
+ if row and row[0]:
977
+ findings.append({"severity": "error", "kind": "dangling_relation", "message": f"{row[0]} relations have missing target symbols"})
978
+ finally:
979
+ conn.close()
980
+ except sqlite3.Error as exc:
981
+ findings.append({"severity": "error", "kind": "sqlite_error", "message": str(exc)})
731
982
  skill_root = Path(args.skill_root)
732
983
  if skill_root.exists():
733
984
  findings.extend(audit_project_binding([skill_root / "workflow-code-index", skill_root / "code-index-query"]))
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import fnmatch
2
3
  import subprocess
3
4
  from pathlib import Path
4
5
  from typing import Iterable, List, Sequence
@@ -22,6 +23,7 @@ DEFAULT_EXCLUDES = {
22
23
  ".dart_tool",
23
24
  "__pycache__",
24
25
  "Pods",
26
+ "server/public",
25
27
  }
26
28
 
27
29
  TEXT_EXTENSIONS = {
@@ -57,7 +59,7 @@ TEXT_EXTENSIONS = {
57
59
  }
58
60
 
59
61
 
60
- def scan_files(root: Path, excludes: Iterable[str] = (), max_bytes: int = 1_500_000) -> List[Path]:
62
+ def scan_files(root: Path, excludes: Iterable[str] = (), max_bytes: int = 1_500_000, includes: Iterable[str] = ()) -> List[Path]:
61
63
  root = root.resolve()
62
64
  excluded = DEFAULT_EXCLUDES | set(excludes)
63
65
  paths = _scan_with_git(root, excluded) or _scan_with_rg(root, excluded) or _scan_with_walk(root, excluded)
@@ -65,6 +67,9 @@ def scan_files(root: Path, excludes: Iterable[str] = (), max_bytes: int = 1_500_
65
67
  for path in sorted(paths):
66
68
  if not path.is_file():
67
69
  continue
70
+ relative = path.relative_to(root).as_posix()
71
+ if includes and not any(fnmatch.fnmatch(relative, pattern) or (pattern.startswith("**/") and fnmatch.fnmatch(relative, pattern[3:])) for pattern in includes):
72
+ continue
68
73
  if _is_excluded(path, root, excluded):
69
74
  continue
70
75
  if path.suffix and path.suffix.lower() not in TEXT_EXTENSIONS:
@@ -130,5 +135,8 @@ def _is_excluded(path: Path, root: Path, excluded: Sequence[str]) -> bool:
130
135
  parts = path.resolve().relative_to(root).parts
131
136
  except ValueError:
132
137
  return True
133
- return any(part in excluded for part in parts)
138
+ relative = "/".join(parts)
139
+ if any(fnmatch.fnmatch(relative, pattern) or fnmatch.fnmatch(relative + "/", pattern.rstrip("/") + "/") for pattern in excluded):
140
+ return True
141
+ return any(part in excluded for part in parts) or any("/" in pattern and ("/" + "/".join(parts) + "/").find("/" + pattern.strip("/") + "/") >= 0 for pattern in excluded)
134
142