@guanlin4924/devflow-pi 0.1.78 → 0.1.80

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.80",
4
4
  "type": "module",
5
5
  "description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
6
6
  "license": "UNLICENSED",
@@ -53,14 +53,17 @@ 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),
62
64
  limit: z.number().int().min(1).max(100).default(20),
63
65
  changedFiles: z.array(z.string()).default([]),
66
+ validationLevel: z.enum(["fast", "publish", "full"]).default("publish"),
64
67
  }),
65
68
  approval: (args) => {
66
69
  const action = (args as { action?: string }).action;
@@ -76,8 +79,12 @@ export default function devflowIntel(pi: ExtensionAPI) {
76
79
  if (params.changedFiles.length > 0) args.push("--changed-files", ...params.changedFiles);
77
80
  } else if (params.action === "validate") {
78
81
  args.push("--index", ".planning/code-index");
82
+ args.push("--level", params.validationLevel);
83
+ if (params.snapshot) args.push("--snapshot", params.snapshot);
79
84
  } else if (params.action === "audit") {
80
85
  args.push("--root", ctx.cwd, "--index", ".planning/code-index", "--skill-root", skillRoot);
86
+ args.push("--level", params.validationLevel === "fast" ? "publish" : params.validationLevel);
87
+ if (params.snapshot) args.push("--snapshot", params.snapshot);
81
88
  } else if (params.action === "query") {
82
89
  if (!params.term) {
83
90
  return render({ ok: false, exitCode: 2, stdout: "", stderr: "term is required" }, {});
@@ -85,6 +92,13 @@ export default function devflowIntel(pi: ExtensionAPI) {
85
92
  args.push("--index", ".planning/code-index", "--term", params.term, "--limit", String(params.limit));
86
93
  if (params.snapshot) args.push("--snapshot", params.snapshot);
87
94
  } else {
95
+ if (params.action === "impact") {
96
+ if (!params.entity) return render({ ok: false, exitCode: 2, stdout: "", stderr: "entity is required" }, {});
97
+ args.push("--index", ".planning/code-index", "--entity", params.entity, "--depth", String(params.depth), "--limit", String(params.limit));
98
+ if (params.snapshot) args.push("--snapshot", params.snapshot);
99
+ if (params.changeType) args.push("--change-type", params.changeType);
100
+ return render(await run(args, ctx.cwd), { action: params.action });
101
+ }
88
102
  if (!params.object) return render({ ok: false, exitCode: 2, stdout: "", stderr: "object is required" }, {});
89
103
  args.push("--index", ".planning/code-index", "--object", params.object, "--depth", String(params.depth), "--limit", String(params.limit));
90
104
  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,8 +44,23 @@ 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
 
50
+ Validation is staged so large indexes do not block publication on a full
51
+ sidecar audit:
52
+
53
+ - `validationLevel="fast"` checks the snapshot bundle and generation metadata.
54
+ - `validationLevel="publish"` (the default) adds SQLite quick-checks and
55
+ SQL-backed symbol/relation referential checks. It is the initialization
56
+ publish gate and does not load every JSONL sidecar into memory.
57
+ - `validationLevel="full"` additionally validates every JSONL record and the
58
+ semantic graph. Run it as an explicit audit when complete verification is
59
+ required.
60
+
61
+ The same split is available to `audit`: the default publish audit is bounded;
62
+ `validationLevel="full"` performs stale-file and full relation auditing.
63
+
49
64
  For changed-file indexing:
50
65
 
51
66
  ```text
@@ -66,3 +81,7 @@ Blocking findings are stale file hashes, missing indexed files, dangling `to_id`
66
81
  - Do not mark dynamic or string-based matches as `exact`.
67
82
  - If adapter support is missing, emit diagnostics and fall back to generic indexing.
68
83
  - Query answers must read source files at evidence paths before making factual claims.
84
+ - The root `SNAPSHOT.json` is only a pointer. Queries, validation, audit, and impact resolve it to the immutable `snapshots/{generation}` directory before reading facts.
85
+ - Query results include snapshot and parser provenance. A historical snapshot may be queried with `snapshot`; it is not freshness-checked against the current worktree.
86
+ - `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.
87
+ - 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,
@@ -187,13 +197,22 @@ def main(argv=None) -> int:
187
197
  scan.add_argument("--changed-files", nargs="*", default=[], help="Optional changed files for incremental refresh.")
188
198
  scan.add_argument("--exclude", action="append", default=[], help="Additional directory names to exclude.")
189
199
 
190
- validate = sub.add_parser("validate", help="Validate JSONL schema.")
200
+ validate = sub.add_parser("validate", help="Validate an index in staged levels.")
191
201
  validate.add_argument("--index", default=".planning/code-index", help="Index directory.")
202
+ validate.add_argument("--snapshot", help="Immutable generation id.")
203
+ validate.add_argument(
204
+ "--level",
205
+ choices=("fast", "publish", "full"),
206
+ default="publish",
207
+ help="fast bundle checks, publish gate checks, or full JSONL/semantic audit",
208
+ )
192
209
 
193
210
  audit = sub.add_parser("audit", help="Audit stale files and relation integrity.")
194
211
  audit.add_argument("--root", default=".", help="Project root.")
195
212
  audit.add_argument("--index", default=".planning/code-index", help="Index directory.")
196
213
  audit.add_argument("--skill-root", default=".skills", help="Optional skill root for project-binding checks.")
214
+ audit.add_argument("--snapshot", help="Immutable generation id.")
215
+ audit.add_argument("--level", choices=("publish", "full"), default="publish")
197
216
 
198
217
  query = sub.add_parser("query", help="Search symbols using SQLite FTS or LIKE fallback.")
199
218
  query.add_argument("--index", default=".planning/code-index", help="Index directory.")
@@ -335,6 +354,12 @@ def command_scan(args) -> int:
335
354
  registry_capabilities() if registry_capabilities else {},
336
355
  )
337
356
  workspace_state["generatedAt"] = datetime.now(timezone.utc).isoformat()
357
+ workspace_state["scanScope"] = {
358
+ "exclude": sorted(set(args.exclude)),
359
+ "codebases": [
360
+ {"name": item.name, "path": str(item.root)} for item in codebases
361
+ ],
362
+ }
338
363
  workspace_state["semanticExtractor"] = SEMANTIC_EXTRACTOR_VERSION
339
364
  workspace_state["extractorSetHash"] = _hash_lines([*workspace_state.get("extractors", []), SEMANTIC_EXTRACTOR_VERSION])
340
365
  progress.emit("semantic", "building semantic facts")
@@ -387,47 +412,114 @@ def command_scan(args) -> int:
387
412
  return 0
388
413
 
389
414
 
390
- def command_validate(args) -> int:
391
- output = Path(args.index).resolve()
392
- selected = _selected_generation(output, None)
393
- errors = []
415
+ def _validate_publish_gate(selected: Path, snapshot: Dict[str, object]) -> List[str]:
416
+ """Run bounded checks required before publishing a generation.
417
+
418
+ This deliberately uses SQLite for referential checks. The previous
419
+ implementation loaded every JSONL sidecar before it could decide whether
420
+ a snapshot was publishable, which made large projects spend minutes at
421
+ 100% CPU in a validation process.
422
+ """
423
+ errors = _validate_snapshot_bundle(selected, snapshot)
424
+ if errors:
425
+ return errors
426
+ index = selected / "index.sqlite"
394
427
  try:
395
- snapshot = _load_snapshot(selected)
396
- files, symbols, relations, diagnostics = _load_tuple(selected)
397
- errors.extend(validate_index(files, symbols, relations, diagnostics))
398
- conn = sqlite3.connect(str(selected / "index.sqlite"))
428
+ conn = sqlite3.connect(str(index))
399
429
  try:
430
+ quick = conn.execute("PRAGMA quick_check").fetchone()
431
+ if not quick or quick[0] != "ok":
432
+ errors.append(f"sqlite quick_check failed: {quick[0] if quick else 'no result'}")
400
433
  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("""
405
- SELECT COUNT(*) FROM semantic_flows f
406
- WHERE f.snapshot_id=? AND (
407
- (NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=f.snapshot_id AND e.logical_id=f.source_id)
408
- AND NOT EXISTS (SELECT 1 FROM symbols s WHERE s.id=f.source_id))
409
- OR
410
- (NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=f.snapshot_id AND e.logical_id=f.target_id)
411
- AND NOT EXISTS (SELECT 1 FROM symbols s WHERE s.id=f.target_id))
412
- )
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("""
417
- SELECT COUNT(*) FROM feature_bindings b
418
- WHERE b.snapshot_id=? AND (
419
- NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=b.snapshot_id AND e.logical_id=b.feature_id)
420
- OR NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=b.snapshot_id AND e.logical_id=b.entity_id)
421
- )
422
- """, (snapshot_id,)).fetchone()[0]
423
- if binding_dangling:
424
- errors.append(f"feature bindings have {binding_dangling} dangling feature or entity nodes")
434
+ tables = _table_names(conn)
435
+ required = {"analysis_snapshots", "code_files", "symbols", "relations", "diagnostics"}
436
+ missing = sorted(required - tables)
437
+ if missing:
438
+ errors.append(f"index missing required tables: {', '.join(missing)}")
439
+ elif not conn.execute("SELECT 1 FROM analysis_snapshots WHERE snapshot_id=? LIMIT 1", (snapshot_id,)).fetchone():
440
+ errors.append(f"snapshot metadata missing generation {snapshot_id}")
441
+
442
+ if {"relations", "symbols"}.issubset(tables):
443
+ dangling = conn.execute("""
444
+ SELECT COUNT(*) FROM relations r
445
+ LEFT JOIN symbols s ON s.id=r.from_id
446
+ WHERE s.id IS NULL
447
+ """).fetchone()[0]
448
+ if dangling:
449
+ errors.append(f"relations have {dangling} missing source symbols")
450
+ dangling = conn.execute("""
451
+ SELECT COUNT(*) FROM relations r
452
+ LEFT JOIN symbols s ON s.id=r.to_id
453
+ WHERE r.to_id IS NOT NULL AND s.id IS NULL AND (r.to_external IS NULL OR r.to_external='')
454
+ """).fetchone()[0]
455
+ if dangling:
456
+ errors.append(f"relations have {dangling} missing target symbols")
425
457
  finally:
426
458
  conn.close()
459
+ except (OSError, sqlite3.Error) as exc:
460
+ errors.append(str(exc))
461
+ return errors
462
+
463
+
464
+ def _validate_semantic_graph(conn: sqlite3.Connection, snapshot_id: str, tables: set[str]) -> List[str]:
465
+ errors: List[str] = []
466
+ semantic_tables = {"semantic_flows", "entity_versions", "feature_bindings", "coverage_metrics"}
467
+ if not semantic_tables.issubset(tables):
468
+ missing = ", ".join(sorted(semantic_tables - tables))
469
+ errors.append(f"full validation unavailable: missing semantic tables: {missing}")
470
+ return errors
471
+ dangling = conn.execute("""
472
+ SELECT COUNT(*) FROM semantic_flows f
473
+ WHERE f.snapshot_id=? AND (
474
+ (NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=f.snapshot_id AND e.logical_id=f.source_id)
475
+ AND NOT EXISTS (SELECT 1 FROM symbols s WHERE s.id=f.source_id))
476
+ OR
477
+ (NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=f.snapshot_id AND e.logical_id=f.target_id)
478
+ AND NOT EXISTS (SELECT 1 FROM symbols s WHERE s.id=f.target_id))
479
+ )
480
+ """, (snapshot_id,)).fetchone()[0]
481
+ if dangling:
482
+ errors.append(f"semantic flows have {dangling} dangling source or target nodes")
483
+ binding_dangling = conn.execute("""
484
+ SELECT COUNT(*) FROM feature_bindings b
485
+ WHERE b.snapshot_id=? AND (
486
+ NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=b.snapshot_id AND e.logical_id=b.feature_id)
487
+ OR NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=b.snapshot_id AND e.logical_id=b.entity_id)
488
+ )
489
+ """, (snapshot_id,)).fetchone()[0]
490
+ if binding_dangling:
491
+ errors.append(f"feature bindings have {binding_dangling} dangling feature or entity nodes")
492
+ return errors
493
+
494
+
495
+ def command_validate(args) -> int:
496
+ output = Path(args.index).resolve()
497
+ selected = _selected_generation(output, getattr(args, "snapshot", None))
498
+ level = getattr(args, "level", "publish")
499
+ errors: List[str] = []
500
+ try:
501
+ snapshot = _load_snapshot(selected)
502
+ print(f"[code-index][validate] level={level} snapshot={snapshot.get('generation', 'unknown')}", file=sys.stderr, flush=True)
503
+ if level == "fast":
504
+ errors.extend(_validate_snapshot_bundle(selected, snapshot))
505
+ else:
506
+ errors.extend(_validate_publish_gate(selected, snapshot))
507
+ if level == "full" and not errors:
508
+ print("[code-index][validate] full JSONL schema validation", file=sys.stderr, flush=True)
509
+ files, symbols, relations, diagnostics = _load_tuple(selected)
510
+ errors.extend(validate_index(files, symbols, relations, diagnostics))
511
+ if not errors:
512
+ conn = sqlite3.connect(str(selected / "index.sqlite"))
513
+ try:
514
+ errors.extend(_validate_semantic_graph(conn, str(snapshot.get("generation")), _table_names(conn)))
515
+ finally:
516
+ conn.close()
427
517
  except (OSError, sqlite3.Error, ValueError) as exc:
428
518
  errors.append(str(exc))
429
519
  for error in errors:
430
520
  print(error, file=sys.stderr)
521
+ if not errors:
522
+ print(f"Index validation passed ({level}).")
431
523
  return 1 if errors else 0
432
524
  def command_query(args) -> int:
433
525
  output = Path(args.index).resolve()
@@ -438,6 +530,11 @@ def command_query(args) -> int:
438
530
  print(f"Missing index: {index}", file=sys.stderr)
439
531
  return 1
440
532
  snapshot = _load_snapshot(selected)
533
+ bundle_errors = _validate_snapshot_bundle(selected, snapshot)
534
+ if bundle_errors:
535
+ print(bundle_errors[0], file=sys.stderr)
536
+ return 1
537
+ limit = _bounded_limit(getattr(args, "limit", 20), 1000)
441
538
  files = read_jsonl(selected / INDEX_FILES["files"])
442
539
  if not getattr(args, "snapshot", None) and not _query_is_fresh(output.parent.parent, snapshot, files):
443
540
  print("Stale code index: refresh required before query.", file=sys.stderr)
@@ -445,30 +542,38 @@ def command_query(args) -> int:
445
542
  conn = sqlite3.connect(str(index))
446
543
  conn.row_factory = sqlite3.Row
447
544
  try:
545
+ symbol_columns = {row[1] for row in conn.execute("PRAGMA table_info(symbols)")}
546
+ 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
547
  try:
449
548
  rows = conn.execute(
450
- """
451
- SELECT id, codebase, kind, name, qualified_name, path, line_start, line_end
549
+ f"""
550
+ SELECT id, codebase, kind, name, qualified_name, path, line_start, line_end,
551
+ {provenance[2:]}
452
552
  FROM symbols_fts
453
553
  JOIN symbols USING(id)
454
554
  WHERE symbols_fts MATCH ?
455
555
  LIMIT ?
456
556
  """,
