@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 +1 -1
- package/plugins/devflow-intel/extensions/devflow-intel.ts +15 -1
- package/plugins/devflow-intel/skills/code-index-query/SKILL.md +8 -0
- package/plugins/devflow-intel/skills/workflow-code-index/SKILL.md +19 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +288 -51
- package/src/constants.ts +1 -1
- package/src/project-initializer.ts +2 -2
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
(
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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,
|
|
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,
|
|
569
|
+
(like, like, like, limit),
|
|
469
570
|
).fetchall()
|
|
470
571
|
for row in rows:
|
|
471
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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]:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
609
|
-
|
|
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
|
@@ -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");
|