@guanlin4924/devflow-pi 0.1.79 → 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.79",
3
+ "version": "0.1.80",
4
4
  "type": "module",
5
5
  "description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
6
6
  "license": "UNLICENSED",
@@ -63,6 +63,7 @@ export default function devflowIntel(pi: ExtensionAPI) {
63
63
  includeHeuristic: z.boolean().default(false),
64
64
  limit: z.number().int().min(1).max(100).default(20),
65
65
  changedFiles: z.array(z.string()).default([]),
66
+ validationLevel: z.enum(["fast", "publish", "full"]).default("publish"),
66
67
  }),
67
68
  approval: (args) => {
68
69
  const action = (args as { action?: string }).action;
@@ -78,9 +79,11 @@ export default function devflowIntel(pi: ExtensionAPI) {
78
79
  if (params.changedFiles.length > 0) args.push("--changed-files", ...params.changedFiles);
79
80
  } else if (params.action === "validate") {
80
81
  args.push("--index", ".planning/code-index");
82
+ args.push("--level", params.validationLevel);
81
83
  if (params.snapshot) args.push("--snapshot", params.snapshot);
82
84
  } else if (params.action === "audit") {
83
85
  args.push("--root", ctx.cwd, "--index", ".planning/code-index", "--skill-root", skillRoot);
86
+ args.push("--level", params.validationLevel === "fast" ? "publish" : params.validationLevel);
84
87
  if (params.snapshot) args.push("--snapshot", params.snapshot);
85
88
  } else if (params.action === "query") {
86
89
  if (!params.term) {
@@ -47,6 +47,20 @@ devflow_code_index(action="audit")
47
47
  devflow_code_index(action="impact", entity="api:orders", changeType="remove_field")
48
48
  ```
49
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
+
50
64
  For changed-file indexing:
51
65
 
52
66
  ```text
@@ -197,15 +197,22 @@ def main(argv=None) -> int:
197
197
  scan.add_argument("--changed-files", nargs="*", default=[], help="Optional changed files for incremental refresh.")
198
198
  scan.add_argument("--exclude", action="append", default=[], help="Additional directory names to exclude.")
199
199
 
200
- validate = sub.add_parser("validate", help="Validate JSONL schema.")
200
+ validate = sub.add_parser("validate", help="Validate an index in staged levels.")
201
201
  validate.add_argument("--index", default=".planning/code-index", help="Index directory.")
202
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
+ )
203
209
 
204
210
  audit = sub.add_parser("audit", help="Audit stale files and relation integrity.")
205
211
  audit.add_argument("--root", default=".", help="Project root.")
206
212
  audit.add_argument("--index", default=".planning/code-index", help="Index directory.")
207
213
  audit.add_argument("--skill-root", default=".skills", help="Optional skill root for project-binding checks.")
208
214
  audit.add_argument("--snapshot", help="Immutable generation id.")
215
+ audit.add_argument("--level", choices=("publish", "full"), default="publish")
209
216
 
210
217
  query = sub.add_parser("query", help="Search symbols using SQLite FTS or LIKE fallback.")
211
218
  query.add_argument("--index", default=".planning/code-index", help="Index directory.")
@@ -405,55 +412,114 @@ def command_scan(args) -> int:
405
412
  return 0
406
413
 
407
414
 
408
- def command_validate(args) -> int:
409
- output = Path(args.index).resolve()
410
- selected = _selected_generation(output, getattr(args, "snapshot", None))
411
- 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"
412
427
  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"))
428
+ conn = sqlite3.connect(str(index))
418
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'}")
419
433
  snapshot_id = snapshot.get("generation")
420
434
  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:
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")
430
450
  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]
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]
440
455
  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")
456
+ errors.append(f"relations have {dangling} missing target symbols")
451
457
  finally:
452
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()
453
517
  except (OSError, sqlite3.Error, ValueError) as exc:
454
518
  errors.append(str(exc))
455
519
  for error in errors:
456
520
  print(error, file=sys.stderr)
521
+ if not errors:
522
+ print(f"Index validation passed ({level}).")
457
523
  return 1 if errors else 0
458
524
  def command_query(args) -> int:
459
525
  output = Path(args.index).resolve()
@@ -625,21 +691,37 @@ def _selected_generation(output: Path, generation: str | None) -> Path:
625
691
  if not generation:
626
692
  root_snapshot = _load_snapshot(output)
627
693
  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
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
632
705
  # Legacy indexes predating immutable snapshots remain readable.
633
706
  return output
634
707
  if not re.fullmatch(r"[0-9a-f-]{36}", generation, re.I):
635
708
  raise ValueError("invalid snapshot generation")
636
709
  selected = output / "snapshots" / generation
637
710
  snapshot = _load_snapshot(selected)
638
- if snapshot.get("generation") != generation:
639
- raise ValueError(f"unknown snapshot generation: {generation}")
711
+ _validate_generation_pointer(selected, snapshot, generation)
640
712
  return selected
641
713
 
642
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
+
643
725
  def _load_snapshot(output: Path) -> Dict[str, object]:
644
726
  path = output / SNAPSHOT_FILE
645
727
  if not path.exists():
@@ -664,6 +746,11 @@ def _validate_snapshot_bundle(selected: Path, snapshot: Dict[str, object]) -> Li
664
746
  generation = snapshot.get("generation")
665
747
  if not generation:
666
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))
667
754
  required = [SNAPSHOT_FILE, "index.sqlite", *INDEX_FILES.values()]
668
755
  for name in required:
669
756
  if not (selected / name).is_file():
@@ -724,10 +811,39 @@ def command_audit(args) -> int:
724
811
  for error in bundle_errors:
725
812
  print(error, file=sys.stderr)
726
813
  return 1
727
- files, symbols, relations, diagnostics = _load_tuple(selected)
728
814
  findings = []
729
- findings.extend(audit_stale(root, files))
730
- 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)})
731
847
  skill_root = Path(args.skill_root)
732
848
  if skill_root.exists():
733
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.79";
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");