457
- (args.term, args.limit),
557
+ (args.term, limit),
458
558
  ).fetchall()
459
559
  except sqlite3.OperationalError:
460
560
  like = f"%{args.term}%"
461
561
  rows = conn.execute(
462
- """
463
- SELECT id, codebase, kind, name, qualified_name, path, line_start, line_end
562
+ f"""
563
+ SELECT id, codebase, kind, name, qualified_name, path, line_start, line_end,
564
+ {provenance[2:]}
464
565
  FROM symbols
465
566
  WHERE name LIKE ? OR qualified_name LIKE ? OR path LIKE ?
466
567
  LIMIT ?
467
568
  """,
468
- (like, like, like, args.limit),
569
+ (like, like, like, limit),
469
570
  ).fetchall()
470
571
  for row in rows:
471
- print(json.dumps(dict(row), ensure_ascii=False, sort_keys=True))
572
+ item = dict(row)
573
+ item["snapshot"] = snapshot.get("generation")
574
+ item["parser"] = item.get("parser") or "unknown"
575
+ item["confidence"] = item.get("confidence") or confidence_for_parser(item["parser"], "symbol")
576
+ print(json.dumps(item, ensure_ascii=False, sort_keys=True))
472
577
  finally:
473
578
  conn.close()
474
579
  return 0
@@ -476,14 +581,22 @@ def command_query(args) -> int:
476
581
 
477
582
  def command_relations(args) -> int:
478
583
  output = Path(args.index).resolve()
479
- depth = max(0, min(args.depth, 20)); limit = max(1, min(args.limit, 1000))
584
+ depth = max(0, min(args.depth, 20)); limit = _bounded_limit(args.limit, 1000)
480
585
  with _index_lock(output, exclusive=False):
481
586
  selected = _selected_generation(output, getattr(args, "snapshot", None))
482
587
  snapshot = _load_snapshot(selected); files = read_jsonl(selected / INDEX_FILES["files"])
588
+ bundle_errors = _validate_snapshot_bundle(selected, snapshot)
589
+ if bundle_errors:
590
+ print(bundle_errors[0], file=sys.stderr); return 1
483
591
  if not getattr(args, "snapshot", None) and not _query_is_fresh(output.parent.parent, snapshot, files):
484
592
  print("Stale code index: refresh required before query.", file=sys.stderr); return 1
485
593
  conn = sqlite3.connect(str(selected / "index.sqlite")); conn.row_factory = sqlite3.Row
486
594
  try:
595
+ symbol_columns = {row[1] for row in conn.execute("PRAGMA table_info(symbols)")}
596
+ parser_expr = "s.parser" if "parser" in symbol_columns else "NULL"
597
+ hash_expr = "s.content_hash" if "content_hash" in symbol_columns else "NULL"
598
+ to_parser_expr = "t.parser" if "parser" in symbol_columns else "NULL"
599
+ to_hash_expr = "t.content_hash" if "content_hash" in symbol_columns else "NULL"
487
600
  if depth == 0:
488
601
  print(json.dumps({"_meta": {"snapshot": snapshot.get("generation"), "depthLimit": 0, "resultLimit": limit, "truncated": False}}, ensure_ascii=False, sort_keys=True))
489
602
  return 0
@@ -496,14 +609,21 @@ def command_relations(args) -> int:
496
609
  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
610
  FROM graph g JOIN relations r ON r.from_id=g.to_id
498
611
  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 ?
612
+ ) SELECT graph.*, s.path AS from_path, s.name AS from_name, {parser_expr} AS from_parser,
613
+ {hash_expr} AS from_content_hash, t.path AS to_path, t.name AS to_name,
614
+ {to_parser_expr} AS to_parser, {to_hash_expr} AS to_content_hash
615
+ FROM graph
616
+ LEFT JOIN symbols s ON s.id=graph.from_id
617
+ LEFT JOIN symbols t ON t.id=graph.to_id
618
+ ORDER BY depth,from_id,relation_type,to_id LIMIT ?
500
619
  """, (args.object, depth, limit + 1)).fetchall()
