@guanlin4924/devflow-pi 0.1.62 → 0.1.64

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.
@@ -3,6 +3,7 @@ import argparse
3
3
  import contextlib
4
4
  import json
5
5
  import os
6
+ import re
6
7
  import shutil
7
8
  import sqlite3
8
9
  import subprocess
@@ -59,12 +60,14 @@ def _index_lock(output: Path, exclusive: bool):
59
60
 
60
61
  def _publish_snapshot(staging: Path, output: Path, counts: Dict[str, int], workspace_state: Dict[str, object]) -> None:
61
62
  generation = str(uuid.uuid4())
63
+ immutable_relative = f"snapshots/{generation}"
62
64
  snapshot = {
63
65
  "schemaVersion": 1,
64
66
  "generation": generation,
65
67
  "generatedAt": datetime.now(timezone.utc).isoformat(),
66
68
  "counts": counts,
67
69
  "workspace": workspace_state,
70
+ "immutablePath": immutable_relative,
68
71
  "artifacts": [*INDEX_FILES.values(), "index.sqlite", *MARKDOWN_FILES],
69
72
  }
70
73
  snapshot_path = staging / SNAPSHOT_FILE
@@ -72,9 +75,21 @@ def _publish_snapshot(staging: Path, output: Path, counts: Dict[str, int], works
72
75
  with snapshot_path.open("rb") as handle:
73
76
  os.fsync(handle.fileno())
74
77
 
78
+ snapshots = output / "snapshots"
79
+ snapshots.mkdir(parents=True, exist_ok=True)
80
+ immutable = snapshots / generation
81
+ os.replace(staging, immutable)
82
+
83
+ # Existing consumers keep reading the root paths. They are projections of
84
+ # one immutable generation; SNAPSHOT.json is switched last as the commit
85
+ # marker so readers never accept an uncommitted generation.
75
86
  for name in snapshot["artifacts"]:
76
- os.replace(staging / name, output / name)
77
- os.replace(snapshot_path, output / SNAPSHOT_FILE)
87
+ source = immutable / name
88
+ temporary = output / f".{name}.{generation}.tmp"
89
+ shutil.copy2(source, temporary)
90
+ os.replace(temporary, output / name)
91
+ shutil.copy2(immutable / SNAPSHOT_FILE, output / f".{SNAPSHOT_FILE}.{generation}.tmp")
92
+ os.replace(output / f".{SNAPSHOT_FILE}.{generation}.tmp", output / SNAPSHOT_FILE)
78
93
  directory_fd = os.open(output, os.O_RDONLY)
79
94
  try:
80
95
  os.fsync(directory_fd)
@@ -90,6 +105,12 @@ def _workspace_state(root: Path, files: List[dict], changed: Set[str]) -> Dict[s
90
105
  "contentManifestHash": source_manifest_hash,
91
106
  "indexedPathsHash": _hash_lines(indexed_paths),
92
107
  "dirtyPathsAtBuild": sorted(changed),
108
+ "extractors": sorted(set(record.get("parser", "unknown") for record in files)),
109
+ "capabilities": {
110
+ "python-ast@1": {"scope": "exact syntax tree", "targets": "lexical names; dynamic dispatch unresolved"},
111
+ "structured-scope@1": {"scope": "inferred lexical braces/end blocks", "targets": "name-only heuristic"},
112
+ "generic-text@2": {"scope": "file-level", "targets": "heuristic only"},
113
+ },
93
114
  }
94
115
 
95
116
 
@@ -152,6 +173,15 @@ def main(argv=None) -> int:
152
173
  query.add_argument("--index", default=".planning/code-index", help="Index directory.")
153
174
  query.add_argument("--term", required=True, help="Search term.")
154
175
  query.add_argument("--limit", type=int, default=20)
176
+ query.add_argument("--snapshot", help="Immutable generation id. Historical queries do not require current workspace freshness.")
177
+
178
+ relations = sub.add_parser("relations", help="Traverse resolved relations in a fixed snapshot.")
179
+ relations.add_argument("--index", default=".planning/code-index")
180
+ relations.add_argument("--object", required=True, help="Source symbol id.")
181
+ relations.add_argument("--depth", type=int, default=3)
182
+ relations.add_argument("--limit", type=int, default=100)
183
+ relations.add_argument("--snapshot")
184
+ relations.add_argument("--include-heuristic", action="store_true")
155
185
 
156
186
  args = parser.parse_args(argv)
157
187
  if args.command == "scan":
@@ -162,6 +192,8 @@ def main(argv=None) -> int:
162
192
  return command_audit(args)
163
193
  if args.command == "query":
164
194
  return command_query(args)
195
+ if args.command == "relations":
196
+ return command_relations(args)
165
197
  return 2
166
198
 
167
199
 
@@ -188,11 +220,11 @@ def command_scan(args) -> int:
188
220
  all_files = [path for path in all_files if _workspace_rel(root, path) in changed]
189
221
  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"}]
190
222
  adapters = registry.adapters_for(codebase, all_files, manifests)
191
- adapter = adapters[0]
192
223
  for path in all_files:
193
224
  rel_path = _workspace_rel(root, path)
194
225
  if rel_path in indexed_paths:
195
226
  continue
227
+ adapter = adapters[-1]
196
228
  try:
197
229
  text = path.read_text(encoding="utf-8", errors="ignore")
198
230
  content_hash = file_sha256(path)
@@ -204,8 +236,10 @@ def command_scan(args) -> int:
204
236
  "size_bytes": path.stat().st_size,
205
237
  "content_hash": content_hash,
206
238
  "indexed_at": datetime.now(timezone.utc).isoformat(),
207
- "parser": adapter.parser_id,
239
+ "parser": "pending",
208
240
  }
