@guanlin4924/devflow-pi 0.1.72 → 0.1.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@ import sqlite3
|
|
|
9
9
|
import subprocess
|
|
10
10
|
import sys
|
|
11
11
|
import tempfile
|
|
12
|
+
import time
|
|
12
13
|
import uuid
|
|
13
14
|
from datetime import datetime, timezone
|
|
14
15
|
from pathlib import Path
|
|
@@ -155,6 +156,29 @@ def _hash_lines(values) -> str:
|
|
|
155
156
|
return f"uuid5:{digest}"
|
|
156
157
|
|
|
157
158
|
|
|
159
|
+
class _Progress:
|
|
160
|
+
"""Emit human-readable progress immediately and retain a machine-readable trail."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, output: Path):
|
|
163
|
+
self.output = output
|
|
164
|
+
self.output.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
self.path = output / "scan-progress.jsonl"
|
|
166
|
+
self.started = time.monotonic()
|
|
167
|
+
|
|
168
|
+
def emit(self, phase: str, message: str, **details) -> None:
|
|
169
|
+
event = {
|
|
170
|
+
"at": datetime.now(timezone.utc).isoformat(),
|
|
171
|
+
"elapsedSeconds": round(time.monotonic() - self.started, 3),
|
|
172
|
+
"phase": phase,
|
|
173
|
+
"message": message,
|
|
174
|
+
**details,
|
|
175
|
+
}
|
|
176
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
177
|
+
handle.write(json.dumps(event, ensure_ascii=False) + "\n")
|
|
178
|
+
handle.flush()
|
|
179
|
+
print(f"[code-index][{phase}] {message}", file=sys.stderr, flush=True)
|
|
180
|
+
|
|
181
|
+
|
|
158
182
|
def main(argv=None) -> int:
|
|
159
183
|
parser = argparse.ArgumentParser(description="Build and validate a generic code intelligence index.")
|
|
160
184
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
@@ -215,7 +239,10 @@ def main(argv=None) -> int:
|
|
|
215
239
|
def command_scan(args) -> int:
|
|
216
240
|
root = Path(args.root).resolve()
|
|
217
241
|
output = (root / args.output).resolve() if not Path(args.output).is_absolute() else Path(args.output).resolve()
|
|
242
|
+
progress = _Progress(output)
|
|
243
|
+
progress.emit("start", "scan started", root=str(root), output=str(output), codebases=args.codebase)
|
|
218
244
|
with _index_lock(output, exclusive=True):
|
|
245
|
+
progress.emit("lock", "exclusive index lock acquired")
|
|
219
246
|
codebases = discover_codebases(root, args.codebase)
|
|
220
247
|
registry = AdapterRegistry()
|
|
221
248
|
changed = _normalize_changed_files(root, args.changed_files)
|
|
@@ -233,12 +260,15 @@ def command_scan(args) -> int:
|
|
|
233
260
|
if not codebase.root.exists():
|
|
234
261
|
diagnostics.append(_diagnostic(codebase.name, "", 1, "warning", "skipped_file", f"codebase root does not exist: {codebase.root}", "discovery@1"))
|
|
235
262
|
continue
|
|
263
|
+
progress.emit("discover", f"discovering {codebase.name}", codebase=codebase.name, path=str(codebase.root))
|
|
236
264
|
all_files = scan_files(codebase.root, excludes=args.exclude)
|
|
265
|
+
progress.emit("discover", f"discovered {len(all_files)} files in {codebase.name}", codebase=codebase.name, files=len(all_files))
|
|
237
266
|
discovered_paths.update(_workspace_rel(root, path) for path in all_files)
|
|
238
267
|
if changed:
|
|
239
268
|
all_files = [path for path in all_files if _workspace_rel(root, path) in changed]
|
|
240
269
|
manifests = [path for path in all_files if path.name in {"package.json", "go.mod", "Cargo.toml", "pyproject.toml", "requirements.txt", "composer.json", "pubspec.yaml"}]
|
|
241
270
|
adapters = registry.adapters_for(codebase, all_files, manifests)
|
|
271
|
+
parsed = 0
|
|
242
272
|
for path in all_files:
|
|
243
273
|
rel_path = _workspace_rel(root, path)
|
|
244
274
|
if rel_path in indexed_paths:
|
|
@@ -266,11 +296,17 @@ def command_scan(args) -> int:
|
|
|
266
296
|
diagnostics.extend(result.diagnostics)
|
|
267
297
|
adapter_entities.extend(getattr(result, "entities", []) or [])
|
|
268
298
|
adapter_flows.extend(getattr(result, "flows", []) or [])
|
|
299
|
+
parsed += 1
|
|
300
|
+
if parsed == 1 or parsed % 250 == 0 or parsed == len(all_files):
|
|
301
|
+
progress.emit("parse", f"parsed {parsed}/{len(all_files)} files in {codebase.name}", codebase=codebase.name, parsed=parsed, total=len(all_files))
|
|
269
302
|
except Exception as exc: # keep indexing useful even when one file fails
|
|
270
303
|
diagnostics.append(_diagnostic(codebase.name, rel_path, 1, "error", "parse_failed", str(exc), adapter.parser_id))
|
|
304
|
+
parsed += 1
|
|
271
305
|
|
|
306
|
+
progress.emit("relations", "resolving relations", files=len(files), symbols=len(symbols), relations=len(relations))
|
|
272
307
|
relations = _resolve_relations(symbols, relations)
|
|
273
308
|
files, symbols, relations, diagnostics = _dedupe(files, symbols, relations, diagnostics)
|
|
309
|
+
progress.emit("validate", "validating extracted facts", files=len(files), symbols=len(symbols), relations=len(relations), diagnostics=len(diagnostics))
|
|
274
310
|
errors = validate_index(files, symbols, relations, diagnostics)
|
|
275
311
|
if errors:
|
|
276
312
|
for error in errors[:200]:
|
|
@@ -285,6 +321,7 @@ def command_scan(args) -> int:
|
|
|
285
321
|
workspace_state["generatedAt"] = datetime.now(timezone.utc).isoformat()
|
|
286
322
|
workspace_state["semanticExtractor"] = SEMANTIC_EXTRACTOR_VERSION
|
|
287
323
|
workspace_state["extractorSetHash"] = _hash_lines([*workspace_state.get("extractors", []), SEMANTIC_EXTRACTOR_VERSION])
|
|
324
|
+
progress.emit("semantic", "building semantic facts")
|
|
288
325
|
semantic_entities, semantic_flows, feature_bindings = build_semantic_facts(root, files, symbols, relations)
|
|
289
326
|
semantic_entities.extend(adapter_entities)
|
|
290
327
|
semantic_flows.extend(adapter_flows)
|
|
@@ -292,6 +329,7 @@ def command_scan(args) -> int:
|
|
|
292
329
|
write_jsonl(staging / INDEX_FILES["symbols"], symbols)
|
|
293
330
|
write_jsonl(staging / INDEX_FILES["relations"], relations)
|
|
294
331
|
write_jsonl(staging / INDEX_FILES["diagnostics"], diagnostics)
|
|
332
|
+
progress.emit("sqlite", "writing sqlite index")
|
|
295
333
|
write_sqlite(
|
|
296
334
|
staging / "index.sqlite", files, symbols, relations, diagnostics,
|
|
297
335
|
snapshot_id=generation,
|
|
@@ -300,6 +338,7 @@ def command_scan(args) -> int:
|
|
|
300
338
|
semantic_flows=semantic_flows,
|
|
301
339
|
feature_bindings=feature_bindings,
|
|
302
340
|
)
|
|
341
|
+
progress.emit("documents", "writing index projections")
|
|
303
342
|
write_markdown(staging, files, symbols, relations, diagnostics)
|
|
304
343
|
staged_errors = validate_index(*_load_tuple(staging))
|
|
305
344
|
if staged_errors:
|
|
@@ -307,6 +346,7 @@ def command_scan(args) -> int:
|
|
|
307
346
|
print(error, file=sys.stderr)
|
|
308
347
|
print("Staged index validation failed; the previous snapshot was preserved.", file=sys.stderr)
|
|
309
348
|
return 1
|
|
349
|
+
progress.emit("publish", "publishing immutable snapshot", generation=generation)
|
|
310
350
|
_publish_snapshot(
|
|
311
351
|
staging,
|
|
312
352
|
output,
|
|
@@ -322,6 +362,7 @@ def command_scan(args) -> int:
|
|
|
322
362
|
finally:
|
|
323
363
|
shutil.rmtree(staging, ignore_errors=True)
|
|
324
364
|
|
|
365
|
+
progress.emit("complete", "scan completed", files=len(files), symbols=len(symbols), relations=len(relations), diagnostics=len(diagnostics))
|
|
325
366
|
print(f"Indexed {len(files)} files, {len(symbols)} symbols, {len(relations)} relations, {len(diagnostics)} diagnostics.")
|
|
326
367
|
print(f"Output: {output}")
|
|
327
368
|
return 0
|
package/src/constants.ts
CHANGED
|
@@ -383,12 +383,27 @@ export class InitializationStore {
|
|
|
383
383
|
return { id: prior.id, leaseToken: prior.leaseToken, reused: true };
|
|
384
384
|
}
|
|
385
385
|
}
|
|
386
|
+
const active = this.db.query(`SELECT id FROM initialization_runs
|
|
387
|
+
WHERE workspace_id=? AND config_id=?
|
|
388
|
+
AND status IN ('preparing','collecting','validating')
|
|
389
|
+
AND lease_expires_at>? LIMIT 1`).get(input.workspaceId, input.configId, now()) as { id: string } | null;
|
|
390
|
+
if (active) throw new Error(`Initialization already running for workspace/config: ${active.id}`);
|
|
386
391
|
const id = randomUUID(); const leaseToken = randomUUID(); const timestamp = now();
|
|
387
392
|
const expires = new Date(Date.now() + (input.leaseMs ?? 15 * 60_000)).toISOString();
|
|
388
393
|
this.db.query("INSERT INTO initialization_runs(id,project_id,workspace_id,config_id,input_snapshot_id,request_id,request_hash,status,lease_token,lease_expires_at,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)").run(id, this.context.manifest.projectId, input.workspaceId, input.configId, input.inputSnapshotId, input.requestId ?? null, requestHash, "collecting", leaseToken, expires, timestamp, timestamp);
|
|
389
394
|
return { id, leaseToken, reused: false };
|
|
390
395
|
}
|
|
391
396
|
|
|
397
|
+
renewRunLease(runId: string, leaseToken: string, leaseMs = 15 * 60_000): boolean {
|
|
398
|
+
const timestamp = now();
|
|
399
|
+
const result = this.db.query(`UPDATE initialization_runs
|
|
400
|
+
SET lease_expires_at=?,updated_at=?
|
|
401
|
+
WHERE id=? AND lease_token=? AND status IN ('preparing','collecting','validating')`).run(
|
|
402
|
+
new Date(Date.now() + leaseMs).toISOString(), timestamp, runId, leaseToken,
|
|
403
|
+
);
|
|
404
|
+
return result.changes === 1;
|
|
405
|
+
}
|
|
406
|
+
|
|
392
407
|
private assertWritableRun(runId: string, leaseToken: string): { status: RunStatus; inputSnapshotId: string; workspaceId: string; configId: string } {
|
|
393
408
|
const row = this.db.query("SELECT status,input_snapshot_id AS inputSnapshotId,workspace_id AS workspaceId,config_id AS configId,lease_token AS leaseToken,lease_expires_at AS expires FROM initialization_runs WHERE id=?").get(runId) as ({ status: RunStatus; inputSnapshotId: string; workspaceId: string; configId: string; leaseToken: string; expires: string }) | null;
|
|
394
409
|
if (!row) throw new Error(`Unknown initialization run: ${runId}`);
|
|
@@ -402,11 +417,21 @@ export class InitializationStore {
|
|
|
402
417
|
this.assertWritableRun(runId, leaseToken);
|
|
403
418
|
const transaction = this.db.transaction(() => {
|
|
404
419
|
const repository = input.repository ?? "";
|
|
405
|
-
|
|
406
|
-
|
|
420
|
+
if (repository) {
|
|
421
|
+
this.db.query("DELETE FROM evidence WHERE fact_id IN (SELECT id FROM facts WHERE run_id=? AND repository=? AND kind=?)").run(runId, repository, input.kind);
|
|
422
|
+
this.db.query("DELETE FROM facts WHERE run_id=? AND repository=? AND kind=?").run(runId, repository, input.kind);
|
|
423
|
+
} else {
|
|
424
|
+
this.db.query("DELETE FROM evidence WHERE fact_id IN (SELECT id FROM facts WHERE run_id=? AND kind=?)").run(runId, input.kind);
|
|
425
|
+
this.db.query("DELETE FROM facts WHERE run_id=? AND kind=?").run(runId, input.kind);
|
|
426
|
+
}
|
|
407
427
|
this.db.query("INSERT INTO collection_results(id,run_id,repository,kind,status,adapter,capability_json,diagnostics_json,created_at) VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT(run_id,repository,kind) DO UPDATE SET status=excluded.status,adapter=excluded.adapter,capability_json=excluded.capability_json,diagnostics_json=excluded.diagnostics_json,created_at=excluded.created_at").run(randomUUID(), runId, repository, input.kind, input.status, input.adapter, canonicalJson(input.capability ?? {}), canonicalJson(input.diagnostics ?? []), now());
|
|
408
|
-
|
|
428
|
+
const uniqueFacts = new Map<string, FactInput>();
|
|
429
|
+
for (const fact of input.facts ?? []) uniqueFacts.set(`${fact.repository ?? repository}\0${fact.objectKey}`, fact);
|
|
430
|
+
for (const fact of uniqueFacts.values()) {
|
|
409
431
|
if (fact.kind !== input.kind) throw new Error(`Fact kind ${fact.kind} does not match collection ${input.kind}`);
|
|
432
|
+
if (repository && fact.repository !== undefined && fact.repository !== repository) {
|
|
433
|
+
throw new Error(`Fact repository ${fact.repository} does not match collection repository ${repository}`);
|
|
434
|
+
}
|
|
410
435
|
if (!fact.objectKey || !fact.extractor) throw new Error("Facts require objectKey and extractor");
|
|
411
436
|
if (fact.evidenceLevel === "observed" && !fact.sourcePath) throw new Error("Observed facts require source evidence");
|
|
412
437
|
validateFactShape(fact);
|
|
@@ -165,12 +165,52 @@ function collect(files: SourceFile[], context: ProjectContext, config: ScanConfi
|
|
|
165
165
|
facts.get("evidence")!.push({ kind: "evidence", repository: repository.name, objectKey: "input-policy", value: { source: "local-static", discoveredCommandsExecuted: false, gitMetadataRead: true, secretsStored: false }, evidenceLevel: "explicit_rule", extractor: "initializer@1", confidence: "exact" });
|
|
166
166
|
facts.get("capability")!.push({ kind: "capability", repository: repository.name, objectKey: "collector:static@1", value: { supported: ["asset", "technology", "api", "page", "data_model", "integration", "rule", "toolchain"], limitations: ["static declarations only", "dynamic behavior unresolved", "no environment enablement inference"], project: context.manifest.name }, evidenceLevel: "explicit_rule", extractor: "initializer@1", confidence: "exact" });
|
|
167
167
|
}
|
|
168
|
+
// A repository can declare the same route/model/page through more than one
|
|
169
|
+
// scanner (or declare it twice). The control-plane key is intentionally
|
|
170
|
+
// unique per run/kind/repository/object_key, so collapse duplicates before
|
|
171
|
+
// handing facts to the store while retaining the richest value/evidence.
|
|
172
|
+
for (const [kind, entries] of facts) {
|
|
173
|
+
const deduped = new Map<string, FactInput>();
|
|
174
|
+
for (const entry of entries) {
|
|
175
|
+
const key = `${entry.repository ?? ""}\0${entry.objectKey}`;
|
|
176
|
+
const previous = deduped.get(key);
|
|
177
|
+
if (!previous) {
|
|
178
|
+
deduped.set(key, entry);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
deduped.set(key, {
|
|
182
|
+
...previous,
|
|
183
|
+
...entry,
|
|
184
|
+
repository: entry.repository ?? previous.repository,
|
|
185
|
+
value: { ...(previous.value ?? {}), ...(entry.value ?? {}) },
|
|
186
|
+
sourcePath: entry.sourcePath ?? previous.sourcePath,
|
|
187
|
+
sourceLine: entry.sourceLine ?? previous.sourceLine,
|
|
188
|
+
sourceFingerprint: entry.sourceFingerprint ?? previous.sourceFingerprint,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
facts.set(kind, [...deduped.values()]);
|
|
192
|
+
}
|
|
168
193
|
return facts;
|
|
169
194
|
}
|
|
170
195
|
|
|
171
|
-
async function runProcess(argv: string[], cwd: string): Promise<{ stdout: string; stderr: string }> {
|
|
196
|
+
async function runProcess(argv: string[], cwd: string, label = path.basename(argv[1] ?? argv[0])): Promise<{ stdout: string; stderr: string }> {
|
|
172
197
|
const child = Bun.spawn(argv, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
173
|
-
const
|
|
198
|
+
const readStream = async (stream: ReadableStream<Uint8Array>, emit?: (line: string) => void): Promise<string> => {
|
|
199
|
+
const reader = stream.getReader(); const decoder = new TextDecoder(); let text = ""; const chunks: string[] = [];
|
|
200
|
+
while (true) {
|
|
201
|
+
const chunk = await reader.read(); if (chunk.done) break;
|
|
202
|
+
const decoded = decoder.decode(chunk.value, { stream: true }); chunks.push(decoded); text += decoded;
|
|
203
|
+
const lines = text.split(/\r?\n/); text = lines.pop() ?? "";
|
|
204
|
+
for (const line of lines) if (line) emit?.(line);
|
|
205
|
+
}
|
|
206
|
+
const tail = decoder.decode(); chunks.push(tail); text += tail; if (text) emit?.(text);
|
|
207
|
+
return chunks.join("");
|
|
208
|
+
};
|
|
209
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
210
|
+
child.exited,
|
|
211
|
+
readStream(child.stdout),
|
|
212
|
+
readStream(child.stderr, line => console.error(`[${label}] ${line}`)),
|
|
213
|
+
]);
|
|
174
214
|
if (exitCode !== 0) throw new Error(`${path.basename(argv[1] ?? argv[0])} failed (${exitCode}): ${(stderr || stdout).trim()}`);
|
|
175
215
|
return { stdout: stdout.trim(), stderr: stderr.trim() };
|
|
176
216
|
}
|
|
@@ -179,9 +219,9 @@ async function buildCodeIndex(context: ProjectContext, config: ScanConfigInput):
|
|
|
179
219
|
const script = path.resolve(import.meta.dir, "../plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py");
|
|
180
220
|
const output = `.planning/.code-index-pending-${randomUUID()}`;
|
|
181
221
|
const codebases = config.repositories.flatMap(repository => ["--codebase", `${repository.name}:${path.resolve(context.root, repository.relativePath)}:${repository.role}`]);
|
|
182
|
-
await runProcess(["python3", script, "scan", "--root", context.root, "--output", output, ...codebases], context.root);
|
|
183
|
-
await runProcess(["python3", script, "validate", "--index", output], context.root);
|
|
184
|
-
await runProcess(["python3", script, "audit", "--root", context.root, "--index", output, "--skill-root", path.resolve(import.meta.dir, "../plugins/devflow-intel/skills")], context.root);
|
|
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");
|
|
185
225
|
const snapshotPath = path.join(context.root, output, "SNAPSHOT.json");
|
|
186
226
|
const bytes = await readFile(snapshotPath); const snapshot = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
|
|
187
227
|
if (!snapshot.generation || !snapshot.immutablePath) throw new Error("Code index did not publish an immutable generation");
|
|
@@ -214,6 +254,7 @@ export async function runProjectInitialization(context: ProjectContext, input: {
|
|
|
214
254
|
const config: ScanConfigInput = input.config ?? { mode: "full", repositories: [{ name: context.manifest.name, relativePath: ".", role: "other" }], requiredKinds: [...FACT_KINDS], adapterVersions: { static: "1" } };
|
|
215
255
|
const store = await InitializationStore.open(context); const diagnostics: string[] = [];
|
|
216
256
|
let activeRun: { id: string; leaseToken: string } | undefined;
|
|
257
|
+
let leaseHeartbeat: ReturnType<typeof setInterval> | undefined;
|
|
217
258
|
try {
|
|
218
259
|
const workspace = await store.registerWorkspace(); const storedConfig = store.putScanConfig(config);
|
|
219
260
|
const initialScan = await scan(context, config); const files = initialScan.files;
|
|
@@ -230,6 +271,13 @@ export async function runProjectInitialization(context: ProjectContext, input: {
|
|
|
230
271
|
workspaceId: context.workspaceId,
|
|
231
272
|
planningRoot: context.planningRoot,
|
|
232
273
|
};
|
|
274
|
+
leaseHeartbeat = setInterval(() => {
|
|
275
|
+
try {
|
|
276
|
+
if (!store.renewRunLease(run.id, run.leaseToken)) diagnostics.push("Initialization lease heartbeat could not be renewed");
|
|
277
|
+
} catch (error) {
|
|
278
|
+
diagnostics.push(`Initialization lease heartbeat failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
279
|
+
}
|
|
280
|
+
}, 60_000);
|
|
233
281
|
const grouped = collect(files, context, config); const required = new Set(config.requiredKinds ?? FACT_KINDS);
|
|
234
282
|
for (const repository of config.repositories) for (const kind of FACT_KINDS) {
|
|
235
283
|
const repositoryFacts = grouped.get(kind)!.filter(fact => fact.repository === repository.name);
|
|
@@ -306,5 +354,8 @@ export async function runProjectInitialization(context: ProjectContext, input: {
|
|
|
306
354
|
diagnostics.push(message);
|
|
307
355
|
if (activeRun) store.failRun(activeRun.id, activeRun.leaseToken, message);
|
|
308
356
|
throw error;
|
|
309
|
-
} finally {
|
|
357
|
+
} finally {
|
|
358
|
+
if (leaseHeartbeat) clearInterval(leaseHeartbeat);
|
|
359
|
+
store.close();
|
|
360
|
+
}
|
|
310
361
|
}
|