501
620
  truncated = len(rows) > limit
502
621
  for row in rows[:limit]:
503
622
  item = dict(row)
623
+ item["snapshot"] = snapshot.get("generation")
504
624
  if getattr(args, "change_type", None):
505
625
  item["impact_kind"] = args.change_type
506
- item["severity"] = _impact_severity(args.change_type, item["flow_type"])
626
+ item["severity"] = _impact_severity(args.change_type, item.get("flow_type", "relation"))
507
627
  print(json.dumps(item, ensure_ascii=False, sort_keys=True))
508
628
  print(json.dumps({"_meta": {"snapshot": snapshot.get("generation"), "depthLimit": depth, "resultLimit": limit, "truncated": truncated}}, ensure_ascii=False, sort_keys=True))
509
629
  finally: conn.close()
@@ -523,10 +643,13 @@ def _impact_severity(change_type: str, flow_type: str) -> str:
523
643
 
524
644
  def command_impact(args) -> int:
525
645
  output = Path(args.index).resolve()
526
- depth = max(0, min(args.depth, 20)); limit = max(1, min(args.limit, 2000))
646
+ depth = max(0, min(args.depth, 20)); limit = _bounded_limit(args.limit, 2000)
527
647
  with _index_lock(output, exclusive=False):
528
648
  selected = _selected_generation(output, getattr(args, "snapshot", None))
