@guanlin4924/devflow-pi 0.1.78 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.78",
3
+ "version": "0.1.79",
4
4
  "type": "module",
5
5
  "description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
6
6
  "license": "UNLICENSED",
@@ -53,9 +53,11 @@ export default function devflowIntel(pi: ExtensionAPI) {
53
53
  label: "Devflow Code Index",
54
54
  description: "Scan, validate, audit, or query the deterministic project code index.",
55
55
  parameters: z.object({
56
- action: z.enum(["scan", "validate", "audit", "query", "relations"]),
56
+ action: z.enum(["scan", "validate", "audit", "query", "relations", "impact"]),
57
57
  term: z.string().optional(),
58
58
  object: z.string().optional(),
59
+ entity: z.string().optional(),
60
+ changeType: z.enum(["remove_field", "rename_field", "change_type", "optional_to_required", "required_to_optional", "change_nullability", "change_enum"]).optional(),
59
61
  snapshot: z.string().optional(),
60
62
  depth: z.number().int().min(0).max(20).default(3),
61
63
  includeHeuristic: z.boolean().default(false),
@@ -76,8 +78,10 @@ export default function devflowIntel(pi: ExtensionAPI) {
76
78
  if (params.changedFiles.length > 0) args.push("--changed-files", ...params.changedFiles);
77
79
  } else if (params.action === "validate") {
78
80
  args.push("--index", ".planning/code-index");
81
+ if (params.snapshot) args.push("--snapshot", params.snapshot);
79
82
  } else if (params.action === "audit") {
80
83
  args.push("--root", ctx.cwd, "--index", ".planning/code-index", "--skill-root", skillRoot);
84
+ if (params.snapshot) args.push("--snapshot", params.snapshot);
81
85
  } else if (params.action === "query") {
82
86
  if (!params.term) {
83
87
  return render({ ok: false, exitCode: 2, stdout: "", stderr: "term is required" }, {});
@@ -85,6 +89,13 @@ export default function devflowIntel(pi: ExtensionAPI) {
85
89
  args.push("--index", ".planning/code-index", "--term", params.term, "--limit", String(params.limit));
86
90
  if (params.snapshot) args.push("--snapshot", params.snapshot);
87
91
  } else {
92
+ if (params.action === "impact") {
93
+ if (!params.entity) return render({ ok: false, exitCode: 2, stdout: "", stderr: "entity is required" }, {});
94
+ args.push("--index", ".planning/code-index", "--entity", params.entity, "--depth", String(params.depth), "--limit", String(params.limit));
95
+ if (params.snapshot) args.push("--snapshot", params.snapshot);
96
+ if (params.changeType) args.push("--change-type", params.changeType);
97
+ return render(await run(args, ctx.cwd), { action: params.action });
98
+ }
88
99
  if (!params.object) return render({ ok: false, exitCode: 2, stdout: "", stderr: "object is required" }, {});
89
100
  args.push("--index", ".planning/code-index", "--object", params.object, "--depth", String(params.depth), "--limit", String(params.limit));
90
101
  if (params.snapshot) args.push("--snapshot", params.snapshot);
@@ -32,6 +32,14 @@ devflow_code_index(action="scan")
32
32
  devflow_code_index(action="audit")
33
33
  ```
34
34
 
35
+ Impact analysis uses the same immutable SQLite snapshot:
36
+
37
+ ```text
38
+ devflow_code_index(action="impact", entity="{entity_id}", changeType="remove_field")
39
+ ```
40
+
41
+ The result carries `_meta.snapshot`, coverage, and truncation status. Older structural-only snapshots return an explicit semantic capability-unavailable result; they remain valid for symbol and relation queries.
42
+
35
43
  ## Query Patterns
36
44
 
37
45
  For more detailed query patterns, read `references/query-patterns.md`.
@@ -44,6 +44,7 @@ Use the OMP runtime tool:
44
44
  devflow_code_index(action="scan")
45
45
  devflow_code_index(action="validate")
46
46
  devflow_code_index(action="audit")
47
+ devflow_code_index(action="impact", entity="api:orders", changeType="remove_field")
47
48
  ```
48
49
 
49
50
  For changed-file indexing:
@@ -66,3 +67,7 @@ Blocking findings are stale file hashes, missing indexed files, dangling `to_id`
66
67
  - Do not mark dynamic or string-based matches as `exact`.
67
68
  - If adapter support is missing, emit diagnostics and fall back to generic indexing.
68
69
  - Query answers must read source files at evidence paths before making factual claims.
70
+ - The root `SNAPSHOT.json` is only a pointer. Queries, validation, audit, and impact resolve it to the immutable `snapshots/{generation}` directory before reading facts.
71
+ - Query results include snapshot and parser provenance. A historical snapshot may be queried with `snapshot`; it is not freshness-checked against the current worktree.
72
+ - `impact` is a typed semantic query. It returns `unavailable` with `semantic_capability_unavailable` when an older structural-only snapshot has no semantic tables; it never executes arbitrary SQL.
73
+ - Query limits are bounded by the runtime. Negative or oversized limits are normalized safely.
@@ -65,7 +65,17 @@ def _publish_snapshot(staging: Path, output: Path, counts: Dict[str, int], works
65
65
  generation = generation or str(uuid.uuid4())
66
66
  immutable_relative = f"snapshots/{generation}"
67
67
  snapshot = {
68
+ # Keep the public snapshot schema stable while exposing the richer
69
+ # SQLite schema separately. Older consumers only know schemaVersion=1.
68
70
  "schemaVersion": 1,
71
+ "indexSchemaVersion": 3,
72
+ "capabilities": {
73
+ "structural": True,
74
+ "semantic": True,
75
+ "fts": True,
76
+ "relations": True,
77
+ "provenance": True,
78
+ },
69
79
  "generation": generation,
70
80
  "generatedAt": datetime.now(timezone.utc).isoformat(),
71
81
  "counts": counts,
@@ -189,11 +199,13 @@ def main(argv=None) -> int:
189
199
 
190
200
  validate = sub.add_parser("validate", help="Validate JSONL schema.")
191
201
  validate.add_argument("--index", default=".planning/code-index", help="Index directory.")
202
+ validate.add_argument("--snapshot", help="Immutable generation id.")
192
203
 
193
204
  audit = sub.add_parser("audit", help="Audit stale files and relation integrity.")
194
205
  audit.add_argument("--root", default=".", help="Project root.")
195
206
  audit.add_argument("--index", default=".planning/code-index", help="Index directory.")
196
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.")
197
209
 
198
210
  query = sub.add_parser("query", help="Search symbols using SQLite FTS or LIKE fallback.")
199
211
  query.add_argument("--index", default=".planning/code-index", help="Index directory.")
@@ -335,6 +347,12 @@ def command_scan(args) -> int:
335
347
  registry_capabilities() if registry_capabilities else {},
336
348
  )
337
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
+ }
338
356
  workspace_state["semanticExtractor"] = SEMANTIC_EXTRACTOR_VERSION
339
357
  workspace_state["extractorSetHash"] = _hash_lines([*workspace_state.get("extractors", []), SEMANTIC_EXTRACTOR_VERSION])
340
358
  progress.emit("semantic", "building semantic facts")
@@ -389,19 +407,27 @@ def command_scan(args) -> int:
389
407
 
390
408
  def command_validate(args) -> int:
391
409
  output = Path(args.index).resolve()
392
- selected = _selected_generation(output, None)
410
+ selected = _selected_generation(output, getattr(args, "snapshot", None))
393
411
  errors = []
394
412
  try:
395
413
  snapshot = _load_snapshot(selected)
414
+ errors.extend(_validate_snapshot_bundle(selected, snapshot))
396
415
  files, symbols, relations, diagnostics = _load_tuple(selected)
397
416
  errors.extend(validate_index(files, symbols, relations, diagnostics))
398
417
  conn = sqlite3.connect(str(selected / "index.sqlite"))
399
418
  try:
400
419
  snapshot_id = snapshot.get("generation")
401
- row = conn.execute("SELECT snapshot_id FROM analysis_snapshots WHERE snapshot_id=?", (snapshot_id,)).fetchone()
402
- if not row:
403
- errors.append(f"semantic schema missing snapshot {snapshot_id}")
404
- 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("""
405
431
  SELECT COUNT(*) FROM semantic_flows f
406
432
  WHERE f.snapshot_id=? AND (
407
433
  (NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=f.snapshot_id AND e.logical_id=f.source_id)
@@ -410,18 +436,18 @@ def command_validate(args) -> int:
410
436
  (NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=f.snapshot_id AND e.logical_id=f.target_id)
411
437
  AND NOT EXISTS (SELECT 1 FROM symbols s WHERE s.id=f.target_id))
412
438
  )
413
- """, (snapshot_id,)).fetchone()[0]
414
- if dangling:
415
- errors.append(f"semantic flows have {dangling} dangling source or target nodes")
416
- 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("""
417
443
  SELECT COUNT(*) FROM feature_bindings b
418
444
  WHERE b.snapshot_id=? AND (
419
445
  NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=b.snapshot_id AND e.logical_id=b.feature_id)
420
446
  OR NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=b.snapshot_id AND e.logical_id=b.entity_id)
421
447
  )
422
- """, (snapshot_id,)).fetchone()[0]
423
- if binding_dangling:
424
- 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")
425
451
  finally:
426
452
  conn.close()
427
453
  except (OSError, sqlite3.Error, ValueError) as exc:
@@ -438,6 +464,11 @@ def command_query(args) -> int:
438
464
  print(f"Missing index: {index}", file=sys.stderr)
439
465
  return 1
440
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)
441
472
  files = read_jsonl(selected / INDEX_FILES["files"])
442
473
  if not getattr(args, "snapshot", None) and not _query_is_fresh(output.parent.parent, snapshot, files):
443
474
  print("Stale code index: refresh required before query.", file=sys.stderr)
@@ -445,30 +476,38 @@ def command_query(args) -> int:
445
476
  conn = sqlite3.connect(str(index))
446
477
  conn.row_factory = sqlite3.Row
447
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"
448
481
  try:
449
482
  rows = conn.execute(
450
- """
451
- 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:]}
452
486
  FROM symbols_fts
453
487
  JOIN symbols USING(id)
454
488
  WHERE symbols_fts MATCH ?
455
489
  LIMIT ?
456
490
  """,
457
- (args.term, args.limit),
491
+ (args.term, limit),
458
492
  ).fetchall()
459
493
  except sqlite3.OperationalError:
460
494
  like = f"%{args.term}%"
461
495
  rows = conn.execute(
462
- """
463
- 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:]}
464
499
  FROM symbols
465
500
  WHERE name LIKE ? OR qualified_name LIKE ? OR path LIKE ?
466
501
  LIMIT ?
467
502
  """,
468
- (like, like, like, args.limit),
503
+ (like, like, like, limit),
469
504
  ).fetchall()
470
505
  for row in rows:
471
- 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))
472
511
  finally:
473
512
  conn.close()
474
513
  return 0
@@ -476,14 +515,22 @@ def command_query(args) -> int:
476
515
 
477
516
  def command_relations(args) -> int:
478
517
  output = Path(args.index).resolve()
479
- 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)
480
519
  with _index_lock(output, exclusive=False):
481
520
  selected = _selected_generation(output, getattr(args, "snapshot", None))
482
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
483
525
  if not getattr(args, "snapshot", None) and not _query_is_fresh(output.parent.parent, snapshot, files):
484
526
  print("Stale code index: refresh required before query.", file=sys.stderr); return 1
485
527
  conn = sqlite3.connect(str(selected / "index.sqlite")); conn.row_factory = sqlite3.Row
486
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"
487
534
  if depth == 0:
488
535
  print(json.dumps({"_meta": {"snapshot": snapshot.get("generation"), "depthLimit": 0, "resultLimit": limit, "truncated": False}}, ensure_ascii=False, sort_keys=True))
489
536
  return 0
@@ -496,14 +543,21 @@ def command_relations(args) -> int:
496
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,'') || ','
497
544
  FROM graph g JOIN relations r ON r.from_id=g.to_id
498
545
  WHERE g.depth < ? AND r.to_id IS NOT NULL {confidence} AND instr(g.visited, ',' || r.to_id || ',')=0
499
- ) 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 ?
500
553
  """, (args.object, depth, limit + 1)).fetchall()
501
554
  truncated = len(rows) > limit
502
555
  for row in rows[:limit]:
503
556
  item = dict(row)
557
+ item["snapshot"] = snapshot.get("generation")
504
558
  if getattr(args, "change_type", None):
505
559
  item["impact_kind"] = args.change_type
506
- item["severity"] = _impact_severity(args.change_type, item["flow_type"])
560
+ item["severity"] = _impact_severity(args.change_type, item.get("flow_type", "relation"))
507
561
  print(json.dumps(item, ensure_ascii=False, sort_keys=True))
508
562
  print(json.dumps({"_meta": {"snapshot": snapshot.get("generation"), "depthLimit": depth, "resultLimit": limit, "truncated": truncated}}, ensure_ascii=False, sort_keys=True))
509
563
  finally: conn.close()
@@ -523,10 +577,13 @@ def _impact_severity(change_type: str, flow_type: str) -> str:
523
577
 
524
578
  def command_impact(args) -> int:
525
579
  output = Path(args.index).resolve()
526
- 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)
527
581
  with _index_lock(output, exclusive=False):
528
582
  selected = _selected_generation(output, getattr(args, "snapshot", None))
529
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
530
587
  if not getattr(args, "snapshot", None):
531
588
  files = read_jsonl(selected / INDEX_FILES["files"])
532
589
  if not _query_is_fresh(output.parent.parent, snapshot, files):
@@ -534,6 +591,11 @@ def command_impact(args) -> int:
534
591
  return 1
535
592
  conn = sqlite3.connect(str(selected / "index.sqlite")); conn.row_factory = sqlite3.Row
536
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
537
599
  rows = conn.execute("""
