@guanlin4924/devflow-pi 0.1.73 → 0.1.75
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/skills/workflow-code-index/scripts/adapters/structured_scope.py +33 -2
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +43 -0
- package/src/constants.ts +1 -1
- package/src/initialization-store.ts +15 -0
- package/src/project-initializer.ts +32 -6
package/package.json
CHANGED
package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/structured_scope.py
CHANGED
|
@@ -131,8 +131,39 @@ class StructuredScopeAdapter(Adapter):
|
|
|
131
131
|
|
|
132
132
|
|
|
133
133
|
def _without_strings_and_comments(line: str) -> str:
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
"""Mask strings/comments without backtracking on malformed source lines.
|
|
135
|
+
|
|
136
|
+
The old back-reference regex could spend unbounded time on an unterminated
|
|
137
|
+
quote. This scanner is linear and keeps character positions stable for
|
|
138
|
+
declaration matching.
|
|
139
|
+
"""
|
|
140
|
+
chars = list(line)
|
|
141
|
+
quote = None
|
|
142
|
+
escaped = False
|
|
143
|
+
index = 0
|
|
144
|
+
while index < len(line):
|
|
145
|
+
character = line[index]
|
|
146
|
+
if quote is not None:
|
|
147
|
+
chars[index] = " "
|
|
148
|
+
if escaped:
|
|
149
|
+
escaped = False
|
|
150
|
+
elif character == "\\":
|
|
151
|
+
escaped = True
|
|
152
|
+
elif character == quote:
|
|
153
|
+
quote = None
|
|
154
|
+
index += 1
|
|
155
|
+
continue
|
|
156
|
+
if character in {"'", '"', "`"}:
|
|
157
|
+
quote = character
|
|
158
|
+
chars[index] = " "
|
|
159
|
+
index += 1
|
|
160
|
+
continue
|
|
161
|
+
if character == "#" or (character == "/" and index + 1 < len(line) and line[index + 1] == "/"):
|
|
162
|
+
for position in range(index, len(chars)):
|
|
163
|
+
chars[position] = " "
|
|
164
|
+
break
|
|
165
|
+
index += 1
|
|
166
|
+
return "".join(chars)
|
|
136
167
|
|
|
137
168
|
|
|
138
169
|
def _brace_end(lines: List[str], start: int) -> int:
|
|
@@ -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,16 +260,21 @@ 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:
|
|
245
275
|
continue
|
|
276
|
+
if parsed == 0 or parsed % 100 == 0:
|
|
277
|
+
progress.emit("parse", f"parsing {rel_path}", codebase=codebase.name, parsed=parsed, total=len(all_files), path=rel_path)
|
|
246
278
|
adapter = adapters[-1]
|
|
247
279
|
try:
|
|
248
280
|
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
@@ -266,11 +298,17 @@ def command_scan(args) -> int:
|
|
|
266
298
|
diagnostics.extend(result.diagnostics)
|
|
267
299
|
adapter_entities.extend(getattr(result, "entities", []) or [])
|
|
268
300
|
adapter_flows.extend(getattr(result, "flows", []) or [])
|
|
301
|
+
parsed += 1
|
|
302
|
+
if parsed == 1 or parsed % 250 == 0 or parsed == len(all_files):
|
|
303
|
+
progress.emit("parse", f"parsed {parsed}/{len(all_files)} files in {codebase.name}", codebase=codebase.name, parsed=parsed, total=len(all_files))
|
|
269
304
|
except Exception as exc: # keep indexing useful even when one file fails
|
|
270
305
|
diagnostics.append(_diagnostic(codebase.name, rel_path, 1, "error", "parse_failed", str(exc), adapter.parser_id))
|
|
306
|
+
parsed += 1
|
|
271
307
|
|
|
308
|
+
progress.emit("relations", "resolving relations", files=len(files), symbols=len(symbols), relations=len(relations))
|
|
272
309
|
relations = _resolve_relations(symbols, relations)
|
|
273
310
|
files, symbols, relations, diagnostics = _dedupe(files, symbols, relations, diagnostics)
|
|
311
|
+
progress.emit("validate", "validating extracted facts", files=len(files), symbols=len(symbols), relations=len(relations), diagnostics=len(diagnostics))
|
|
274
312
|
errors = validate_index(files, symbols, relations, diagnostics)
|
|
275
313
|
if errors:
|
|
276
314
|
for error in errors[:200]:
|
|
@@ -285,6 +323,7 @@ def command_scan(args) -> int:
|
|
|
285
323
|
workspace_state["generatedAt"] = datetime.now(timezone.utc).isoformat()
|
|
286
324
|
workspace_state["semanticExtractor"] = SEMANTIC_EXTRACTOR_VERSION
|
|
287
325
|
workspace_state["extractorSetHash"] = _hash_lines([*workspace_state.get("extractors", []), SEMANTIC_EXTRACTOR_VERSION])
|
|
326
|
+
progress.emit("semantic", "building semantic facts")
|
|
288
327
|
semantic_entities, semantic_flows, feature_bindings = build_semantic_facts(root, files, symbols, relations)
|
|
289
328
|
semantic_entities.extend(adapter_entities)
|
|
290
329
|
semantic_flows.extend(adapter_flows)
|
|
@@ -292,6 +331,7 @@ def command_scan(args) -> int:
|
|
|
292
331
|
write_jsonl(staging / INDEX_FILES["symbols"], symbols)
|
|
293
332
|
write_jsonl(staging / INDEX_FILES["relations"], relations)
|
|
294
333
|
write_jsonl(staging / INDEX_FILES["diagnostics"], diagnostics)
|
|
334
|
+
progress.emit("sqlite", "writing sqlite index")
|
|
295
335
|
write_sqlite(
|
|
296
336
|
staging / "index.sqlite", files, symbols, relations, diagnostics,
|
|
297
337
|
snapshot_id=generation,
|
|
@@ -300,6 +340,7 @@ def command_scan(args) -> int:
|
|
|
300
340
|
semantic_flows=semantic_flows,
|
|
301
341
|
feature_bindings=feature_bindings,
|
|
302
342
|
)
|
|
343
|
+
progress.emit("documents", "writing index projections")
|
|
303
344
|
write_markdown(staging, files, symbols, relations, diagnostics)
|
|
304
345
|
staged_errors = validate_index(*_load_tuple(staging))
|
|
305
346
|
if staged_errors:
|
|
@@ -307,6 +348,7 @@ def command_scan(args) -> int:
|
|
|
307
348
|
print(error, file=sys.stderr)
|
|
308
349
|
print("Staged index validation failed; the previous snapshot was preserved.", file=sys.stderr)
|
|
309
350
|
return 1
|
|
351
|
+
progress.emit("publish", "publishing immutable snapshot", generation=generation)
|
|
310
352
|
_publish_snapshot(
|
|
311
353
|
staging,
|
|
312
354
|
output,
|
|
@@ -322,6 +364,7 @@ def command_scan(args) -> int:
|
|
|
322
364
|
finally:
|
|
323
365
|
shutil.rmtree(staging, ignore_errors=True)
|
|
324
366
|
|
|
367
|
+
progress.emit("complete", "scan completed", files=len(files), symbols=len(symbols), relations=len(relations), diagnostics=len(diagnostics))
|
|
325
368
|
print(f"Indexed {len(files)} files, {len(symbols)} symbols, {len(relations)} relations, {len(diagnostics)} diagnostics.")
|
|
326
369
|
print(f"Output: {output}")
|
|
327
370
|
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}`);
|
|
@@ -193,9 +193,24 @@ function collect(files: SourceFile[], context: ProjectContext, config: ScanConfi
|
|
|
193
193
|
return facts;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
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 }> {
|
|
197
197
|
const child = Bun.spawn(argv, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
198
|
-
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
|
+
]);
|
|
199
214
|
if (exitCode !== 0) throw new Error(`${path.basename(argv[1] ?? argv[0])} failed (${exitCode}): ${(stderr || stdout).trim()}`);
|
|
200
215
|
return { stdout: stdout.trim(), stderr: stderr.trim() };
|
|
201
216
|
}
|
|
@@ -204,9 +219,9 @@ async function buildCodeIndex(context: ProjectContext, config: ScanConfigInput):
|
|
|
204
219
|
const script = path.resolve(import.meta.dir, "../plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py");
|
|
205
220
|
const output = `.planning/.code-index-pending-${randomUUID()}`;
|
|
206
221
|
const codebases = config.repositories.flatMap(repository => ["--codebase", `${repository.name}:${path.resolve(context.root, repository.relativePath)}:${repository.role}`]);
|
|
207
|
-
await runProcess(["python3", script, "scan", "--root", context.root, "--output", output, ...codebases], context.root);
|
|
208
|
-
await runProcess(["python3", script, "validate", "--index", output], context.root);
|
|
209
|
-
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");
|
|
210
225
|
const snapshotPath = path.join(context.root, output, "SNAPSHOT.json");
|
|
211
226
|
const bytes = await readFile(snapshotPath); const snapshot = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
|
|
212
227
|
if (!snapshot.generation || !snapshot.immutablePath) throw new Error("Code index did not publish an immutable generation");
|
|
@@ -239,6 +254,7 @@ export async function runProjectInitialization(context: ProjectContext, input: {
|
|
|
239
254
|
const config: ScanConfigInput = input.config ?? { mode: "full", repositories: [{ name: context.manifest.name, relativePath: ".", role: "other" }], requiredKinds: [...FACT_KINDS], adapterVersions: { static: "1" } };
|
|
240
255
|
const store = await InitializationStore.open(context); const diagnostics: string[] = [];
|
|
241
256
|
let activeRun: { id: string; leaseToken: string } | undefined;
|
|
257
|
+
let leaseHeartbeat: ReturnType<typeof setInterval> | undefined;
|
|
242
258
|
try {
|
|
243
259
|
const workspace = await store.registerWorkspace(); const storedConfig = store.putScanConfig(config);
|
|
244
260
|
const initialScan = await scan(context, config); const files = initialScan.files;
|
|
@@ -255,6 +271,13 @@ export async function runProjectInitialization(context: ProjectContext, input: {
|
|
|
255
271
|
workspaceId: context.workspaceId,
|
|
256
272
|
planningRoot: context.planningRoot,
|
|
257
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);
|
|
258
281
|
const grouped = collect(files, context, config); const required = new Set(config.requiredKinds ?? FACT_KINDS);
|
|
259
282
|
for (const repository of config.repositories) for (const kind of FACT_KINDS) {
|
|
260
283
|
const repositoryFacts = grouped.get(kind)!.filter(fact => fact.repository === repository.name);
|
|
@@ -331,5 +354,8 @@ export async function runProjectInitialization(context: ProjectContext, input: {
|
|
|
331
354
|
diagnostics.push(message);
|
|
332
355
|
if (activeRun) store.failRun(activeRun.id, activeRun.leaseToken, message);
|
|
333
356
|
throw error;
|
|
334
|
-
} finally {
|
|
357
|
+
} finally {
|
|
358
|
+
if (leaseHeartbeat) clearInterval(leaseHeartbeat);
|
|
359
|
+
store.close();
|
|
360
|
+
}
|
|
335
361
|
}
|