@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 +1 -1
- package/plugins/devflow-intel/extensions/devflow-intel.ts +3 -0
- package/plugins/devflow-intel/skills/workflow-code-index/SKILL.md +14 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +163 -47
- package/src/constants.ts +1 -1
- package/src/project-initializer.ts +2 -2
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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"
|
|
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
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
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
|
-
|
|
730
|
-
|
|
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
|
@@ -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");
|