241
+ adapter = registry.adapter_for_file(codebase, file_record)
242
+ file_record["parser"] = adapter.parser_id
209
243
  result = adapter.parse_file(codebase, file_record, text)
210
244
  files.append(file_record)
211
245
  symbols.extend(result.symbols)
@@ -261,13 +295,14 @@ def command_validate(args) -> int:
261
295
  def command_query(args) -> int:
262
296
  output = Path(args.index).resolve()
263
297
  with _index_lock(output, exclusive=False):
264
- index = output / "index.sqlite"
298
+ selected = _selected_generation(output, getattr(args, "snapshot", None))
299
+ index = selected / "index.sqlite"
265
300
  if not index.exists():
266
301
  print(f"Missing index: {index}", file=sys.stderr)
267
302
  return 1
268
- snapshot = _load_snapshot(output)
269
- files = read_jsonl(output / INDEX_FILES["files"])
270
- if not _query_is_fresh(output.parent.parent, snapshot, files):
303
+ snapshot = _load_snapshot(selected)
304
+ files = read_jsonl(selected / INDEX_FILES["files"])
305
+ if not getattr(args, "snapshot", None) and not _query_is_fresh(output.parent.parent, snapshot, files):
271
306
  print("Stale code index: refresh required before query.", file=sys.stderr)
272
307
  return 1
273
308
  conn = sqlite3.connect(str(index))
@@ -302,6 +337,49 @@ def command_query(args) -> int:
302
337
  return 0
303
338
 
304
339
 
340
+ def command_relations(args) -> int:
341
+ output = Path(args.index).resolve()
342
+ depth = max(0, min(args.depth, 20)); limit = max(1, min(args.limit, 1000))
343
+ with _index_lock(output, exclusive=False):
344
+ selected = _selected_generation(output, getattr(args, "snapshot", None))
345
+ snapshot = _load_snapshot(selected); files = read_jsonl(selected / INDEX_FILES["files"])
346
+ if not getattr(args, "snapshot", None) and not _query_is_fresh(output.parent.parent, snapshot, files):
347
+ print("Stale code index: refresh required before query.", file=sys.stderr); return 1
348
+ conn = sqlite3.connect(str(selected / "index.sqlite")); conn.row_factory = sqlite3.Row
349
+ try:
350
+ if depth == 0:
351
+ print(json.dumps({"_meta": {"snapshot": snapshot.get("generation"), "depthLimit": 0, "resultLimit": limit, "truncated": False}}, ensure_ascii=False, sort_keys=True))
352
+ return 0
353
+ confidence = "" if args.include_heuristic else "AND r.confidence != 'heuristic'"
354
+ rows = conn.execute(f"""
355
+ WITH RECURSIVE graph(from_id,relation_type,to_id,to_external,evidence_path,evidence_line,confidence,depth,visited) AS (
356
+ SELECT r.from_id,r.relation_type,r.to_id,r.to_external,r.evidence_path,r.evidence_line,r.confidence,1,',' || r.from_id || ',' || COALESCE(r.to_id,'') || ','
357
+ FROM relations r WHERE r.from_id=? {confidence}
358
+ UNION ALL
359
+ 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,'') || ','
360
+ FROM graph g JOIN relations r ON r.from_id=g.to_id
361
+ WHERE g.depth < ? AND r.to_id IS NOT NULL {confidence} AND instr(g.visited, ',' || r.to_id || ',')=0
362
+ ) SELECT * FROM graph ORDER BY depth,from_id,relation_type,to_id LIMIT ?
363
+ """, (args.object, depth, limit + 1)).fetchall()
364
+ truncated = len(rows) > limit
365
+ for row in rows[:limit]: print(json.dumps(dict(row), ensure_ascii=False, sort_keys=True))
366
+ print(json.dumps({"_meta": {"snapshot": snapshot.get("generation"), "depthLimit": depth, "resultLimit": limit, "truncated": truncated}}, ensure_ascii=False, sort_keys=True))
367
+ finally: conn.close()
368
+ return 0
369
+
370
+
371
+ def _selected_generation(output: Path, generation: str | None) -> Path:
372
+ if not generation:
373
+ return output
374
+ if not re.fullmatch(r"[0-9a-f-]{36}", generation, re.I):
375
+ raise ValueError("invalid snapshot generation")
376
+ selected = output / "snapshots" / generation
377
+ snapshot = _load_snapshot(selected)
378
+ if snapshot.get("generation") != generation:
379
+ raise ValueError(f"unknown snapshot generation: {generation}")
380
+ return selected
381
+
382
+
305
383
  def _load_snapshot(output: Path) -> Dict[str, object]:
306
384
  path = output / SNAPSHOT_FILE
307
385
  if not path.exists():
@@ -318,6 +396,18 @@ def _query_is_fresh(root: Path, snapshot: Dict[str, object], files: List[dict])
318
396
  indexed_manifest = _hash_lines(f"{record['path']}\0{record['content_hash']}" for record in files)
319
397
  if workspace.get("contentManifestHash") != indexed_manifest:
320
398
  return False
399
+ for record in files:
400
+ source = (root / record["path"]).resolve()
401
+ try:
402
+ if not source.is_file() or file_sha256(source) != record["content_hash"]:
403
+ return False
404
+ except OSError:
405
+ return False
406
+ indexed_paths = sorted(record["path"] for record in files)
407
+ if not any(path.startswith("../") for path in indexed_paths):
408
+ current_paths = sorted(_workspace_rel(root, item) for item in scan_files(root))
409
+ if _hash_lines(current_paths) != workspace.get("indexedPathsHash"):
410
+ return False
321
411
  return True
322
412
 
323
413
 
@@ -432,13 +522,21 @@ def _resolve_relations(symbols: List[dict], relations: List[dict]) -> List[dict]
432
522
  continue
433
523
  name = relation["to_external"][5:]
434
524
  candidates = by_name.get((source["codebase"], name), [])
435
- if len(candidates) == 1:
525
+ resolution = relation.get("metadata", {}).get("resolution")
526
+ may_resolve = resolution == "lexical_name" or relation.get("metadata", {}).get("allow_unique_name_resolution") is True
527
+ if len(candidates) == 1 and may_resolve:
436
528
  upgraded = dict(relation)
437
529
  upgraded["to_id"] = candidates[0]["id"]
438
530
  upgraded["confidence"] = "inferred" if upgraded["confidence"] == "heuristic" else upgraded["confidence"]
439
531
  upgraded.setdefault("metadata", {})["resolution"] = "unique_symbol_name_in_codebase"
440
532
  resolved.append(upgraded)
441
533
  else:
534
+ if candidates:
535
+ unresolved = dict(relation)
536
+ unresolved.setdefault("metadata", {})["resolution"] = "ambiguous_symbol_name"
537
+ unresolved["metadata"]["candidate_ids"] = sorted(candidate["id"] for candidate in candidates)
538
+ resolved.append(unresolved)
539
+ continue
442
540
  resolved.append(relation)
443
541
  return resolved
444
542
 
package/src/cli.ts CHANGED
@@ -11,7 +11,7 @@ import { projectSessionDir } from "./paths.ts";
11
11
  function usage(): string {
12
12
  return [
13
13
  "Usage:",
14
- " devflow-pi init [project]",
14
+ " devflow-pi init [project] [--scan]",
15
15
  " devflow-pi doctor [project]",
16
16
  " devflow-pi usage [project]",
17
17
  " devflow-pi [project] [--resume [session-id]] [--prompt <text>] [--mcp] [--yolo]",
@@ -47,12 +47,22 @@ async function main(): Promise<void> {
47
47
  return;
48
48
  }
49
49
  if (command === "init") {
50
- const requested = args[1] ? path.resolve(args[1]) : process.cwd();
50
+ const projectArgument = args[1] && !args[1].startsWith("-") ? args[1] : undefined;
51
+ const requested = projectArgument ? path.resolve(projectArgument) : process.cwd();
51
52
  const root = await resolveProjectRoot(requested, { explicit: requested, allowUninitialized: true });
52
53
  const { context, created } = await initializeProject(root);
53
54
  console.log(`${created ? "Initialized" : "Already initialized"}: ${context.root}`);
54
55
  console.log(`projectId: ${context.manifest.projectId}`);
56
+ console.log(`workspaceId: ${context.workspaceId}`);
57
+ console.log(`database: ${context.controlDatabasePath}`);
55
58
  console.log(`planning: ${context.planningRoot}`);
59
+ if (args.includes("--scan")) {
60
+ const { runProjectInitialization } = await import("./project-initializer.ts");
61
+ const result = await runProjectInitialization(context, { requestId: option(args, "--request-id") });
62
+ console.log(`initializationRun: ${result.runId}`);
63
+ console.log(`contextSnapshot: ${result.snapshotId}`);
64
+ console.log(`completeness: ${result.completeness}`);
65
+ }
56
66
  return;
57
67
  }
58
68
  const positionalProject = command && !["doctor", "usage"].includes(command) && !command.startsWith("-") ? command : undefined;
package/src/constants.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PRODUCT_NAME = "Devflow Pi";
2
- export const PRODUCT_VERSION = "0.1.62";
2
+ export const PRODUCT_VERSION = "0.1.64";
3
3
  export const PROJECT_SCHEMA_VERSION = 1;
4
4
  export const OMP_RUNTIME_VERSION = "17.2.7";
5
5