529
649
  snapshot = _load_snapshot(selected)
650
+ bundle_errors = _validate_snapshot_bundle(selected, snapshot)
651
+ if bundle_errors:
652
+ print(bundle_errors[0], file=sys.stderr); return 1
530
653
  if not getattr(args, "snapshot", None):
531
654
  files = read_jsonl(selected / INDEX_FILES["files"])
532
655
  if not _query_is_fresh(output.parent.parent, snapshot, files):
@@ -534,6 +657,11 @@ def command_impact(args) -> int:
534
657
  return 1
535
658
  conn = sqlite3.connect(str(selected / "index.sqlite")); conn.row_factory = sqlite3.Row
536
659
  try:
660
+ tables = _table_names(conn)
661
+ semantic_tables = {"semantic_flows", "entity_versions", "feature_bindings", "coverage_metrics"}
662
+ if not semantic_tables.issubset(tables):
663
+ 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))
664
+ return 0
537
665
  rows = conn.execute("""
538
666
  WITH RECURSIVE graph(node_id, flow_type, source_path, line_start, confidence, depth, visited) AS (
539
667
  SELECT CASE WHEN source_id=? THEN target_id ELSE source_id END, flow_type, source_path, line_start, confidence, 1, ',' || ? || ','
@@ -545,7 +673,12 @@ def command_impact(args) -> int:
545
673
  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
674
  ) SELECT * FROM graph LIMIT ?
547
675
  """, (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))
