@guanlin4924/devflow-pi 0.1.78 → 0.1.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugins/devflow-intel/extensions/devflow-intel.ts +12 -1
- package/plugins/devflow-intel/skills/code-index-query/SKILL.md +8 -0
- package/plugins/devflow-intel/skills/workflow-code-index/SKILL.md +5 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +147 -26
- package/src/constants.ts +1 -1
package/package.json
CHANGED
|
@@ -53,9 +53,11 @@ export default function devflowIntel(pi: ExtensionAPI) {
|
|
|
53
53
|
label: "Devflow Code Index",
|
|
54
54
|
description: "Scan, validate, audit, or query the deterministic project code index.",
|
|
55
55
|
parameters: z.object({
|
|
56
|
-
action: z.enum(["scan", "validate", "audit", "query", "relations"]),
|
|
56
|
+
action: z.enum(["scan", "validate", "audit", "query", "relations", "impact"]),
|
|
57
57
|
term: z.string().optional(),
|
|
58
58
|
object: z.string().optional(),
|
|
59
|
+
entity: z.string().optional(),
|
|
60
|
+
changeType: z.enum(["remove_field", "rename_field", "change_type", "optional_to_required", "required_to_optional", "change_nullability", "change_enum"]).optional(),
|
|
59
61
|
snapshot: z.string().optional(),
|
|
60
62
|
depth: z.number().int().min(0).max(20).default(3),
|
|
61
63
|
includeHeuristic: z.boolean().default(false),
|
|
@@ -76,8 +78,10 @@ export default function devflowIntel(pi: ExtensionAPI) {
|
|
|
76
78
|
if (params.changedFiles.length > 0) args.push("--changed-files", ...params.changedFiles);
|
|
77
79
|
} else if (params.action === "validate") {
|
|
78
80
|
args.push("--index", ".planning/code-index");
|
|
81
|
+
if (params.snapshot) args.push("--snapshot", params.snapshot);
|
|
79
82
|
} else if (params.action === "audit") {
|
|
80
83
|
args.push("--root", ctx.cwd, "--index", ".planning/code-index", "--skill-root", skillRoot);
|
|
84
|
+
if (params.snapshot) args.push("--snapshot", params.snapshot);
|
|
81
85
|
} else if (params.action === "query") {
|
|
82
86
|
if (!params.term) {
|
|
83
87
|
return render({ ok: false, exitCode: 2, stdout: "", stderr: "term is required" }, {});
|
|
@@ -85,6 +89,13 @@ export default function devflowIntel(pi: ExtensionAPI) {
|
|
|
85
89
|
args.push("--index", ".planning/code-index", "--term", params.term, "--limit", String(params.limit));
|
|
86
90
|
if (params.snapshot) args.push("--snapshot", params.snapshot);
|
|
87
91
|
} else {
|
|
92
|
+
if (params.action === "impact") {
|
|
93
|
+
if (!params.entity) return render({ ok: false, exitCode: 2, stdout: "", stderr: "entity is required" }, {});
|
|
94
|
+
args.push("--index", ".planning/code-index", "--entity", params.entity, "--depth", String(params.depth), "--limit", String(params.limit));
|
|
95
|
+
if (params.snapshot) args.push("--snapshot", params.snapshot);
|
|
96
|
+
if (params.changeType) args.push("--change-type", params.changeType);
|
|
97
|
+
return render(await run(args, ctx.cwd), { action: params.action });
|
|
98
|
+
}
|
|
88
99
|
if (!params.object) return render({ ok: false, exitCode: 2, stdout: "", stderr: "object is required" }, {});
|
|
89
100
|
args.push("--index", ".planning/code-index", "--object", params.object, "--depth", String(params.depth), "--limit", String(params.limit));
|
|
90
101
|
if (params.snapshot) args.push("--snapshot", params.snapshot);
|
|
@@ -32,6 +32,14 @@ devflow_code_index(action="scan")
|
|
|
32
32
|
devflow_code_index(action="audit")
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
Impact analysis uses the same immutable SQLite snapshot:
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
devflow_code_index(action="impact", entity="{entity_id}", changeType="remove_field")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The result carries `_meta.snapshot`, coverage, and truncation status. Older structural-only snapshots return an explicit semantic capability-unavailable result; they remain valid for symbol and relation queries.
|
|
42
|
+
|
|
35
43
|
## Query Patterns
|
|
36
44
|
|
|
37
45
|
For more detailed query patterns, read `references/query-patterns.md`.
|
|
@@ -44,6 +44,7 @@ Use the OMP runtime tool:
|
|
|
44
44
|
devflow_code_index(action="scan")
|
|
45
45
|
devflow_code_index(action="validate")
|
|
46
46
|
devflow_code_index(action="audit")
|
|
47
|
+
devflow_code_index(action="impact", entity="api:orders", changeType="remove_field")
|
|
47
48
|
```
|
|
48
49
|
|
|
49
50
|
For changed-file indexing:
|
|
@@ -66,3 +67,7 @@ Blocking findings are stale file hashes, missing indexed files, dangling `to_id`
|
|
|
66
67
|
- Do not mark dynamic or string-based matches as `exact`.
|
|
67
68
|
- If adapter support is missing, emit diagnostics and fall back to generic indexing.
|
|
68
69
|
- Query answers must read source files at evidence paths before making factual claims.
|
|
70
|
+
- The root `SNAPSHOT.json` is only a pointer. Queries, validation, audit, and impact resolve it to the immutable `snapshots/{generation}` directory before reading facts.
|
|
71
|
+
- Query results include snapshot and parser provenance. A historical snapshot may be queried with `snapshot`; it is not freshness-checked against the current worktree.
|
|
72
|
+
- `impact` is a typed semantic query. It returns `unavailable` with `semantic_capability_unavailable` when an older structural-only snapshot has no semantic tables; it never executes arbitrary SQL.
|
|
73
|
+
- Query limits are bounded by the runtime. Negative or oversized limits are normalized safely.
|
|
@@ -65,7 +65,17 @@ def _publish_snapshot(staging: Path, output: Path, counts: Dict[str, int], works
|
|
|
65
65
|
generation = generation or str(uuid.uuid4())
|
|
66
66
|
immutable_relative = f"snapshots/{generation}"
|
|
67
67
|
snapshot = {
|
|
68
|
+
# Keep the public snapshot schema stable while exposing the richer
|
|
69
|
+
# SQLite schema separately. Older consumers only know schemaVersion=1.
|
|
68
70
|
"schemaVersion": 1,
|
|
71
|
+
"indexSchemaVersion": 3,
|
|
72
|
+
"capabilities": {
|
|
73
|
+
"structural": True,
|
|
74
|
+
"semantic": True,
|
|
75
|
+
"fts": True,
|
|
76
|
+
"relations": True,
|
|
77
|
+
"provenance": True,
|
|
78
|
+
},
|
|
69
79
|
"generation": generation,
|
|
70
80
|
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
|
71
81
|
"counts": counts,
|
|
@@ -189,11 +199,13 @@ def main(argv=None) -> int:
|
|
|
189
199
|
|
|
190
200
|
validate = sub.add_parser("validate", help="Validate JSONL schema.")
|
|
191
201
|
validate.add_argument("--index", default=".planning/code-index", help="Index directory.")
|
|
202
|
+
validate.add_argument("--snapshot", help="Immutable generation id.")
|
|
192
203
|
|
|
193
204
|
audit = sub.add_parser("audit", help="Audit stale files and relation integrity.")
|
|
194
205
|
audit.add_argument("--root", default=".", help="Project root.")
|
|
195
206
|
audit.add_argument("--index", default=".planning/code-index", help="Index directory.")
|
|
196
207
|
audit.add_argument("--skill-root", default=".skills", help="Optional skill root for project-binding checks.")
|
|
208
|
+
audit.add_argument("--snapshot", help="Immutable generation id.")
|
|
197
209
|
|
|
198
210
|
query = sub.add_parser("query", help="Search symbols using SQLite FTS or LIKE fallback.")
|
|
199
211
|
query.add_argument("--index", default=".planning/code-index", help="Index directory.")
|
|
@@ -335,6 +347,12 @@ def command_scan(args) -> int:
|
|
|
335
347
|
registry_capabilities() if registry_capabilities else {},
|
|
336
348
|
)
|
|
337
349
|
workspace_state["generatedAt"] = datetime.now(timezone.utc).isoformat()
|
|
350
|
+
workspace_state["scanScope"] = {
|
|
351
|
+
"exclude": sorted(set(args.exclude)),
|
|
352
|
+
"codebases": [
|
|
353
|
+
{"name": item.name, "path": str(item.root)} for item in codebases
|
|
354
|
+
],
|
|
355
|
+
}
|
|
338
356
|
workspace_state["semanticExtractor"] = SEMANTIC_EXTRACTOR_VERSION
|
|
339
357
|
workspace_state["extractorSetHash"] = _hash_lines([*workspace_state.get("extractors", []), SEMANTIC_EXTRACTOR_VERSION])
|
|
340
358
|
progress.emit("semantic", "building semantic facts")
|
|
@@ -389,19 +407,27 @@ def command_scan(args) -> int:
|
|
|
389
407
|
|
|
390
408
|
def command_validate(args) -> int:
|
|
391
409
|
output = Path(args.index).resolve()
|
|
392
|
-
selected = _selected_generation(output, None)
|
|
410
|
+
selected = _selected_generation(output, getattr(args, "snapshot", None))
|
|
393
411
|
errors = []
|
|
394
412
|
try:
|
|
395
413
|
snapshot = _load_snapshot(selected)
|
|
414
|
+
errors.extend(_validate_snapshot_bundle(selected, snapshot))
|
|
396
415
|
files, symbols, relations, diagnostics = _load_tuple(selected)
|
|
397
416
|
errors.extend(validate_index(files, symbols, relations, diagnostics))
|
|
398
417
|
conn = sqlite3.connect(str(selected / "index.sqlite"))
|
|
399
418
|
try:
|
|
400
419
|
snapshot_id = snapshot.get("generation")
|
|
401
|
-
|
|
402
|
-
if
|
|
403
|
-
|
|
404
|
-
|
|
420
|
+
tables = _table_names(conn)
|
|
421
|
+
if "analysis_snapshots" in tables:
|
|
422
|
+
row = conn.execute("SELECT snapshot_id FROM analysis_snapshots WHERE snapshot_id=?", (snapshot_id,)).fetchone()
|
|
423
|
+
if not row:
|
|
424
|
+
errors.append(f"snapshot metadata missing generation {snapshot_id}")
|
|
425
|
+
semantic_tables = {"semantic_flows", "entity_versions", "feature_bindings", "coverage_metrics"}
|
|
426
|
+
if not semantic_tables.issubset(tables):
|
|
427
|
+
# Structural-only historical snapshots remain valid.
|
|
428
|
+
snapshot.setdefault("capabilities", {})["semantic"] = False
|
|
429
|
+
else:
|
|
430
|
+
dangling = conn.execute("""
|
|
405
431
|
SELECT COUNT(*) FROM semantic_flows f
|
|
406
432
|
WHERE f.snapshot_id=? AND (
|
|
407
433
|
(NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=f.snapshot_id AND e.logical_id=f.source_id)
|
|
@@ -410,18 +436,18 @@ def command_validate(args) -> int:
|
|
|
410
436
|
(NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=f.snapshot_id AND e.logical_id=f.target_id)
|
|
411
437
|
AND NOT EXISTS (SELECT 1 FROM symbols s WHERE s.id=f.target_id))
|
|
412
438
|
)
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
439
|
+
""", (snapshot_id,)).fetchone()[0]
|
|
440
|
+
if dangling:
|
|
441
|
+
errors.append(f"semantic flows have {dangling} dangling source or target nodes")
|
|
442
|
+
binding_dangling = conn.execute("""
|
|
417
443
|
SELECT COUNT(*) FROM feature_bindings b
|
|
418
444
|
WHERE b.snapshot_id=? AND (
|
|
419
445
|
NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=b.snapshot_id AND e.logical_id=b.feature_id)
|
|
420
446
|
OR NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=b.snapshot_id AND e.logical_id=b.entity_id)
|
|
421
447
|
)
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
448
|
+
""", (snapshot_id,)).fetchone()[0]
|
|
449
|
+
if binding_dangling:
|
|
450
|
+
errors.append(f"feature bindings have {binding_dangling} dangling feature or entity nodes")
|
|
425
451
|
finally:
|
|
426
452
|
conn.close()
|
|
427
453
|
except (OSError, sqlite3.Error, ValueError) as exc:
|
|
@@ -438,6 +464,11 @@ def command_query(args) -> int:
|
|
|
438
464
|
print(f"Missing index: {index}", file=sys.stderr)
|
|
439
465
|
return 1
|
|
440
466
|
snapshot = _load_snapshot(selected)
|
|
467
|
+
bundle_errors = _validate_snapshot_bundle(selected, snapshot)
|
|
468
|
+
if bundle_errors:
|
|
469
|
+
print(bundle_errors[0], file=sys.stderr)
|
|
470
|
+
return 1
|
|
471
|
+
limit = _bounded_limit(getattr(args, "limit", 20), 1000)
|
|
441
472
|
files = read_jsonl(selected / INDEX_FILES["files"])
|
|
442
473
|
if not getattr(args, "snapshot", None) and not _query_is_fresh(output.parent.parent, snapshot, files):
|
|
443
474
|
print("Stale code index: refresh required before query.", file=sys.stderr)
|
|
@@ -445,30 +476,38 @@ def command_query(args) -> int:
|
|
|
445
476
|
conn = sqlite3.connect(str(index))
|
|
446
477
|
conn.row_factory = sqlite3.Row
|
|
447
478
|
try:
|
|
479
|
+
symbol_columns = {row[1] for row in conn.execute("PRAGMA table_info(symbols)")}
|
|
480
|
+
provenance = ", parser, content_hash, metadata_json" if {"parser", "content_hash", "metadata_json"}.issubset(symbol_columns) else ", NULL AS parser, NULL AS content_hash, NULL AS metadata_json"
|
|
448
481
|
try:
|
|
449
482
|
rows = conn.execute(
|
|
450
|
-
"""
|
|
451
|
-
SELECT id, codebase, kind, name, qualified_name, path, line_start, line_end
|
|
483
|
+
f"""
|
|
484
|
+
SELECT id, codebase, kind, name, qualified_name, path, line_start, line_end,
|
|
485
|
+
{provenance[2:]}
|
|
452
486
|
FROM symbols_fts
|
|
453
487
|
JOIN symbols USING(id)
|
|
454
488
|
WHERE symbols_fts MATCH ?
|
|
455
489
|
LIMIT ?
|
|
456
490
|
""",
|
|
457
|
-
(args.term,
|
|
491
|
+
(args.term, limit),
|
|
458
492
|
).fetchall()
|
|
459
493
|
except sqlite3.OperationalError:
|
|
460
494
|
like = f"%{args.term}%"
|
|
461
495
|
rows = conn.execute(
|
|
462
|
-
"""
|
|
463
|
-
SELECT id, codebase, kind, name, qualified_name, path, line_start, line_end
|
|
496
|
+
f"""
|
|
497
|
+
SELECT id, codebase, kind, name, qualified_name, path, line_start, line_end,
|
|
498
|
+
{provenance[2:]}
|
|
464
499
|
FROM symbols
|
|
465
500
|
WHERE name LIKE ? OR qualified_name LIKE ? OR path LIKE ?
|
|
466
501
|
LIMIT ?
|
|
467
502
|
""",
|
|
468
|
-
(like, like, like,
|
|
503
|
+
(like, like, like, limit),
|
|
469
504
|
).fetchall()
|
|
470
505
|
for row in rows:
|
|
471
|
-
|
|
506
|
+
item = dict(row)
|
|
507
|
+
item["snapshot"] = snapshot.get("generation")
|
|
508
|
+
item["parser"] = item.get("parser") or "unknown"
|
|
509
|
+
item["confidence"] = item.get("confidence") or confidence_for_parser(item["parser"], "symbol")
|
|
510
|
+
print(json.dumps(item, ensure_ascii=False, sort_keys=True))
|
|
472
511
|
finally:
|
|
473
512
|
conn.close()
|
|
474
513
|
return 0
|
|
@@ -476,14 +515,22 @@ def command_query(args) -> int:
|
|
|
476
515
|
|
|
477
516
|
def command_relations(args) -> int:
|
|
478
517
|
output = Path(args.index).resolve()
|
|
479
|
-
depth = max(0, min(args.depth, 20)); limit =
|
|
518
|
+
depth = max(0, min(args.depth, 20)); limit = _bounded_limit(args.limit, 1000)
|
|
480
519
|
with _index_lock(output, exclusive=False):
|
|
481
520
|
selected = _selected_generation(output, getattr(args, "snapshot", None))
|
|
482
521
|
snapshot = _load_snapshot(selected); files = read_jsonl(selected / INDEX_FILES["files"])
|
|
522
|
+
bundle_errors = _validate_snapshot_bundle(selected, snapshot)
|
|
523
|
+
if bundle_errors:
|
|
524
|
+
print(bundle_errors[0], file=sys.stderr); return 1
|
|
483
525
|
if not getattr(args, "snapshot", None) and not _query_is_fresh(output.parent.parent, snapshot, files):
|
|
484
526
|
print("Stale code index: refresh required before query.", file=sys.stderr); return 1
|
|
485
527
|
conn = sqlite3.connect(str(selected / "index.sqlite")); conn.row_factory = sqlite3.Row
|
|
486
528
|
try:
|
|
529
|
+
symbol_columns = {row[1] for row in conn.execute("PRAGMA table_info(symbols)")}
|
|
530
|
+
parser_expr = "s.parser" if "parser" in symbol_columns else "NULL"
|
|
531
|
+
hash_expr = "s.content_hash" if "content_hash" in symbol_columns else "NULL"
|
|
532
|
+
to_parser_expr = "t.parser" if "parser" in symbol_columns else "NULL"
|
|
533
|
+
to_hash_expr = "t.content_hash" if "content_hash" in symbol_columns else "NULL"
|
|
487
534
|
if depth == 0:
|
|
488
535
|
print(json.dumps({"_meta": {"snapshot": snapshot.get("generation"), "depthLimit": 0, "resultLimit": limit, "truncated": False}}, ensure_ascii=False, sort_keys=True))
|
|
489
536
|
return 0
|
|
@@ -496,14 +543,21 @@ def command_relations(args) -> int:
|
|
|
496
543
|
SELECT r.from_id,r.relation_type,r.to_id,r.to_external,r.evidence_path,r.evidence_line,r.confidence,g.depth+1,g.visited || COALESCE(r.to_id,'') || ','
|
|
497
544
|
FROM graph g JOIN relations r ON r.from_id=g.to_id
|
|
498
545
|
WHERE g.depth < ? AND r.to_id IS NOT NULL {confidence} AND instr(g.visited, ',' || r.to_id || ',')=0
|
|
499
|
-
) SELECT
|
|
546
|
+
) SELECT graph.*, s.path AS from_path, s.name AS from_name, {parser_expr} AS from_parser,
|
|
547
|
+
{hash_expr} AS from_content_hash, t.path AS to_path, t.name AS to_name,
|
|
548
|
+
{to_parser_expr} AS to_parser, {to_hash_expr} AS to_content_hash
|
|
549
|
+
FROM graph
|
|
550
|
+
LEFT JOIN symbols s ON s.id=graph.from_id
|
|
551
|
+
LEFT JOIN symbols t ON t.id=graph.to_id
|
|
552
|
+
ORDER BY depth,from_id,relation_type,to_id LIMIT ?
|
|
500
553
|
""", (args.object, depth, limit + 1)).fetchall()
|
|
501
554
|
truncated = len(rows) > limit
|
|
502
555
|
for row in rows[:limit]:
|
|
503
556
|
item = dict(row)
|
|
557
|
+
item["snapshot"] = snapshot.get("generation")
|
|
504
558
|
if getattr(args, "change_type", None):
|
|
505
559
|
item["impact_kind"] = args.change_type
|
|
506
|
-
item["severity"] = _impact_severity(args.change_type, item
|
|
560
|
+
item["severity"] = _impact_severity(args.change_type, item.get("flow_type", "relation"))
|
|
507
561
|
print(json.dumps(item, ensure_ascii=False, sort_keys=True))
|
|
508
562
|
print(json.dumps({"_meta": {"snapshot": snapshot.get("generation"), "depthLimit": depth, "resultLimit": limit, "truncated": truncated}}, ensure_ascii=False, sort_keys=True))
|
|
509
563
|
finally: conn.close()
|
|
@@ -523,10 +577,13 @@ def _impact_severity(change_type: str, flow_type: str) -> str:
|
|
|
523
577
|
|
|
524
578
|
def command_impact(args) -> int:
|
|
525
579
|
output = Path(args.index).resolve()
|
|
526
|
-
depth = max(0, min(args.depth, 20)); limit =
|
|
580
|
+
depth = max(0, min(args.depth, 20)); limit = _bounded_limit(args.limit, 2000)
|
|
527
581
|
with _index_lock(output, exclusive=False):
|
|
528
582
|
selected = _selected_generation(output, getattr(args, "snapshot", None))
|
|
529
583
|
snapshot = _load_snapshot(selected)
|
|
584
|
+
bundle_errors = _validate_snapshot_bundle(selected, snapshot)
|
|
585
|
+
if bundle_errors:
|
|
586
|
+
print(bundle_errors[0], file=sys.stderr); return 1
|
|
530
587
|
if not getattr(args, "snapshot", None):
|
|
531
588
|
files = read_jsonl(selected / INDEX_FILES["files"])
|
|
532
589
|
if not _query_is_fresh(output.parent.parent, snapshot, files):
|
|
@@ -534,6 +591,11 @@ def command_impact(args) -> int:
|
|
|
534
591
|
return 1
|
|
535
592
|
conn = sqlite3.connect(str(selected / "index.sqlite")); conn.row_factory = sqlite3.Row
|
|
536
593
|
try:
|
|
594
|
+
tables = _table_names(conn)
|
|
595
|
+
semantic_tables = {"semantic_flows", "entity_versions", "feature_bindings", "coverage_metrics"}
|
|
596
|
+
if not semantic_tables.issubset(tables):
|
|
597
|
+
print(json.dumps({"_meta": {"snapshot": snapshot.get("generation"), "status": "unavailable", "reason": "semantic_capability_unavailable", "coverage": {"status": "unknown"}, "truncated": False}}, ensure_ascii=False, sort_keys=True))
|
|
598
|
+
return 0
|
|
537
599
|
rows = conn.execute("""
|
|
538
600
|
WITH RECURSIVE graph(node_id, flow_type, source_path, line_start, confidence, depth, visited) AS (
|
|
539
601
|
SELECT CASE WHEN source_id=? THEN target_id ELSE source_id END, flow_type, source_path, line_start, confidence, 1, ',' || ? || ','
|
|
@@ -545,7 +607,12 @@ def command_impact(args) -> int:
|
|
|
545
607
|
WHERE g.depth < ? AND instr(g.visited, ',' || CASE WHEN f.source_id=g.node_id THEN f.target_id ELSE f.source_id END || ',')=0
|
|
546
608
|
) SELECT * FROM graph LIMIT ?
|
|
547
609
|
""", (args.entity, args.entity, snapshot.get("generation"), args.entity, args.entity, snapshot.get("generation"), depth, limit + 1)).fetchall()
|
|
548
|
-
for row in rows[:limit]:
|
|
610
|
+
for row in rows[:limit]:
|
|
611
|
+
item = dict(row); item["snapshot"] = snapshot.get("generation")
|
|
612
|
+
if getattr(args, "change_type", None):
|
|
613
|
+
item["impact_kind"] = args.change_type
|
|
614
|
+
item["severity"] = _impact_severity(args.change_type, item.get("flow_type", "relation"))
|
|
615
|
+
print(json.dumps(item, ensure_ascii=False, sort_keys=True))
|
|
549
616
|
coverage_row = conn.execute("SELECT * FROM coverage_metrics WHERE snapshot_id=?", (snapshot.get("generation"),)).fetchone()
|
|
550
617
|
coverage = dict(coverage_row) if coverage_row else {"status": "unknown"}
|
|
551
618
|
print(json.dumps({"_meta": {"snapshot": snapshot.get("generation"), "coverage": coverage, "depthLimit": depth, "truncated": len(rows) > limit}}, ensure_ascii=False, sort_keys=True))
|
|
@@ -556,6 +623,13 @@ def command_impact(args) -> int:
|
|
|
556
623
|
|
|
557
624
|
def _selected_generation(output: Path, generation: str | None) -> Path:
|
|
558
625
|
if not generation:
|
|
626
|
+
root_snapshot = _load_snapshot(output)
|
|
627
|
+
current = root_snapshot.get("generation") if root_snapshot else None
|
|
628
|
+
if current and re.fullmatch(r"[0-9a-f-]{36}", str(current), re.I):
|
|
629
|
+
selected = output / "snapshots" / str(current)
|
|
630
|
+
if selected.is_dir():
|
|
631
|
+
return selected
|
|
632
|
+
# Legacy indexes predating immutable snapshots remain readable.
|
|
559
633
|
return output
|
|
560
634
|
if not re.fullmatch(r"[0-9a-f-]{36}", generation, re.I):
|
|
561
635
|
raise ValueError("invalid snapshot generation")
|
|
@@ -573,6 +647,44 @@ def _load_snapshot(output: Path) -> Dict[str, object]:
|
|
|
573
647
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
574
648
|
|
|
575
649
|
|
|
650
|
+
def _bounded_limit(value, maximum: int) -> int:
|
|
651
|
+
try:
|
|
652
|
+
value = int(value)
|
|
653
|
+
except (TypeError, ValueError):
|
|
654
|
+
value = 20
|
|
655
|
+
return max(1, min(value, maximum))
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _table_names(conn: sqlite3.Connection) -> set[str]:
|
|
659
|
+
return {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type IN ('table','virtual table')")}
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _validate_snapshot_bundle(selected: Path, snapshot: Dict[str, object]) -> List[str]:
|
|
663
|
+
errors: List[str] = []
|
|
664
|
+
generation = snapshot.get("generation")
|
|
665
|
+
if not generation:
|
|
666
|
+
return [f"snapshot metadata missing generation in {selected}"]
|
|
667
|
+
required = [SNAPSHOT_FILE, "index.sqlite", *INDEX_FILES.values()]
|
|
668
|
+
for name in required:
|
|
669
|
+
if not (selected / name).is_file():
|
|
670
|
+
errors.append(f"snapshot {generation} missing artifact {name}")
|
|
671
|
+
index = selected / "index.sqlite"
|
|
672
|
+
if index.is_file():
|
|
673
|
+
try:
|
|
674
|
+
conn = sqlite3.connect(str(index))
|
|
675
|
+
try:
|
|
676
|
+
tables = _table_names(conn)
|
|
677
|
+
if "analysis_snapshots" in tables:
|
|
678
|
+
row = conn.execute("SELECT snapshot_id FROM analysis_snapshots LIMIT 1").fetchone()
|
|
679
|
+
if row and row[0] != generation:
|
|
680
|
+
errors.append(f"snapshot generation mismatch: metadata={generation}, sqlite={row[0]}")
|
|
681
|
+
finally:
|
|
682
|
+
conn.close()
|
|
683
|
+
except sqlite3.Error as exc:
|
|
684
|
+
errors.append(f"invalid sqlite snapshot {generation}: {exc}")
|
|
685
|
+
return errors
|
|
686
|
+
|
|
687
|
+
|
|
576
688
|
def _query_is_fresh(root: Path, snapshot: Dict[str, object], files: List[dict]) -> bool:
|
|
577
689
|
workspace = snapshot.get("workspace") if isinstance(snapshot, dict) else None
|
|
578
690
|
if not isinstance(workspace, dict):
|
|
@@ -591,7 +703,9 @@ def _query_is_fresh(root: Path, snapshot: Dict[str, object], files: List[dict])
|
|
|
591
703
|
return False
|
|
592
704
|
indexed_paths = sorted(record["path"] for record in files)
|
|
593
705
|
if not any(path.startswith("../") for path in indexed_paths):
|
|
594
|
-
|
|
706
|
+
scope = workspace.get("scanScope") if isinstance(workspace.get("scanScope"), dict) else {}
|
|
707
|
+
excludes = scope.get("exclude", []) if isinstance(scope, dict) else []
|
|
708
|
+
current_paths = sorted(_workspace_rel(root, item) for item in scan_files(root, excludes=excludes))
|
|
595
709
|
expected_paths_hash = workspace.get("discoveredPathsHash") or workspace.get("indexedPathsHash")
|
|
596
710
|
if _hash_lines(current_paths) != expected_paths_hash:
|
|
597
711
|
return False
|
|
@@ -603,7 +717,14 @@ def command_audit(args) -> int:
|
|
|
603
717
|
root = Path(args.root).resolve()
|
|
604
718
|
output = Path(args.index).resolve()
|
|
605
719
|
with _index_lock(output, exclusive=True):
|
|
606
|
-
|
|
720
|
+
selected = _selected_generation(output, getattr(args, "snapshot", None))
|
|
721
|
+
snapshot = _load_snapshot(selected)
|
|
722
|
+
bundle_errors = _validate_snapshot_bundle(selected, snapshot)
|
|
723
|
+
if bundle_errors:
|
|
724
|
+
for error in bundle_errors:
|
|
725
|
+
print(error, file=sys.stderr)
|
|
726
|
+
return 1
|
|
727
|
+
files, symbols, relations, diagnostics = _load_tuple(selected)
|
|
607
728
|
findings = []
|
|
608
729
|
findings.extend(audit_stale(root, files))
|
|
609
730
|
findings.extend(audit_relations(symbols, relations))
|
package/src/constants.ts
CHANGED