@guanlin4924/devflow-pi 0.1.79 → 0.1.81
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 +52 -3
- package/plugins/devflow-intel/skills/workflow-code-index/SKILL.md +14 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +311 -60
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +10 -2
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/domain_registry.py +168 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/hybrid_query.py +367 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/source_content.py +88 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/writers/sqlite_writer.py +5 -2
- package/src/constants.ts +1 -1
- package/src/initialization-store.ts +1 -0
- package/src/project-initializer.ts +8 -3
|
@@ -27,7 +27,10 @@ from adapters.registry import AdapterRegistry
|
|
|
27
27
|
from semantic import SEMANTIC_EXTRACTOR_VERSION, build_semantic_facts
|
|
28
28
|
from core.discovery import discover_codebases
|
|
29
29
|
from core.hashing import file_sha256
|
|
30
|
-
from core.scanner import scan_files
|
|
30
|
+
from core.scanner import scan_files, DEFAULT_EXCLUDES
|
|
31
|
+
from hybrid_query import execute as execute_plan, read_connection, validate_plan
|
|
32
|
+
from domain_registry import import_bundle
|
|
33
|
+
from source_content import content_integrity
|
|
31
34
|
from validators.project_binding_check import audit_project_binding
|
|
32
35
|
from validators.relation_check import audit_relations
|
|
33
36
|
from validators.schema_validate import validate_index
|
|
@@ -68,13 +71,16 @@ def _publish_snapshot(staging: Path, output: Path, counts: Dict[str, int], works
|
|
|
68
71
|
# Keep the public snapshot schema stable while exposing the richer
|
|
69
72
|
# SQLite schema separately. Older consumers only know schemaVersion=1.
|
|
70
73
|
"schemaVersion": 1,
|
|
71
|
-
"indexSchemaVersion": 3,
|
|
74
|
+
"indexSchemaVersion": 4 if workspace_state.get("contentSearch") else 3,
|
|
72
75
|
"capabilities": {
|
|
73
76
|
"structural": True,
|
|
74
77
|
"semantic": True,
|
|
75
78
|
"fts": True,
|
|
76
79
|
"relations": True,
|
|
77
80
|
"provenance": True,
|
|
81
|
+
"contentSearch": workspace_state.get("contentSearch", False),
|
|
82
|
+
"domainMapping": {"version": 1, "storage": "external_registry"},
|
|
83
|
+
"queryPlan": {"version": 1},
|
|
78
84
|
},
|
|
79
85
|
"generation": generation,
|
|
80
86
|
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
|
@@ -195,17 +201,26 @@ def main(argv=None) -> int:
|
|
|
195
201
|
scan.add_argument("--output", default=".planning/code-index", help="Index output directory.")
|
|
196
202
|
scan.add_argument("--codebase", action="append", default=[], help="Explicit codebase: name:path[:type]. Can repeat.")
|
|
197
203
|
scan.add_argument("--changed-files", nargs="*", default=[], help="Optional changed files for incremental refresh.")
|
|
204
|
+
scan.add_argument("--include", action="append", default=[], help="Repository-relative include glob. Can repeat.")
|
|
205
|
+
scan.add_argument("--max-file-size", type=int, default=1500000)
|
|
198
206
|
scan.add_argument("--exclude", action="append", default=[], help="Additional directory names to exclude.")
|
|
199
207
|
|
|
200
|
-
validate = sub.add_parser("validate", help="Validate
|
|
208
|
+
validate = sub.add_parser("validate", help="Validate an index in staged levels.")
|
|
201
209
|
validate.add_argument("--index", default=".planning/code-index", help="Index directory.")
|
|
202
210
|
validate.add_argument("--snapshot", help="Immutable generation id.")
|
|
211
|
+
validate.add_argument(
|
|
212
|
+
"--level",
|
|
213
|
+
choices=("fast", "publish", "full"),
|
|
214
|
+
default="publish",
|
|
215
|
+
help="fast bundle checks, publish gate checks, or full JSONL/semantic audit",
|
|
216
|
+
)
|
|
203
217
|
|
|
204
218
|
audit = sub.add_parser("audit", help="Audit stale files and relation integrity.")
|
|
205
219
|
audit.add_argument("--root", default=".", help="Project root.")
|
|
206
220
|
audit.add_argument("--index", default=".planning/code-index", help="Index directory.")
|
|
207
221
|
audit.add_argument("--skill-root", default=".skills", help="Optional skill root for project-binding checks.")
|
|
208
222
|
audit.add_argument("--snapshot", help="Immutable generation id.")
|
|
223
|
+
audit.add_argument("--level", choices=("publish", "full"), default="publish")
|
|
209
224
|
|
|
210
225
|
query = sub.add_parser("query", help="Search symbols using SQLite FTS or LIKE fallback.")
|
|
211
226
|
query.add_argument("--index", default=".planning/code-index", help="Index directory.")
|
|
@@ -220,6 +235,7 @@ def main(argv=None) -> int:
|
|
|
220
235
|
relations.add_argument("--limit", type=int, default=100)
|
|
221
236
|
relations.add_argument("--snapshot")
|
|
222
237
|
relations.add_argument("--include-heuristic", action="store_true")
|
|
238
|
+
relations.add_argument("--direction", choices=["incoming", "outgoing", "both"], default="outgoing")
|
|
223
239
|
|
|
224
240
|
impact = sub.add_parser("impact", help="Traverse cross-layer semantic facts in a fixed snapshot.")
|
|
225
241
|
impact.add_argument("--index", default=".planning/code-index")
|
|
@@ -229,7 +245,28 @@ def main(argv=None) -> int:
|
|
|
229
245
|
impact.add_argument("--snapshot")
|
|
230
246
|
impact.add_argument("--change-type", choices=["remove_field", "rename_field", "change_type", "optional_to_required", "required_to_optional", "change_nullability", "change_enum"], help="Optional proposed change classification.")
|
|
231
247
|
|
|
248
|
+
for action in ("search-content", "resolve-domain", "query-plan", "domain-import"):
|
|
249
|
+
operation = sub.add_parser(action)
|
|
250
|
+
operation.add_argument("--index", default=".planning/code-index")
|
|
251
|
+
operation.add_argument("--snapshot")
|
|
252
|
+
operation.add_argument("--project-id", default="")
|
|
253
|
+
operation.add_argument("--limit", type=int, default=20)
|
|
254
|
+
operation.add_argument("--repository", action="append", default=[])
|
|
255
|
+
operation.add_argument("--path-prefix", default="")
|
|
256
|
+
operation.add_argument("--mapping-revision", default="approved-current")
|
|
257
|
+
if action == "search-content":
|
|
258
|
+
operation.add_argument("--language")
|
|
259
|
+
operation.add_argument("--match-kind", choices=["literal", "substring", "identifier", "exact_phrase"], default="literal")
|
|
260
|
+
if action == "query-plan":
|
|
261
|
+
operation.add_argument("--plan", required=True, help="Structured QueryPlan JSON, never SQL")
|
|
262
|
+
elif action == "domain-import":
|
|
263
|
+
operation.add_argument("--bundle", required=True, help="JSON mapping bundle file")
|
|
264
|
+
operation.add_argument("--expected-revision", help="Expected approved current revision for CAS")
|
|
265
|
+
elif action in {"search-content", "resolve-domain"}:
|
|
266
|
+
operation.add_argument("--term", required=True)
|
|
232
267
|
args = parser.parse_args(argv)
|
|
268
|
+
if args.command in {"search-content", "resolve-domain", "query-plan", "domain-import"}:
|
|
269
|
+
return command_structured(args)
|
|
233
270
|
if args.command == "scan":
|
|
234
271
|
return command_scan(args)
|
|
235
272
|
if args.command == "validate":
|
|
@@ -248,6 +285,10 @@ def main(argv=None) -> int:
|
|
|
248
285
|
def command_scan(args) -> int:
|
|
249
286
|
root = Path(args.root).resolve()
|
|
250
287
|
output = (root / args.output).resolve() if not Path(args.output).is_absolute() else Path(args.output).resolve()
|
|
288
|
+
includes = getattr(args, "include", [])
|
|
289
|
+
max_file_size = getattr(args, "max_file_size", 1500000)
|
|
290
|
+
if not 1 <= max_file_size <= 50000000:
|
|
291
|
+
raise ValueError("max-file-size must be 1..50000000")
|
|
251
292
|
progress = _Progress(output)
|
|
252
293
|
progress.emit("start", "scan started", root=str(root), output=str(output), codebases=args.codebase)
|
|
253
294
|
with _index_lock(output, exclusive=True):
|
|
@@ -256,6 +297,10 @@ def command_scan(args) -> int:
|
|
|
256
297
|
registry = AdapterRegistry()
|
|
257
298
|
changed = _normalize_changed_files(root, args.changed_files)
|
|
258
299
|
existing = _load_existing(output)
|
|
300
|
+
previous_scope = _load_snapshot(output).get("workspace", {}).get("scanScope", {})
|
|
301
|
+
previous_roots = {(item.get("name"), item.get("path")) for item in previous_scope.get("codebases", [])}
|
|
302
|
+
if changed and (set(previous_scope.get("exclude", [])) != DEFAULT_EXCLUDES | set(args.exclude) or previous_roots != {(item.name, str(item.root)) for item in codebases} or previous_scope.get("includes", []) != sorted(set(includes)) or previous_scope.get("maxFileSize", 1500000) != max_file_size):
|
|
303
|
+
changed = set() # Scope changes require a complete rebuild, never reuse out-of-scope rows.
|
|
259
304
|
secondary_changed = _incoming_evidence_files(existing, changed)
|
|
260
305
|
changed |= secondary_changed
|
|
261
306
|
|
|
@@ -270,7 +315,7 @@ def command_scan(args) -> int:
|
|
|
270
315
|
diagnostics.append(_diagnostic(codebase.name, "", 1, "warning", "skipped_file", f"codebase root does not exist: {codebase.root}", "discovery@1"))
|
|
271
316
|
continue
|
|
272
317
|
progress.emit("discover", f"discovering {codebase.name}", codebase=codebase.name, path=str(codebase.root))
|
|
273
|
-
all_files = scan_files(codebase.root, excludes=args.exclude)
|
|
318
|
+
all_files = scan_files(codebase.root, excludes=args.exclude, includes=includes, max_bytes=max_file_size)
|
|
274
319
|
progress.emit("discover", f"discovered {len(all_files)} files in {codebase.name}", codebase=codebase.name, files=len(all_files))
|
|
275
320
|
discovered_paths.update(_workspace_rel(root, path) for path in all_files)
|
|
276
321
|
if changed:
|
|
@@ -348,11 +393,17 @@ def command_scan(args) -> int:
|
|
|
348
393
|
)
|
|
349
394
|
workspace_state["generatedAt"] = datetime.now(timezone.utc).isoformat()
|
|
350
395
|
workspace_state["scanScope"] = {
|
|
351
|
-
"exclude": sorted(set(args.exclude)),
|
|
396
|
+
"exclude": sorted(DEFAULT_EXCLUDES | set(args.exclude)),
|
|
397
|
+
"includes": sorted(set(includes)),
|
|
398
|
+
"generatedPolicy": "exclude_known_build_directories",
|
|
399
|
+
"dependencyPolicy": "exclude_known_dependency_directories",
|
|
400
|
+
"maxFileSize": max_file_size,
|
|
352
401
|
"codebases": [
|
|
353
|
-
{"name": item.name, "path": str(item.root)} for item in codebases
|
|
402
|
+
{"name": item.name, "path": str(item.root), "workspacePath": _workspace_rel(root, item.root)} for item in codebases
|
|
354
403
|
],
|
|
355
404
|
}
|
|
405
|
+
workspace_state["scopeHash"] = _hash_lines([json.dumps(workspace_state["scanScope"], sort_keys=True)])
|
|
406
|
+
workspace_state["root"] = str(root)
|
|
356
407
|
workspace_state["semanticExtractor"] = SEMANTIC_EXTRACTOR_VERSION
|
|
357
408
|
workspace_state["extractorSetHash"] = _hash_lines([*workspace_state.get("extractors", []), SEMANTIC_EXTRACTOR_VERSION])
|
|
358
409
|
progress.emit("semantic", "building semantic facts")
|
|
@@ -374,6 +425,7 @@ def command_scan(args) -> int:
|
|
|
374
425
|
semantic_entities=semantic_entities,
|
|
375
426
|
semantic_flows=semantic_flows,
|
|
376
427
|
feature_bindings=feature_bindings,
|
|
428
|
+
source_root=root,
|
|
377
429
|
)
|
|
378
430
|
progress.emit("documents", "writing index projections")
|
|
379
431
|
write_markdown(staging, files, symbols, relations, diagnostics)
|
|
@@ -405,58 +457,198 @@ def command_scan(args) -> int:
|
|
|
405
457
|
return 0
|
|
406
458
|
|
|
407
459
|
|
|
408
|
-
def
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
460
|
+
def _validate_publish_gate(selected: Path, snapshot: Dict[str, object]) -> List[str]:
|
|
461
|
+
"""Run bounded checks required before publishing a generation.
|
|
462
|
+
|
|
463
|
+
This deliberately uses SQLite for referential checks. The previous
|
|
464
|
+
implementation loaded every JSONL sidecar before it could decide whether
|
|
465
|
+
a snapshot was publishable, which made large projects spend minutes at
|
|
466
|
+
100% CPU in a validation process.
|
|
467
|
+
"""
|
|
468
|
+
errors = _validate_snapshot_bundle(selected, snapshot)
|
|
469
|
+
if errors:
|
|
470
|
+
return errors
|
|
471
|
+
index = selected / "index.sqlite"
|
|
412
472
|
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"))
|
|
473
|
+
conn = sqlite3.connect(str(index))
|
|
418
474
|
try:
|
|
475
|
+
quick = conn.execute("PRAGMA quick_check").fetchone()
|
|
476
|
+
if not quick or quick[0] != "ok":
|
|
477
|
+
errors.append(f"sqlite quick_check failed: {quick[0] if quick else 'no result'}")
|
|
478
|
+
if snapshot.get("capabilities", {}).get("contentSearch"):
|
|
479
|
+
content_tables = _table_names(conn)
|
|
480
|
+
for table in ("source_artifacts", "source_content_chunks", "snapshot_metadata"):
|
|
481
|
+
if table not in content_tables:
|
|
482
|
+
errors.append(f"content capability missing table {table}")
|
|
483
|
+
content_capability = snapshot["capabilities"]["contentSearch"]
|
|
484
|
+
if isinstance(content_capability, dict) and content_capability.get("tokenizer") == "trigram" and "source_content_fts" not in content_tables:
|
|
485
|
+
errors.append("trigram content capability missing FTS table")
|
|
486
|
+
if {"source_content_chunks", "snapshot_metadata"}.issubset(content_tables):
|
|
487
|
+
declared = conn.execute("SELECT value FROM snapshot_metadata WHERE key='contentSearch'").fetchone()
|
|
488
|
+
integrity = conn.execute("SELECT value FROM snapshot_metadata WHERE key='contentIntegrity'").fetchone()
|
|
489
|
+
if declared is None or json.loads(declared[0]) != content_capability:
|
|
490
|
+
errors.append("content capability metadata mismatch")
|
|
491
|
+
actual = content_integrity(conn)
|
|
492
|
+
if integrity is None or json.loads(integrity[0]) != actual or actual.get("missing_fts_rowids", 0):
|
|
493
|
+
errors.append("content/FTS rowid, count or integrity mismatch")
|
|
494
|
+
if {"source_content_chunks", "code_files", "source_artifacts"}.issubset(content_tables):
|
|
495
|
+
invalid = conn.execute("SELECT count(*) FROM source_content_chunks c LEFT JOIN code_files f ON f.id=c.code_file_id LEFT JOIN source_artifacts a ON a.content_hash=c.content_hash WHERE f.id IS NULL OR a.content_hash IS NULL OR c.content_hash<>f.content_hash OR c.code_generation<>?", (snapshot.get("generation"),)).fetchone()[0]
|
|
496
|
+
if invalid:
|
|
497
|
+
errors.append(f"content index has {invalid} invalid chunk references")
|
|
419
498
|
snapshot_id = snapshot.get("generation")
|
|
420
499
|
tables = _table_names(conn)
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
500
|
+
required = {"analysis_snapshots", "code_files", "symbols", "relations", "diagnostics"}
|
|
501
|
+
missing = sorted(required - tables)
|
|
502
|
+
if missing:
|
|
503
|
+
errors.append(f"index missing required tables: {', '.join(missing)}")
|
|
504
|
+
elif not conn.execute("SELECT 1 FROM analysis_snapshots WHERE snapshot_id=? LIMIT 1", (snapshot_id,)).fetchone():
|
|
505
|
+
errors.append(f"snapshot metadata missing generation {snapshot_id}")
|
|
506
|
+
|
|
507
|
+
if {"relations", "symbols"}.issubset(tables):
|
|
508
|
+
dangling = conn.execute("""
|
|
509
|
+
SELECT COUNT(*) FROM relations r
|
|
510
|
+
LEFT JOIN symbols s ON s.id=r.from_id
|
|
511
|
+
WHERE s.id IS NULL
|
|
512
|
+
""").fetchone()[0]
|
|
513
|
+
if dangling:
|
|
514
|
+
errors.append(f"relations have {dangling} missing source symbols")
|
|
430
515
|
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]
|
|
516
|
+
SELECT COUNT(*) FROM relations r
|
|
517
|
+
LEFT JOIN symbols s ON s.id=r.to_id
|
|
518
|
+
WHERE r.to_id IS NOT NULL AND s.id IS NULL AND (r.to_external IS NULL OR r.to_external='')
|
|
519
|
+
""").fetchone()[0]
|
|
440
520
|
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")
|
|
521
|
+
errors.append(f"relations have {dangling} missing target symbols")
|
|
451
522
|
finally:
|
|
452
523
|
conn.close()
|
|
524
|
+
except (OSError, sqlite3.Error) as exc:
|
|
525
|
+
errors.append(str(exc))
|
|
526
|
+
return errors
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _validate_semantic_graph(conn: sqlite3.Connection, snapshot_id: str, tables: set[str]) -> List[str]:
|
|
530
|
+
errors: List[str] = []
|
|
531
|
+
semantic_tables = {"semantic_flows", "entity_versions", "feature_bindings", "coverage_metrics"}
|
|
532
|
+
if not semantic_tables.issubset(tables):
|
|
533
|
+
missing = ", ".join(sorted(semantic_tables - tables))
|
|
534
|
+
errors.append(f"full validation unavailable: missing semantic tables: {missing}")
|
|
535
|
+
return errors
|
|
536
|
+
dangling = conn.execute("""
|
|
537
|
+
SELECT COUNT(*) FROM semantic_flows f
|
|
538
|
+
WHERE f.snapshot_id=? AND (
|
|
539
|
+
(NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=f.snapshot_id AND e.logical_id=f.source_id)
|
|
540
|
+
AND NOT EXISTS (SELECT 1 FROM symbols s WHERE s.id=f.source_id))
|
|
541
|
+
OR
|
|
542
|
+
(NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=f.snapshot_id AND e.logical_id=f.target_id)
|
|
543
|
+
AND NOT EXISTS (SELECT 1 FROM symbols s WHERE s.id=f.target_id))
|
|
544
|
+
)
|
|
545
|
+
""", (snapshot_id,)).fetchone()[0]
|
|
546
|
+
if dangling:
|
|
547
|
+
errors.append(f"semantic flows have {dangling} dangling source or target nodes")
|
|
548
|
+
binding_dangling = conn.execute("""
|
|
549
|
+
SELECT COUNT(*) FROM feature_bindings b
|
|
550
|
+
WHERE b.snapshot_id=? AND (
|
|
551
|
+
NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=b.snapshot_id AND e.logical_id=b.feature_id)
|
|
552
|
+
OR NOT EXISTS (SELECT 1 FROM entity_versions e WHERE e.snapshot_id=b.snapshot_id AND e.logical_id=b.entity_id)
|
|
553
|
+
)
|
|
554
|
+
""", (snapshot_id,)).fetchone()[0]
|
|
555
|
+
if binding_dangling:
|
|
556
|
+
errors.append(f"feature bindings have {binding_dangling} dangling feature or entity nodes")
|
|
557
|
+
return errors
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def command_validate(args) -> int:
|
|
561
|
+
output = Path(args.index).resolve()
|
|
562
|
+
selected = _selected_generation(output, getattr(args, "snapshot", None))
|
|
563
|
+
level = getattr(args, "level", "publish")
|
|
564
|
+
errors: List[str] = []
|
|
565
|
+
try:
|
|
566
|
+
snapshot = _load_snapshot(selected)
|
|
567
|
+
print(f"[code-index][validate] level={level} snapshot={snapshot.get('generation', 'unknown')}", file=sys.stderr, flush=True)
|
|
568
|
+
if level == "fast":
|
|
569
|
+
errors.extend(_validate_snapshot_bundle(selected, snapshot))
|
|
570
|
+
else:
|
|
571
|
+
errors.extend(_validate_publish_gate(selected, snapshot))
|
|
572
|
+
if level == "full" and not errors:
|
|
573
|
+
print("[code-index][validate] full JSONL schema validation", file=sys.stderr, flush=True)
|
|
574
|
+
files, symbols, relations, diagnostics = _load_tuple(selected)
|
|
575
|
+
errors.extend(validate_index(files, symbols, relations, diagnostics))
|
|
576
|
+
if not errors:
|
|
577
|
+
conn = sqlite3.connect(str(selected / "index.sqlite"))
|
|
578
|
+
try:
|
|
579
|
+
errors.extend(_validate_semantic_graph(conn, str(snapshot.get("generation")), _table_names(conn)))
|
|
580
|
+
finally:
|
|
581
|
+
conn.close()
|
|
453
582
|
except (OSError, sqlite3.Error, ValueError) as exc:
|
|
454
583
|
errors.append(str(exc))
|
|
455
584
|
for error in errors:
|
|
456
585
|
print(error, file=sys.stderr)
|
|
586
|
+
if not errors:
|
|
587
|
+
print(f"Index validation passed ({level}).")
|
|
457
588
|
return 1 if errors else 0
|
|
589
|
+
def command_structured(args) -> int:
|
|
590
|
+
output = Path(args.index).resolve()
|
|
591
|
+
try:
|
|
592
|
+
if args.command == "query-plan":
|
|
593
|
+
if len(args.plan) > 32000:
|
|
594
|
+
raise ValueError("QueryPlan exceeds 32KB")
|
|
595
|
+
raw_plan = json.loads(args.plan)
|
|
596
|
+
if "limit" not in raw_plan:
|
|
597
|
+
raw_plan["limit"] = getattr(args, "limit", 20)
|
|
598
|
+
plan = validate_plan(raw_plan)
|
|
599
|
+
else:
|
|
600
|
+
plan = {"featureTerms": [args.term] if hasattr(args, "term") else [],
|
|
601
|
+
"phases": ["search_content", "verify_evidence"] if args.command == "search-content" else ["resolve_domain"],
|
|
602
|
+
"limit": args.limit, "repositoryFilter": getattr(args, "repository", []),
|
|
603
|
+
"pathPrefix": getattr(args, "path_prefix", ""), "mappingRevision": getattr(args, "mapping_revision", "approved-current"),
|
|
604
|
+
"language": getattr(args, "language", None), "matchKind": getattr(args, "match_kind", "literal")}
|
|
605
|
+
plan = validate_plan(plan)
|
|
606
|
+
requested = getattr(args, "snapshot", None)
|
|
607
|
+
planned = plan.get("codeGeneration")
|
|
608
|
+
if planned and planned != "current":
|
|
609
|
+
if requested and requested != planned:
|
|
610
|
+
raise ValueError("conflicting snapshot and codeGeneration")
|
|
611
|
+
requested = planned
|
|
612
|
+
with _index_lock(output, exclusive=False):
|
|
613
|
+
selected = _selected_generation(output, requested)
|
|
614
|
+
snapshot = _load_snapshot(selected)
|
|
615
|
+
errors = _validate_snapshot_bundle(selected, snapshot)
|
|
616
|
+
if errors:
|
|
617
|
+
raise ValueError(errors[0])
|
|
618
|
+
if not requested and not _query_is_fresh(Path(snapshot.get("workspace", {}).get("root", output.parent.parent)), snapshot, read_jsonl(selected / INDEX_FILES["files"])):
|
|
619
|
+
print(json.dumps({"_meta": {"status": "stale_index", "snapshot": snapshot.get("generation")}}))
|
|
620
|
+
return 1
|
|
621
|
+
conn = read_connection(selected / "index.sqlite")
|
|
622
|
+
try:
|
|
623
|
+
if snapshot.get("workspace", {}).get("scopeHash"):
|
|
624
|
+
scope_json = json.dumps(snapshot["workspace"]["scanScope"], sort_keys=True)
|
|
625
|
+
if _hash_lines([scope_json]) != snapshot["workspace"]["scopeHash"]:
|
|
626
|
+
raise ValueError("scan scope hash mismatch")
|
|
627
|
+
stored = conn.execute("SELECT value FROM snapshot_metadata WHERE key='scanScope'").fetchone()
|
|
628
|
+
if stored is None or json.loads(stored[0]) != snapshot["workspace"]["scanScope"]:
|
|
629
|
+
raise ValueError("scan scope metadata mismatch")
|
|
630
|
+
registry = output.parent / "domain-registry.sqlite"
|
|
631
|
+
if args.command == "domain-import":
|
|
632
|
+
bundle_path = Path(args.bundle)
|
|
633
|
+
if bundle_path.stat().st_size > 2_000_000:
|
|
634
|
+
raise ValueError("mapping bundle exceeds 2MB")
|
|
635
|
+
result = import_bundle(registry, args.project_id, json.loads(bundle_path.read_text()), getattr(args, "expected_revision", None), conn, snapshot["generation"])
|
|
636
|
+
else:
|
|
637
|
+
result = execute_plan(conn, snapshot, plan, registry, getattr(args, "project_id", ""))
|
|
638
|
+
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
639
|
+
finally:
|
|
640
|
+
conn.close()
|
|
641
|
+
return 0
|
|
642
|
+
except (ValueError, TypeError, KeyError, OSError, sqlite3.Error) as exc:
|
|
643
|
+
status = "partial" if isinstance(exc, sqlite3.OperationalError) and "interrupt" in str(exc) else "invalid_query"
|
|
644
|
+
print(json.dumps({"_meta": {"status": status, "reason": str(exc)}}))
|
|
645
|
+
return 1
|
|
646
|
+
|
|
647
|
+
|
|
458
648
|
def command_query(args) -> int:
|
|
459
649
|
output = Path(args.index).resolve()
|
|
650
|
+
if hasattr(args, "term") and (not args.term or len(args.term) > 256 or "\x00" in args.term):
|
|
651
|
+
raise ValueError("term must contain 1..256 characters")
|
|
460
652
|
with _index_lock(output, exclusive=False):
|
|
461
653
|
selected = _selected_generation(output, getattr(args, "snapshot", None))
|
|
462
654
|
index = selected / "index.sqlite"
|
|
@@ -473,8 +665,7 @@ def command_query(args) -> int:
|
|
|
473
665
|
if not getattr(args, "snapshot", None) and not _query_is_fresh(output.parent.parent, snapshot, files):
|
|
474
666
|
print("Stale code index: refresh required before query.", file=sys.stderr)
|
|
475
667
|
return 1
|
|
476
|
-
conn =
|
|
477
|
-
conn.row_factory = sqlite3.Row
|
|
668
|
+
conn = read_connection(index)
|
|
478
669
|
try:
|
|
479
670
|
symbol_columns = {row[1] for row in conn.execute("PRAGMA table_info(symbols)")}
|
|
480
671
|
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"
|
|
@@ -488,16 +679,16 @@ def command_query(args) -> int:
|
|
|
488
679
|
WHERE symbols_fts MATCH ?
|
|
489
680
|
LIMIT ?
|
|
490
681
|
""",
|
|
491
|
-
(args.term, limit),
|
|
682
|
+
('"' + args.term.replace('"', '""') + '"', limit),
|
|
492
683
|
).fetchall()
|
|
493
684
|
except sqlite3.OperationalError:
|
|
494
|
-
like =
|
|
685
|
+
like = args.term
|
|
495
686
|
rows = conn.execute(
|
|
496
687
|
f"""
|
|
497
688
|
SELECT id, codebase, kind, name, qualified_name, path, line_start, line_end,
|
|
498
689
|
{provenance[2:]}
|
|
499
690
|
FROM symbols
|
|
500
|
-
WHERE name
|
|
691
|
+
WHERE instr(lower(name),lower(?))>0 OR instr(lower(qualified_name),lower(?))>0 OR instr(lower(path),lower(?))>0
|
|
501
692
|
LIMIT ?
|
|
502
693
|
""",
|
|
503
694
|
(like, like, like, limit),
|
|
@@ -514,6 +705,10 @@ def command_query(args) -> int:
|
|
|
514
705
|
|
|
515
706
|
|
|
516
707
|
def command_relations(args) -> int:
|
|
708
|
+
if getattr(args, "direction", "outgoing") != "outgoing":
|
|
709
|
+
args.plan = json.dumps({"seedEntityIds": [args.object], "phases": ["traverse_relations", "verify_evidence"], "direction": args.direction, "depth": min(args.depth, 5), "limit": min(args.limit, 100), "includeHeuristic": args.include_heuristic})
|
|
710
|
+
args.command = "query-plan"
|
|
711
|
+
return command_structured(args)
|
|
517
712
|
output = Path(args.index).resolve()
|
|
518
713
|
depth = max(0, min(args.depth, 20)); limit = _bounded_limit(args.limit, 1000)
|
|
519
714
|
with _index_lock(output, exclusive=False):
|
|
@@ -524,7 +719,7 @@ def command_relations(args) -> int:
|
|
|
524
719
|
print(bundle_errors[0], file=sys.stderr); return 1
|
|
525
720
|
if not getattr(args, "snapshot", None) and not _query_is_fresh(output.parent.parent, snapshot, files):
|
|
526
721
|
print("Stale code index: refresh required before query.", file=sys.stderr); return 1
|
|
527
|
-
conn =
|
|
722
|
+
conn = read_connection(selected / "index.sqlite")
|
|
528
723
|
try:
|
|
529
724
|
symbol_columns = {row[1] for row in conn.execute("PRAGMA table_info(symbols)")}
|
|
530
725
|
parser_expr = "s.parser" if "parser" in symbol_columns else "NULL"
|
|
@@ -555,6 +750,10 @@ def command_relations(args) -> int:
|
|
|
555
750
|
for row in rows[:limit]:
|
|
556
751
|
item = dict(row)
|
|
557
752
|
item["snapshot"] = snapshot.get("generation")
|
|
753
|
+
relation_parser = conn.execute("SELECT parser FROM relations WHERE from_id=? AND relation_type=? AND evidence_path=? AND evidence_line=? LIMIT 1", (item["from_id"], item["relation_type"], item["evidence_path"], item["evidence_line"])).fetchone()
|
|
754
|
+
item["relation_parser"] = relation_parser[0] if relation_parser else "unknown"
|
|
755
|
+
item["source_file_parser"] = item.get("from_parser")
|
|
756
|
+
item["provenance"] = {"relation_parser": item["relation_parser"], "source_file_parser": item["source_file_parser"], "snapshot": snapshot.get("generation")}
|
|
558
757
|
if getattr(args, "change_type", None):
|
|
559
758
|
item["impact_kind"] = args.change_type
|
|
560
759
|
item["severity"] = _impact_severity(args.change_type, item.get("flow_type", "relation"))
|
|
@@ -589,7 +788,7 @@ def command_impact(args) -> int:
|
|
|
589
788
|
if not _query_is_fresh(output.parent.parent, snapshot, files):
|
|
590
789
|
print("Stale code index: refresh required before impact query.", file=sys.stderr)
|
|
591
790
|
return 1
|
|
592
|
-
conn =
|
|
791
|
+
conn = read_connection(selected / "index.sqlite")
|
|
593
792
|
try:
|
|
594
793
|
tables = _table_names(conn)
|
|
595
794
|
semantic_tables = {"semantic_flows", "entity_versions", "feature_bindings", "coverage_metrics"}
|
|
@@ -625,21 +824,37 @@ def _selected_generation(output: Path, generation: str | None) -> Path:
|
|
|
625
824
|
if not generation:
|
|
626
825
|
root_snapshot = _load_snapshot(output)
|
|
627
826
|
current = root_snapshot.get("generation") if root_snapshot else None
|
|
628
|
-
if current
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
827
|
+
if current is not None:
|
|
828
|
+
if not re.fullmatch(r"[0-9a-f-]{36}", str(current), re.I):
|
|
829
|
+
raise ValueError(f"invalid current snapshot generation: {current}")
|
|
830
|
+
current = str(current)
|
|
831
|
+
_validate_generation_pointer(output, root_snapshot, current)
|
|
832
|
+
selected = output / "snapshots" / current
|
|
833
|
+
if not selected.is_dir():
|
|
834
|
+
raise ValueError(f"current snapshot directory is missing: {selected}")
|
|
835
|
+
selected_snapshot = _load_snapshot(selected)
|
|
836
|
+
_validate_generation_pointer(selected, selected_snapshot, current)
|
|
837
|
+
return selected
|
|
632
838
|
# Legacy indexes predating immutable snapshots remain readable.
|
|
633
839
|
return output
|
|
634
840
|
if not re.fullmatch(r"[0-9a-f-]{36}", generation, re.I):
|
|
635
841
|
raise ValueError("invalid snapshot generation")
|
|
636
842
|
selected = output / "snapshots" / generation
|
|
637
843
|
snapshot = _load_snapshot(selected)
|
|
638
|
-
|
|
639
|
-
raise ValueError(f"unknown snapshot generation: {generation}")
|
|
844
|
+
_validate_generation_pointer(selected, snapshot, generation)
|
|
640
845
|
return selected
|
|
641
846
|
|
|
642
847
|
|
|
848
|
+
def _validate_generation_pointer(selected: Path, snapshot: Dict[str, object], expected: str) -> None:
|
|
849
|
+
if snapshot.get("generation") != expected:
|
|
850
|
+
raise ValueError(f"snapshot generation mismatch: expected={expected}, metadata={snapshot.get('generation')}")
|
|
851
|
+
expected_path = f"snapshots/{expected}"
|
|
852
|
+
if snapshot.get("immutablePath") != expected_path:
|
|
853
|
+
raise ValueError(
|
|
854
|
+
f"snapshot immutablePath mismatch: expected={expected_path}, metadata={snapshot.get('immutablePath')}"
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
|
|
643
858
|
def _load_snapshot(output: Path) -> Dict[str, object]:
|
|
644
859
|
path = output / SNAPSHOT_FILE
|
|
645
860
|
if not path.exists():
|
|
@@ -664,6 +879,11 @@ def _validate_snapshot_bundle(selected: Path, snapshot: Dict[str, object]) -> Li
|
|
|
664
879
|
generation = snapshot.get("generation")
|
|
665
880
|
if not generation:
|
|
666
881
|
return [f"snapshot metadata missing generation in {selected}"]
|
|
882
|
+
if selected.parent.name == "snapshots":
|
|
883
|
+
try:
|
|
884
|
+
_validate_generation_pointer(selected, snapshot, selected.name)
|
|
885
|
+
except ValueError as exc:
|
|
886
|
+
errors.append(str(exc))
|
|
667
887
|
required = [SNAPSHOT_FILE, "index.sqlite", *INDEX_FILES.values()]
|
|
668
888
|
for name in required:
|
|
669
889
|
if not (selected / name).is_file():
|
|
@@ -705,7 +925,9 @@ def _query_is_fresh(root: Path, snapshot: Dict[str, object], files: List[dict])
|
|
|
705
925
|
if not any(path.startswith("../") for path in indexed_paths):
|
|
706
926
|
scope = workspace.get("scanScope") if isinstance(workspace.get("scanScope"), dict) else {}
|
|
707
927
|
excludes = scope.get("exclude", []) if isinstance(scope, dict) else []
|
|
708
|
-
|
|
928
|
+
repositories = scope.get("codebases", [])
|
|
929
|
+
roots = [Path(item["path"]) for item in repositories] if repositories else [root]
|
|
930
|
+
current_paths = sorted({_workspace_rel(root, item) for repository in roots for item in scan_files(repository, excludes=excludes, max_bytes=scope.get("maxFileSize", 1500000), includes=scope.get("includes", []))})
|
|
709
931
|
expected_paths_hash = workspace.get("discoveredPathsHash") or workspace.get("indexedPathsHash")
|
|
710
932
|
if _hash_lines(current_paths) != expected_paths_hash:
|
|
711
933
|
return False
|
|
@@ -724,10 +946,39 @@ def command_audit(args) -> int:
|
|
|
724
946
|
for error in bundle_errors:
|
|
725
947
|
print(error, file=sys.stderr)
|
|
726
948
|
return 1
|
|
727
|
-
files, symbols, relations, diagnostics = _load_tuple(selected)
|
|
728
949
|
findings = []
|
|
729
|
-
|
|
730
|
-
|
|
950
|
+
level = getattr(args, "level", "publish")
|
|
951
|
+
if level == "full":
|
|
952
|
+
files, symbols, relations, diagnostics = _load_tuple(selected)
|
|
953
|
+
findings.extend(audit_stale(root, files))
|
|
954
|
+
findings.extend(audit_relations(symbols, relations))
|
|
955
|
+
else:
|
|
956
|
+
# The publish audit is intentionally bounded and SQL-backed. A
|
|
957
|
+
# full stale/content audit remains available explicitly via
|
|
958
|
+
# --level full and should not block initialization publication.
|
|
959
|
+
try:
|
|
960
|
+
conn = sqlite3.connect(str(selected / "index.sqlite"))
|
|
961
|
+
try:
|
|
962
|
+
tables = _table_names(conn)
|
|
963
|
+
if {"relations", "symbols"}.issubset(tables):
|
|
964
|
+
row = conn.execute("""
|
|
965
|
+
SELECT COUNT(*) FROM relations r
|
|
966
|
+
LEFT JOIN symbols s ON s.id=r.from_id
|
|
967
|
+
WHERE s.id IS NULL
|
|
968
|
+
""").fetchone()
|
|
969
|
+
if row and row[0]:
|
|
970
|
+
findings.append({"severity": "error", "kind": "dangling_relation", "message": f"{row[0]} relations have missing source symbols"})
|
|
971
|
+
row = conn.execute("""
|
|
972
|
+
SELECT COUNT(*) FROM relations r
|
|
973
|
+
LEFT JOIN symbols s ON s.id=r.to_id
|
|
974
|
+
WHERE r.to_id IS NOT NULL AND s.id IS NULL AND (r.to_external IS NULL OR r.to_external='')
|
|
975
|
+
""").fetchone()
|
|
976
|
+
if row and row[0]:
|
|
977
|
+
findings.append({"severity": "error", "kind": "dangling_relation", "message": f"{row[0]} relations have missing target symbols"})
|
|
978
|
+
finally:
|
|
979
|
+
conn.close()
|
|
980
|
+
except sqlite3.Error as exc:
|
|
981
|
+
findings.append({"severity": "error", "kind": "sqlite_error", "message": str(exc)})
|
|
731
982
|
skill_root = Path(args.skill_root)
|
|
732
983
|
if skill_root.exists():
|
|
733
984
|
findings.extend(audit_project_binding([skill_root / "workflow-code-index", skill_root / "code-index-query"]))
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import fnmatch
|
|
2
3
|
import subprocess
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
from typing import Iterable, List, Sequence
|
|
@@ -22,6 +23,7 @@ DEFAULT_EXCLUDES = {
|
|
|
22
23
|
".dart_tool",
|
|
23
24
|
"__pycache__",
|
|
24
25
|
"Pods",
|
|
26
|
+
"server/public",
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
TEXT_EXTENSIONS = {
|
|
@@ -57,7 +59,7 @@ TEXT_EXTENSIONS = {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
|
|
60
|
-
def scan_files(root: Path, excludes: Iterable[str] = (), max_bytes: int = 1_500_000) -> List[Path]:
|
|
62
|
+
def scan_files(root: Path, excludes: Iterable[str] = (), max_bytes: int = 1_500_000, includes: Iterable[str] = ()) -> List[Path]:
|
|
61
63
|
root = root.resolve()
|
|
62
64
|
excluded = DEFAULT_EXCLUDES | set(excludes)
|
|
63
65
|
paths = _scan_with_git(root, excluded) or _scan_with_rg(root, excluded) or _scan_with_walk(root, excluded)
|
|
@@ -65,6 +67,9 @@ def scan_files(root: Path, excludes: Iterable[str] = (), max_bytes: int = 1_500_
|
|
|
65
67
|
for path in sorted(paths):
|
|
66
68
|
if not path.is_file():
|
|
67
69
|
continue
|
|
70
|
+
relative = path.relative_to(root).as_posix()
|
|
71
|
+
if includes and not any(fnmatch.fnmatch(relative, pattern) or (pattern.startswith("**/") and fnmatch.fnmatch(relative, pattern[3:])) for pattern in includes):
|
|
72
|
+
continue
|
|
68
73
|
if _is_excluded(path, root, excluded):
|
|
69
74
|
continue
|
|
70
75
|
if path.suffix and path.suffix.lower() not in TEXT_EXTENSIONS:
|
|
@@ -130,5 +135,8 @@ def _is_excluded(path: Path, root: Path, excluded: Sequence[str]) -> bool:
|
|
|
130
135
|
parts = path.resolve().relative_to(root).parts
|
|
131
136
|
except ValueError:
|
|
132
137
|
return True
|
|
133
|
-
|
|
138
|
+
relative = "/".join(parts)
|
|
139
|
+
if any(fnmatch.fnmatch(relative, pattern) or fnmatch.fnmatch(relative + "/", pattern.rstrip("/") + "/") for pattern in excluded):
|
|
140
|
+
return True
|
|
141
|
+
return any(part in excluded for part in parts) or any("/" in pattern and ("/" + "/".join(parts) + "/").find("/" + pattern.strip("/") + "/") >= 0 for pattern in excluded)
|
|
134
142
|
|