676
+ for row in rows[:limit]:
677
+ item = dict(row); item["snapshot"] = snapshot.get("generation")
678
+ if getattr(args, "change_type", None):
679
+ item["impact_kind"] = args.change_type
680
+ item["severity"] = _impact_severity(args.change_type, item.get("flow_type", "relation"))
681
+ print(json.dumps(item, ensure_ascii=False, sort_keys=True))
549
682
  coverage_row = conn.execute("SELECT * FROM coverage_metrics WHERE snapshot_id=?", (snapshot.get("generation"),)).fetchone()
550
683
  coverage = dict(coverage_row) if coverage_row else {"status": "unknown"}
551
684
  print(json.dumps({"_meta": {"snapshot": snapshot.get("generation"), "coverage": coverage, "depthLimit": depth, "truncated": len(rows) > limit}}, ensure_ascii=False, sort_keys=True))
@@ -556,16 +689,39 @@ def command_impact(args) -> int:
556
689
 
557
690
  def _selected_generation(output: Path, generation: str | None) -> Path:
558
691
  if not generation:
692
+ root_snapshot = _load_snapshot(output)
693
+ current = root_snapshot.get("generation") if root_snapshot else None
694
+ if current is not None:
695
+ if not re.fullmatch(r"[0-9a-f-]{36}", str(current), re.I):
696
+ raise ValueError(f"invalid current snapshot generation: {current}")
697
+ current = str(current)
698
+ _validate_generation_pointer(output, root_snapshot, current)
699
+ selected = output / "snapshots" / current
700
+ if not selected.is_dir():
701
+ raise ValueError(f"current snapshot directory is missing: {selected}")
702
+ selected_snapshot = _load_snapshot(selected)
703
+ _validate_generation_pointer(selected, selected_snapshot, current)
704
+ return selected
705
+ # Legacy indexes predating immutable snapshots remain readable.
559
706
  return output