538
600
  WITH RECURSIVE graph(node_id, flow_type, source_path, line_start, confidence, depth, visited) AS (
539
601
  SELECT CASE WHEN source_id=? THEN target_id ELSE source_id END, flow_type, source_path, line_start, confidence, 1, ',' || ? || ','
@@ -545,7 +607,12 @@ def command_impact(args) -> int:
545
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
546
608
  ) SELECT * FROM graph LIMIT ?
547
609
  """, (args.entity, args.entity, snapshot.get("generation"), args.entity, args.entity, snapshot.get("generation"), depth, limit + 1)).fetchall()
548
- 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))
549
616
  coverage_row = conn.execute("SELECT * FROM coverage_metrics WHERE snapshot_id=?", (snapshot.get("generation"),)).fetchone()
550
617
  coverage = dict(coverage_row) if coverage_row else {"status": "unknown"}
551
618
  print(json.dumps({"_meta": {"snapshot": snapshot.get("generation"), "coverage": coverage, "depthLimit": depth, "truncated": len(rows) > limit}}, ensure_ascii=False, sort_keys=True))
@@ -556,6 +623,13 @@ def command_impact(args) -> int:
556
623
 
557
624
  def _selected_generation(output: Path, generation: str | None) -> Path:
558
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.
559
633
  return output
560
634
  if not re.fullmatch(r"[0-9a-f-]{36}", generation, re.I):
561
635
  raise ValueError("invalid snapshot generation")
@@ -573,6 +647,44 @@ def _load_snapshot(output: Path) -> Dict[str, object]:
573
647
  return json.loads(path.read_text(encoding="utf-8"))
574
648
 
575
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
+
576
688
  def _query_is_fresh(root: Path, snapshot: Dict[str, object], files: List[dict]) -> bool:
577
689
  workspace = snapshot.get("workspace") if isinstance(snapshot, dict) else None
578
690
  if not isinstance(workspace, dict):
@@ -591,7 +703,9 @@ def _query_is_fresh(root: Path, snapshot: Dict[str, object], files: List[dict])
591
703
  return False
592
704
  indexed_paths = sorted(record["path"] for record in files)
593
705
  if not any(path.startswith("../") for path in indexed_paths):
594
- 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))
595
709
  expected_paths_hash = workspace.get("discoveredPathsHash") or workspace.get("indexedPathsHash")
596
710
  if _hash_lines(current_paths) != expected_paths_hash:
597
711
  return False
@@ -603,7 +717,14 @@ def command_audit(args) -> int:
603
717
  root = Path(args.root).resolve()
604
718
  output = Path(args.index).resolve()
605
719
  with _index_lock(output, exclusive=True):
606
- 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)
607
728
  findings = []
608
729
  findings.extend(audit_stale(root, files))
609
730
  findings.extend(audit_relations(symbols, relations))
package/src/constants.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PRODUCT_NAME = "Devflow Pi";
2
- export const PRODUCT_VERSION = "0.1.78";
2
+ export const PRODUCT_VERSION = "0.1.79";
3
3
  export const PROJECT_SCHEMA_VERSION = 1;
4
4
  export const OMP_RUNTIME_VERSION = "17.2.7";
5
5