560
707
  if not re.fullmatch(r"[0-9a-f-]{36}", generation, re.I):
561
708
  raise ValueError("invalid snapshot generation")
562
709
  selected = output / "snapshots" / generation
563
710
  snapshot = _load_snapshot(selected)
564
- if snapshot.get("generation") != generation:
565
- raise ValueError(f"unknown snapshot generation: {generation}")
711
+ _validate_generation_pointer(selected, snapshot, generation)
566
712
  return selected
567
713
 
568
714
 
715
+ def _validate_generation_pointer(selected: Path, snapshot: Dict[str, object], expected: str) -> None:
716
+ if snapshot.get("generation") != expected:
717
+ raise ValueError(f"snapshot generation mismatch: expected={expected}, metadata={snapshot.get('generation')}")
718
+ expected_path = f"snapshots/{expected}"
719
+ if snapshot.get("immutablePath") != expected_path:
720
+ raise ValueError(
721
+ f"snapshot immutablePath mismatch: expected={expected_path}, metadata={snapshot.get('immutablePath')}"
722
+ )
723
+
724
+
569
725
  def _load_snapshot(output: Path) -> Dict[str, object]:
570
726
  path = output / SNAPSHOT_FILE
571
727
  if not path.exists():
@@ -573,6 +729,49 @@ def _load_snapshot(output: Path) -> Dict[str, object]:
573
729
  return json.loads(path.read_text(encoding="utf-8"))
574
730
 
575
731
 
732
+ def _bounded_limit(value, maximum: int) -> int:
733
+ try:
734
+ value = int(value)
735
+ except (TypeError, ValueError):
736
+ value = 20
737
+ return max(1, min(value, maximum))
738
+
739
+
740
+ def _table_names(conn: sqlite3.Connection) -> set[str]:
741
+ return {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type IN ('table','virtual table')")}
742
+
743
+
744
+ def _validate_snapshot_bundle(selected: Path, snapshot: Dict[str, object]) -> List[str]:
745
+ errors: List[str] = []
746
+ generation = snapshot.get("generation")
747
+ if not generation:
748
+ return [f"snapshot metadata missing generation in {selected}"]
749
+ if selected.parent.name == "snapshots":
750
+ try:
751
+ _validate_generation_pointer(selected, snapshot, selected.name)
752
+ except ValueError as exc:
753
+ errors.append(str(exc))
754
+ required = [SNAPSHOT_FILE, "index.sqlite", *INDEX_FILES.values()]
755
+ for name in required:
756
+ if not (selected / name).is_file():
757
+ errors.append(f"snapshot {generation} missing artifact {name}")
758
+ index = selected / "index.sqlite"
759
+ if index.is_file():
760
+ try:
761
+ conn = sqlite3.connect(str(index))
762
+ try:
763
+ tables = _table_names(conn)
764
+ if "analysis_snapshots" in tables:
765
+ row = conn.execute("SELECT snapshot_id FROM analysis_snapshots LIMIT 1").fetchone()
766
+ if row and row[0] != generation:
767
+ errors.append(f"snapshot generation mismatch: metadata={generation}, sqlite={row[0]}")
768
+ finally:
769
+ conn.close()
770
+ except sqlite3.Error as exc:
771
+ errors.append(f"invalid sqlite snapshot {generation}: {exc}")
772
+ return errors
773
+
774
+
576
775
  def _query_is_fresh(root: Path, snapshot: Dict[str, object], files: List[dict]) -> bool:
577
776
  workspace = snapshot.get("workspace") if isinstance(snapshot, dict) else None
578
777
  if not isinstance(workspace, dict):
@@ -591,7 +790,9 @@ def _query_is_fresh(root: Path, snapshot: Dict[str, object], files: List[dict])
591
790
  return False
592
791
  indexed_paths = sorted(record["path"] for record in files)
593
792
  if not any(path.startswith("../") for path in indexed_paths):
594
- current_paths = sorted(_workspace_rel(root, item) for item in scan_files(root))
793
+ scope = workspace.get("scanScope") if isinstance(workspace.get("scanScope"), dict) else {}
794
+ excludes = scope.get("exclude", []) if isinstance(scope, dict) else []
795
+ current_paths = sorted(_workspace_rel(root, item) for item in scan_files(root, excludes=excludes))
595
796
  expected_paths_hash = workspace.get("discoveredPathsHash") or workspace.get("indexedPathsHash")
596
797
  if _hash_lines(current_paths) != expected_paths_hash:
597
798
  return False
@@ -603,10 +804,46 @@ def command_audit(args) -> int:
603
804
  root = Path(args.root).resolve()
604
805
  output = Path(args.index).resolve()
605
806
  with _index_lock(output, exclusive=True):
606
- files, symbols, relations, diagnostics = _load_tuple(output)
807
+ selected = _selected_generation(output, getattr(args, "snapshot", None))
808
+ snapshot = _load_snapshot(selected)
809
+ bundle_errors = _validate_snapshot_bundle(selected, snapshot)
810
+ if bundle_errors:
811
+ for error in bundle_errors:
812
+ print(error, file=sys.stderr)
813
+ return 1
607
814
  findings = []
608
- findings.extend(audit_stale(root, files))
609
- findings.extend(audit_relations(symbols, relations))
815
+ level = getattr(args, "level", "publish")
816
+ if level == "full":
817
+ files, symbols, relations, diagnostics = _load_tuple(selected)
818
+ findings.extend(audit_stale(root, files))
819
+ findings.extend(audit_relations(symbols, relations))
820
+ else:
821
+ # The publish audit is intentionally bounded and SQL-backed. A
822
+ # full stale/content audit remains available explicitly via
823
+ # --level full and should not block initialization publication.
824
+ try:
825
+ conn = sqlite3.connect(str(selected / "index.sqlite"))
826
+ try:
827
+ tables = _table_names(conn)
828
+ if {"relations", "symbols"}.issubset(tables):
829
+ row = conn.execute("""
830
+ SELECT COUNT(*) FROM relations r
831
+ LEFT JOIN symbols s ON s.id=r.from_id
832
+ WHERE s.id IS NULL
833
+ """).fetchone()
834
+ if row and row[0]:
835
+ findings.append({"severity": "error", "kind": "dangling_relation", "message": f"{row[0]} relations have missing source symbols"})
836
+ row = conn.execute("""
837
+ SELECT COUNT(*) FROM relations r
838
+ LEFT JOIN symbols s ON s.id=r.to_id
839
+ WHERE r.to_id IS NOT NULL AND s.id IS NULL AND (r.to_external IS NULL OR r.to_external='')
840
+ """).fetchone()
841
+ if row and row[0]:
842
+ findings.append({"severity": "error", "kind": "dangling_relation", "message": f"{row[0]} relations have missing target symbols"})
843
+ finally:
844
+ conn.close()
845
+ except sqlite3.Error as exc:
846
+ findings.append({"severity": "error", "kind": "sqlite_error", "message": str(exc)})
610
847
  skill_root = Path(args.skill_root)
611
848
  if skill_root.exists():
612
849
  findings.extend(audit_project_binding([skill_root / "workflow-code-index", skill_root / "code-index-query"]))
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.80";
3
3
  export const PROJECT_SCHEMA_VERSION = 1;
4
4
  export const OMP_RUNTIME_VERSION = "17.2.7";
5
5
 
@@ -220,8 +220,8 @@ async function buildCodeIndex(context: ProjectContext, config: ScanConfigInput):
220
220
  const output = `.planning/.code-index-pending-${randomUUID()}`;
221
221
  const codebases = config.repositories.flatMap(repository => ["--codebase", `${repository.name}:${path.resolve(context.root, repository.relativePath)}:${repository.role}`]);
222
222
  await runProcess(["python3", script, "scan", "--root", context.root, "--output", output, ...codebases], context.root, "code-index scan");
223
- await runProcess(["python3", script, "validate", "--index", output], context.root, "code-index validate");
224
- await runProcess(["python3", script, "audit", "--root", context.root, "--index", output, "--skill-root", path.resolve(import.meta.dir, "../plugins/devflow-intel/skills")], context.root, "code-index audit");
223
+ await runProcess(["python3", script, "validate", "--index", output, "--level", "publish"], context.root, "code-index validate");
224
+ await runProcess(["python3", script, "audit", "--root", context.root, "--index", output, "--skill-root", path.resolve(import.meta.dir, "../plugins/devflow-intel/skills"), "--level", "publish"], context.root, "code-index audit");
225
225
  const snapshotPath = path.join(context.root, output, "SNAPSHOT.json");
226
226
  const bytes = await readFile(snapshotPath); const snapshot = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
227
227
  if (!snapshot.generation || !snapshot.immutablePath) throw new Error("Code index did not publish an immutable generation");