pi-plans 0.2.0 → 0.3.0

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.
Files changed (65) hide show
  1. package/README.md +74 -21
  2. package/index.ts +115 -9
  3. package/package.json +7 -1
  4. package/references/pi-planning-workflow.md +18 -3
  5. package/references/state-and-config.md +34 -2
  6. package/scripts/validate.ts +4 -0
  7. package/src/code-graph/commands.ts +437 -0
  8. package/src/code-graph/discovery.ts +118 -0
  9. package/src/code-graph/git.ts +108 -0
  10. package/src/code-graph/identity.ts +59 -0
  11. package/src/code-graph/indexer.ts +281 -0
  12. package/src/code-graph/materialize.ts +166 -0
  13. package/src/code-graph/mode.ts +28 -0
  14. package/src/code-graph/mutations.ts +160 -0
  15. package/src/code-graph/parser.ts +51 -0
  16. package/src/code-graph/parsers/javascript.ts +35 -0
  17. package/src/code-graph/parsers/python.ts +160 -0
  18. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  19. package/src/code-graph/paths.ts +85 -0
  20. package/src/code-graph/prompts.ts +18 -0
  21. package/src/code-graph/resolver.ts +69 -0
  22. package/src/code-graph/runtime.ts +158 -0
  23. package/src/code-graph/schema.ts +135 -0
  24. package/src/code-graph/screening.ts +82 -0
  25. package/src/code-graph/store.ts +278 -0
  26. package/src/code-graph/summary.ts +435 -0
  27. package/src/code-graph/types.ts +163 -0
  28. package/src/compaction.ts +1125 -371
  29. package/src/config-command.ts +326 -0
  30. package/src/exec.ts +356 -686
  31. package/src/refine-prompts.ts +50 -0
  32. package/src/refine-ui-helpers.ts +71 -18
  33. package/src/refine-ui-state.ts +87 -21
  34. package/src/refine-ui.ts +210 -102
  35. package/src/state.ts +19 -6
  36. package/src/subagent.ts +163 -61
  37. package/tests/ask-choice.test.ts +263 -0
  38. package/tests/autocomplete.test.ts +6 -1
  39. package/tests/code-graph-apply.test.ts +185 -0
  40. package/tests/code-graph-commands.test.ts +211 -0
  41. package/tests/code-graph-db.test.ts +166 -0
  42. package/tests/code-graph-discovery.test.ts +38 -0
  43. package/tests/code-graph-git.test.ts +94 -0
  44. package/tests/code-graph-index.test.ts +175 -0
  45. package/tests/code-graph-loop.e2e.test.ts +159 -0
  46. package/tests/code-graph-mutations.test.ts +117 -0
  47. package/tests/code-graph-parser.test.ts +85 -0
  48. package/tests/code-graph-rollback.test.ts +100 -0
  49. package/tests/code-graph-summary-batching.test.ts +518 -0
  50. package/tests/code-graph-summary.test.ts +148 -0
  51. package/tests/compaction.test.ts +371 -57
  52. package/tests/config-command.test.ts +255 -0
  53. package/tests/exec.test.ts +665 -241
  54. package/tests/fixtures/code-graph/sample.js +36 -0
  55. package/tests/fixtures/code-graph/sample.py +20 -0
  56. package/tests/fixtures/code-graph/sample.ts +15 -0
  57. package/tests/graph-aware-file-tools.test.ts +411 -0
  58. package/tests/refine-prompts.test.ts +67 -2
  59. package/tests/refine-ui.test.ts +337 -72
  60. package/tests/subagent.test.ts +26 -20
  61. package/tools/ask-choice.ts +158 -11
  62. package/tools/code-graph.ts +254 -0
  63. package/tools/graph-aware-file-tools.ts +392 -0
  64. package/tools/plans.ts +84 -1
  65. package/tools/refine.ts +61 -15
@@ -0,0 +1,59 @@
1
+ /** Deterministic function identity normalization for a parsed file. */
2
+
3
+ import type { ParsedFile } from "./parser.ts";
4
+ import type { FunctionRecord, RenderUnit } from "./types.ts";
5
+
6
+ function spanKey(startByte: number, endByte: number): string {
7
+ return `${startByte}:${endByte}`;
8
+ }
9
+
10
+ function finalNames(functions: FunctionRecord[]): Map<string, string> {
11
+ const counts = new Map<string, number>();
12
+ const names = new Map<string, string>();
13
+ for (const fn of functions) {
14
+ const ordinal = (counts.get(fn.functionName) ?? 0) + 1;
15
+ counts.set(fn.functionName, ordinal);
16
+ const name = ordinal === 1 ? fn.functionName : `${fn.functionName}#${ordinal}`;
17
+ names.set(spanKey(fn.provenance.startByte, fn.provenance.endByte), name);
18
+ }
19
+ return names;
20
+ }
21
+
22
+ function renameUnits(units: RenderUnit[], names: Map<string, string>): RenderUnit[] {
23
+ return units.map((unit) => ({
24
+ ...unit,
25
+ label: names.get(spanKey(unit.startByte, unit.endByte)) ?? unit.label,
26
+ children: unit.children ? renameUnits(unit.children, names) : unit.children,
27
+ }));
28
+ }
29
+
30
+ /**
31
+ * Normalize parser output before calls are resolved or rows are written. The
32
+ * parser order is structural and deterministic; provenance is only used to
33
+ * associate an existing render unit with its function record.
34
+ */
35
+ export function normalizeFunctionIdentities(parsed: ParsedFile): ParsedFile {
36
+ const names = finalNames(parsed.functions);
37
+ const functions = parsed.functions.map((fn) => ({
38
+ ...fn,
39
+ functionName: names.get(spanKey(fn.provenance.startByte, fn.provenance.endByte)) ?? fn.functionName,
40
+ }));
41
+ return {
42
+ ...parsed,
43
+ functions,
44
+ renderUnits: renameUnits(parsed.renderUnits, names),
45
+ };
46
+ }
47
+
48
+ export function assertUniqueFunctionKeys(functions: FunctionRecord[], fileDir: string, fileName: string): void {
49
+ const seen = new Set<string>();
50
+ for (const fn of functions) {
51
+ const key = `${fileDir}\0${fileName}\0${fn.functionName}`;
52
+ if (seen.has(key)) {
53
+ throw new Error(
54
+ `duplicate normalized function identity for ${fileDir}/${fileName}/${fn.functionName} at ${fn.provenance.startByte}`,
55
+ );
56
+ }
57
+ seen.add(key);
58
+ }
59
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Staged indexing pipeline: discovery → parse → resolve calls → write records
3
+ * inside a single SQLite write transaction. Each stage produces immutable
4
+ * snapshots so a parser crash cannot leave the DB in an inconsistent state.
5
+ */
6
+
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { discoverFiles, type DiscoveredFile } from "./discovery.ts";
10
+ import { Store } from "./store.ts";
11
+ import type { ParserBackend } from "./parser.ts";
12
+ import { hashText } from "./parser.ts";
13
+ import type { Language } from "./types.ts";
14
+ import { resolveCalls, type CallSite } from "./resolver.ts";
15
+ import { assertUniqueFunctionKeys, normalizeFunctionIdentities } from "./identity.ts";
16
+
17
+ export interface IndexerOptions {
18
+ store: Store;
19
+ worktreeRoot: string;
20
+ parsers: Record<Language, ParserBackend>;
21
+ reindex?: boolean;
22
+ /** When provided, only index these POSIX "dir/name" paths; paths that no
23
+ * longer exist on disk are purged from the DB (deletions and rename-old). */
24
+ paths?: string[];
25
+ }
26
+
27
+ export interface IndexReport {
28
+ filesScanned: number;
29
+ functionsIndexed: number;
30
+ edgesResolved: number;
31
+ edgesUnresolved: number;
32
+ conflicts: number;
33
+ durationMs: number;
34
+ reindexedPaths: string[];
35
+ purgedPaths: string[];
36
+ }
37
+
38
+ export async function runIndex(opts: IndexerOptions): Promise<IndexReport> {
39
+ const started = Date.now();
40
+ const files = discoverFiles({ worktreeRoot: opts.worktreeRoot });
41
+ const pathFilter = opts.paths ? new Set(opts.paths) : null;
42
+ if (pathFilter) {
43
+ for (const requested of pathFilter) {
44
+ if (!files.some((file) => `${file.fileDir}/${file.fileName}` === requested)) {
45
+ // Requested path no longer exists on disk (deletion or rename-old):
46
+ // purge its DB rows inside the same transaction below.
47
+ files.push({
48
+ absolutePath: path.join(opts.worktreeRoot, requested),
49
+ fileDir: requested.slice(0, requested.lastIndexOf("/")) || ".",
50
+ fileName: requested.slice(requested.lastIndexOf("/") + 1),
51
+ language: "javascript",
52
+ }) as DiscoveredFile;
53
+ }
54
+ }
55
+ }
56
+ const staged: Array<{
57
+ file: DiscoveredFile;
58
+ parsed: ReturnType<ParserBackend["parse"]>;
59
+ calls: CallSite[];
60
+ sourceText: string;
61
+ sourceHash: string;
62
+ exists: boolean;
63
+ }> = [];
64
+ let preflightConflict = 0;
65
+ const existingByFile = new Map<string, { sourceHash: string }>();
66
+ for (const row of opts.store
67
+ .read(() => opts.store.db.prepare("SELECT file_dir, file_name, source_hash FROM files").all()) as Array<{
68
+ file_dir: string;
69
+ file_name: string;
70
+ source_hash: string;
71
+ }>) {
72
+ existingByFile.set(`${row.file_dir}/${row.file_name}`, { sourceHash: row.source_hash });
73
+ }
74
+ for (const file of files) {
75
+ const relativeKey = `${file.fileDir}/${file.fileName}`;
76
+ if (pathFilter && !pathFilter.has(relativeKey)) continue;
77
+ let sourceText: string;
78
+ let exists = true;
79
+ try {
80
+ sourceText = fs.readFileSync(file.absolutePath, "utf8");
81
+ } catch {
82
+ if (pathFilter) {
83
+ // Path was requested but is gone from disk: stage a purge.
84
+ staged.push({ file, parsed: { functions: [], renderUnits: [] } as ReturnType<ParserBackend["parse"]>, calls: [], sourceText: "", sourceHash: "", exists: false });
85
+ }
86
+ continue;
87
+ }
88
+ const sourceHash = hashText(sourceText);
89
+ const backend = opts.parsers[file.language];
90
+ if (!backend) continue;
91
+ let parsed = backend.parse(sourceText);
92
+ parsed = normalizeFunctionIdentities(parsed);
93
+ parsed.functions.forEach((fn) => {
94
+ fn.fileDir = file.fileDir;
95
+ fn.fileName = file.fileName;
96
+ });
97
+ assertUniqueFunctionKeys(parsed.functions, file.fileDir, file.fileName);
98
+ const calls: CallSite[] = [];
99
+ for (const fn of parsed.functions) {
100
+ const fromText = fn.fullCode;
101
+ resolveCalls(fn.functionName, fromText, file, calls);
102
+ }
103
+ staged.push({ file, parsed, calls, sourceText, sourceHash, exists: true });
104
+ }
105
+ const now = new Date().toISOString();
106
+ const report: IndexReport = {
107
+ filesScanned: staged.filter((entry) => entry.exists).length,
108
+ functionsIndexed: 0,
109
+ edgesResolved: 0,
110
+ edgesUnresolved: 0,
111
+ conflicts: 0,
112
+ durationMs: 0,
113
+ reindexedPaths: [],
114
+ purgedPaths: [],
115
+ };
116
+ try {
117
+ opts.store.tx(() => {
118
+ for (const { file, parsed, calls, sourceText, sourceHash, exists } of staged) {
119
+ if (!exists) {
120
+ opts.store
121
+ .prepare("delete_functions_purge", `DELETE FROM functions WHERE file_dir = ? AND file_name = ?`)
122
+ .run(file.fileDir, file.fileName);
123
+ opts.store
124
+ .prepare("delete_entries_purge", `DELETE FROM file_entries WHERE file_dir = ? AND file_name = ?`)
125
+ .run(file.fileDir, file.fileName);
126
+ opts.store
127
+ .prepare("delete_edges_purge", `DELETE FROM call_edges WHERE from_file_dir = ? AND from_file_name = ?`)
128
+ .run(file.fileDir, file.fileName);
129
+ opts.store
130
+ .prepare("delete_file_purge", `DELETE FROM files WHERE file_dir = ? AND file_name = ?`)
131
+ .run(file.fileDir, file.fileName);
132
+ report.purgedPaths.push(`${file.fileDir}/${file.fileName}`);
133
+ continue;
134
+ }
135
+ report.reindexedPaths.push(`${file.fileDir}/${file.fileName}`);
136
+ const stmt = opts.store.prepare(
137
+ "insert_file",
138
+ `INSERT INTO files (file_dir, file_name, language, source_hash, source_text, updated_at)
139
+ VALUES (?, ?, ?, ?, ?, ?)
140
+ ON CONFLICT(file_dir, file_name) DO UPDATE SET
141
+ language = excluded.language,
142
+ source_hash = excluded.source_hash,
143
+ source_text = excluded.source_text,
144
+ updated_at = excluded.updated_at`,
145
+ );
146
+ stmt.run(file.fileDir, file.fileName, file.language, sourceHash, sourceText, now);
147
+ const oldHash = existingByFile.get(`${file.fileDir}/${file.fileName}`)?.sourceHash;
148
+ if (opts.reindex && oldHash && oldHash !== sourceHash) {
149
+ opts.store
150
+ .prepare(
151
+ "insert_conflict",
152
+ `INSERT INTO reindex_conflicts (file_dir, file_name, kind, detail, recorded_at)
153
+ VALUES (?, ?, ?, ?, ?)`,
154
+ )
155
+ .run(file.fileDir, file.fileName, "external-change", `was ${oldHash}, now ${sourceHash}`, now);
156
+ report.conflicts++;
157
+ }
158
+ opts.store
159
+ .prepare("delete_functions", `DELETE FROM functions WHERE file_dir = ? AND file_name = ?`)
160
+ .run(file.fileDir, file.fileName);
161
+ opts.store
162
+ .prepare("delete_entries", `DELETE FROM file_entries WHERE file_dir = ? AND file_name = ?`)
163
+ .run(file.fileDir, file.fileName);
164
+ opts.store
165
+ .prepare("delete_edges", `DELETE FROM call_edges WHERE from_file_dir = ? AND from_file_name = ?`)
166
+ .run(file.fileDir, file.fileName);
167
+ const insertFn = opts.store.prepare(
168
+ "insert_function",
169
+ `INSERT INTO functions (
170
+ file_dir, file_name, function_name, language, kind,
171
+ full_code, full_code_hash, render_code, render_code_hash,
172
+ parent, container, move_supported, is_primary, overload_signatures,
173
+ provenance_start_byte, provenance_end_byte,
174
+ provenance_start_line, provenance_start_col,
175
+ provenance_end_line, provenance_end_col,
176
+ summary_description, summary_inputs, summary_outputs,
177
+ summary_status, summary_model, summary_schema_version,
178
+ summary_effective_effort, summary_error, summary_updated_at,
179
+ version
180
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
181
+ );
182
+ for (const fn of parsed.functions) {
183
+ const summary = fn.summary;
184
+ insertFn.run(
185
+ fn.fileDir,
186
+ fn.fileName,
187
+ fn.functionName,
188
+ fn.language,
189
+ fn.kind,
190
+ fn.fullCode,
191
+ fn.fullCodeHash,
192
+ fn.renderCode,
193
+ fn.renderCodeHash,
194
+ fn.parent ?? null,
195
+ fn.container ?? null,
196
+ fn.moveSupported ? 1 : 0,
197
+ fn.isPrimary ? 1 : 0,
198
+ fn.overloadSignatures ? JSON.stringify(fn.overloadSignatures) : null,
199
+ fn.provenance.startByte,
200
+ fn.provenance.endByte,
201
+ fn.provenance.startLine,
202
+ fn.provenance.startColumn,
203
+ fn.provenance.endLine,
204
+ fn.provenance.endColumn,
205
+ summary?.description ?? null,
206
+ summary ? JSON.stringify(summary.inputs) : null,
207
+ summary ? JSON.stringify(summary.outputs) : null,
208
+ summary?.status ?? null,
209
+ summary?.model ?? null,
210
+ summary?.schemaVersion ?? null,
211
+ summary?.effectiveEffort ?? null,
212
+ summary?.errorMessage ?? null,
213
+ summary?.updatedAt ?? null,
214
+ fn.version,
215
+ );
216
+ report.functionsIndexed++;
217
+ }
218
+ let ordinal = 0;
219
+ const insertEntry = opts.store.prepare(
220
+ "insert_entry",
221
+ `INSERT INTO file_entries (file_dir, file_name, ordinal, kind, function_name, start_byte, end_byte, text)
222
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
223
+ );
224
+ for (const unit of parsed.renderUnits) {
225
+ insertEntry.run(
226
+ file.fileDir,
227
+ file.fileName,
228
+ ordinal,
229
+ unit.kind,
230
+ unit.label ?? null,
231
+ unit.startByte,
232
+ unit.endByte,
233
+ unit.text ?? "",
234
+ );
235
+ ordinal++;
236
+ }
237
+ const insertEdge = opts.store.prepare(
238
+ "insert_edge",
239
+ `INSERT INTO call_edges (
240
+ from_file_dir, from_file_name, from_function,
241
+ to_file_dir, to_file_name, to_function,
242
+ to_callee_text, kind, resolution, reason,
243
+ provenance_start_byte, provenance_end_byte,
244
+ provenance_start_line, provenance_start_col,
245
+ provenance_end_line, provenance_end_col
246
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
247
+ );
248
+ for (const call of calls) {
249
+ insertEdge.run(
250
+ file.fileDir,
251
+ file.fileName,
252
+ call.fromFunction,
253
+ call.target?.fileDir ?? null,
254
+ call.target?.fileName ?? null,
255
+ call.target?.functionName ?? null,
256
+ call.calleeText,
257
+ call.kind,
258
+ call.resolution,
259
+ call.reason ?? null,
260
+ call.provenance.startByte,
261
+ call.provenance.endByte,
262
+ call.provenance.startLine,
263
+ call.provenance.startColumn,
264
+ call.provenance.endLine,
265
+ call.provenance.endColumn,
266
+ );
267
+ if (call.resolution === "resolved") report.edgesResolved++;
268
+ else report.edgesUnresolved++;
269
+ }
270
+ }
271
+ });
272
+ } catch (error) {
273
+ if (!opts.reindex && staged.length === 0) {
274
+ preflightConflict++;
275
+ }
276
+ throw error;
277
+ }
278
+ report.durationMs = Date.now() - started;
279
+ report.conflicts += preflightConflict;
280
+ return report;
281
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * DB-to-source materialization. `files.source_text` is the canonical content
3
+ * for every row. Files whose `pending_kind` is set (DB-first mutations) are
4
+ * applied with convergence semantics: 'update' writes/creates the file;
5
+ * 'delete' removes the disk file and purges its DB rows. Pending-NULL files
6
+ * keep the stale-hash guard and are never rewritten from manifest entries —
7
+ * render-unit texts are not a faithful mirror of the source (empty texts,
8
+ * overlapping spans), so reassembly from `file_entries` is unsafe.
9
+ */
10
+
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import * as os from "node:os";
14
+ import { Store } from "./store.ts";
15
+
16
+ export interface MaterializeOptions {
17
+ store: Store;
18
+ worktreeRoot: string;
19
+ force?: boolean;
20
+ }
21
+
22
+ export interface MaterializeReport {
23
+ files: Array<{
24
+ fileDir: string;
25
+ fileName: string;
26
+ absolutePath: string;
27
+ status: "ok" | "stale" | "error" | "deleted" | "skipped-missing";
28
+ reason?: string;
29
+ }>;
30
+ }
31
+
32
+ function loadSourceText(store: Store, fileDir: string, fileName: string): { text: string; hash: string } | null {
33
+ const row = store
34
+ .read(() =>
35
+ store.db
36
+ .prepare(
37
+ `SELECT source_text, source_hash FROM files WHERE file_dir = ? AND file_name = ?`,
38
+ )
39
+ .get(fileDir, fileName),
40
+ ) as { source_text: string; source_hash: string } | undefined;
41
+ return row ? { text: row.source_text, hash: row.source_hash } : null;
42
+ }
43
+
44
+ function hashText(text: string): string {
45
+ let h = 0x811c9dc5;
46
+ for (let i = 0; i < text.length; i++) {
47
+ h ^= text.charCodeAt(i);
48
+ h = Math.imul(h, 0x01000193);
49
+ }
50
+ return (h >>> 0).toString(16).padStart(8, "0");
51
+ }
52
+
53
+ export function materializeFile(
54
+ store: Store,
55
+ worktreeRoot: string,
56
+ fileDir: string,
57
+ fileName: string,
58
+ opts: { force?: boolean } = {},
59
+ ): { status: "ok" | "stale" | "error" | "deleted" | "skipped-missing"; reason?: string; written?: string } {
60
+ const file = loadSourceText(store, fileDir, fileName);
61
+ if (!file) return { status: "error", reason: "file not indexed" };
62
+ const absolute = path.join(worktreeRoot, fileDir === "." ? fileName : path.join(fileDir, fileName));
63
+ const pendingKind = store.read(() =>
64
+ store.db.prepare(`SELECT pending_kind FROM files WHERE file_dir = ? AND file_name = ?`).get(fileDir, fileName),
65
+ ) as { pending_kind: string | null } | undefined;
66
+ const pending = pendingKind?.pending_kind ?? null;
67
+
68
+ if (pending === "delete") {
69
+ try {
70
+ if (fs.existsSync(absolute)) fs.rmSync(absolute);
71
+ } catch (error) {
72
+ return { status: "error", reason: (error as Error).message };
73
+ }
74
+ store.tx(() => {
75
+ store.db.prepare(`DELETE FROM file_entries WHERE file_dir = ? AND file_name = ?`).run(fileDir, fileName);
76
+ store.db.prepare(`DELETE FROM call_edges WHERE from_file_dir = ? AND from_file_name = ?`).run(fileDir, fileName);
77
+ store.db.prepare(`DELETE FROM functions WHERE file_dir = ? AND file_name = ?`).run(fileDir, fileName);
78
+ store.db.prepare(`DELETE FROM files WHERE file_dir = ? AND file_name = ?`).run(fileDir, fileName);
79
+ });
80
+ return { status: "deleted", written: absolute };
81
+ }
82
+
83
+ let currentText: string | null = null;
84
+ try {
85
+ currentText = fs.readFileSync(absolute, "utf8");
86
+ } catch {
87
+ currentText = null;
88
+ }
89
+ if (pending === "update") {
90
+ // DB-first convergence: files.source_text IS the canonical new content
91
+ // (mutations maintain it; the loop-end reindex rebuilds the manifest).
92
+ // Write (or create) regardless of the on-disk hash.
93
+ const write = writeAtomically(absolute, file.text);
94
+ if (write.error) return { status: "error", reason: write.error };
95
+ store.tx(() => {
96
+ store.db
97
+ .prepare(`UPDATE files SET pending_kind = NULL, updated_at = ? WHERE file_dir = ? AND file_name = ?`)
98
+ .run(new Date().toISOString(), fileDir, fileName);
99
+ });
100
+ return { status: "ok", written: absolute };
101
+ }
102
+ if (currentText === null) {
103
+ // Pending-NULL and missing on disk: never resurrect deletions.
104
+ return { status: "skipped-missing", reason: "file missing on disk and not pending; skipped (not resurrected)" };
105
+ }
106
+ const currentHash = hashText(currentText);
107
+ if (currentHash !== file.hash) {
108
+ if (!opts.force) {
109
+ return { status: "stale", reason: `current hash ${currentHash} != indexed hash ${file.hash}` };
110
+ }
111
+ // Force converge from the canonical DB content, not the manifest.
112
+ const write = writeAtomically(absolute, file.text);
113
+ if (write.error) return { status: "error", reason: write.error };
114
+ return { status: "ok", written: absolute };
115
+ }
116
+ // Disk already matches the canonical source_text: nothing to converge.
117
+ // Never rewrite pending-NULL files from manifest entries — render-unit
118
+ // texts are empty/overlapping for real indexes, so reassembly corrupts.
119
+ return { status: "ok" };
120
+ }
121
+
122
+ function writeAtomically(absolute: string, output: string): { error?: string } {
123
+ try {
124
+ fs.mkdirSync(path.dirname(absolute), { recursive: true });
125
+ const tmp = path.join(os.tmpdir(), `code-graph-apply-${process.pid}-${Date.now()}-${path.basename(absolute)}`);
126
+ fs.writeFileSync(tmp, output);
127
+ try {
128
+ fs.renameSync(tmp, absolute);
129
+ } catch (error) {
130
+ try {
131
+ fs.unlinkSync(tmp);
132
+ } catch {
133
+ /* ignore */
134
+ }
135
+ return { error: (error as Error).message };
136
+ }
137
+ return {};
138
+ } catch (error) {
139
+ return { error: (error as Error).message };
140
+ }
141
+ }
142
+
143
+ export function materialize(opts: MaterializeOptions): MaterializeReport {
144
+ const rows = opts.store
145
+ .read(() =>
146
+ opts.store.db
147
+ .prepare(
148
+ `SELECT file_dir, file_name FROM files ORDER BY file_dir, file_name`,
149
+ )
150
+ .all(),
151
+ ) as Array<{ file_dir: string; file_name: string }>;
152
+ const files: MaterializeReport["files"] = [];
153
+ for (const row of rows) {
154
+ const result = materializeFile(opts.store, opts.worktreeRoot, row.file_dir, row.file_name, {
155
+ force: opts.force === true,
156
+ });
157
+ files.push({
158
+ fileDir: row.file_dir,
159
+ fileName: row.file_name,
160
+ absolutePath: path.join(opts.worktreeRoot, row.file_dir === "." ? row.file_name : path.join(row.file_dir, row.file_name)),
161
+ status: result.status,
162
+ reason: result.reason,
163
+ });
164
+ }
165
+ return { files };
166
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared graph-enabled tri-state resolution. Both the graph-aware file-tool
3
+ * wrappers (tools/) and the execution prompt injection (src/exec.ts) must read
4
+ * the SAME state through this helper, so the injected guidance and the actual
5
+ * tool behavior can never diverge.
6
+ *
7
+ * Three states, deliberately not a boolean:
8
+ * - "enabled" — graph_enabled === true: wrappers and graph reads active.
9
+ * - "off" — flag explicitly false/absent: native tools, no markers
10
+ * (the injected prompt already announces the disabled state).
11
+ * - "config-unavailable" — state root missing config or config.json unreadable:
12
+ * treated as an unexpected fallback and surfaced with a
13
+ * marker line (never silently collapsed into "off").
14
+ */
15
+
16
+ import { loadConfig, resolveStateRootOrNull } from "../state.ts";
17
+
18
+ export type GraphMode = "enabled" | "off" | "config-unavailable";
19
+
20
+ export function resolveGraphMode(workdir: string): GraphMode {
21
+ const stateRoot = resolveStateRootOrNull(workdir);
22
+ if (!stateRoot) return "off";
23
+ try {
24
+ return loadConfig(stateRoot).graph_enabled === true ? "enabled" : "off";
25
+ } catch {
26
+ return "config-unavailable";
27
+ }
28
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * DB-first mutation core for the code_graph tool. Each mutation edits graph
3
+ * rows, marks the file pending_materialization, and appends to change_log.
4
+ * /apply-graph later converges the worktree to the DB state.
5
+ */
6
+
7
+ import { Store } from "./store.ts";
8
+ import { hashText } from "./parser.ts";
9
+ import type { Language } from "./types.ts";
10
+
11
+ export interface MutationResult {
12
+ ok: boolean;
13
+ reason?: string;
14
+ updated?: string;
15
+ pending?: "update" | "delete";
16
+ created?: boolean;
17
+ }
18
+
19
+ interface ManifestRow {
20
+ ordinal: number;
21
+ start_byte: number;
22
+ end_byte: number;
23
+ text: string;
24
+ function_name: string | null;
25
+ }
26
+
27
+ function loadManifest(store: Store, fileDir: string, fileName: string): ManifestRow[] {
28
+ return store.read(() =>
29
+ store.db
30
+ .prepare(
31
+ `SELECT ordinal, start_byte, end_byte, text, function_name FROM file_entries
32
+ WHERE file_dir = ? AND file_name = ? ORDER BY ordinal ASC`,
33
+ )
34
+ .all(fileDir, fileName),
35
+ ) as ManifestRow[];
36
+ }
37
+
38
+ export function updateFunction(
39
+ store: Store,
40
+ opts: { fileDir: string; fileName: string; functionName: string; fullCode: string },
41
+ ): MutationResult {
42
+ const fnRow = store.read(() =>
43
+ store.db
44
+ .prepare(
45
+ `SELECT provenance_start_byte, provenance_end_byte FROM functions
46
+ WHERE file_dir = ? AND file_name = ? AND function_name = ? LIMIT 1`,
47
+ )
48
+ .get(opts.fileDir, opts.fileName, opts.functionName),
49
+ ) as { provenance_start_byte: number; provenance_end_byte: number } | undefined;
50
+ if (!fnRow) return { ok: false, reason: "function not found" };
51
+
52
+ // Reads hoisted OUT of the tx: Store.read begins its own transaction.
53
+ const manifest = loadManifest(store, opts.fileDir, opts.fileName);
54
+ const fileRow = store.read(() =>
55
+ store.db.prepare(`SELECT source_text FROM files WHERE file_dir = ? AND file_name = ?`).get(opts.fileDir, opts.fileName),
56
+ ) as { source_text: string } | undefined;
57
+ const matched = manifest.filter((row) => row.function_name === opts.functionName);
58
+ if (!fileRow || matched.length === 0) {
59
+ return {
60
+ ok: false,
61
+ reason: !fileRow
62
+ ? "file row missing; run /update-graph first"
63
+ : "no manifest entry matched the function name (qualified/overload names like name#2 may differ); re-check via code_graph get-function",
64
+ };
65
+ }
66
+
67
+ const newHash = hashText(opts.fullCode);
68
+ const first = matched[0]!;
69
+ // Splice by the function's provenance byte range — the authoritative source
70
+ // of the callable's extent. Manifest entry texts from the current parser are
71
+ // empty, so reassembly-by-manifest cannot rebuild function bodies; the range
72
+ // splice is deterministic and reindex-independent.
73
+ const start = fnRow.provenance_start_byte;
74
+ const end = fnRow.provenance_end_byte;
75
+ const rebuilt = fileRow.source_text.slice(0, start) + opts.fullCode + fileRow.source_text.slice(end);
76
+ const delta = opts.fullCode.length - (end - start);
77
+ for (const row of manifest) {
78
+ if (row.ordinal === first.ordinal) row.text = opts.fullCode;
79
+ else if (row.start_byte >= end) {
80
+ // Persist the shift so later reassembly aligns; the loop-end reindex
81
+ // rebuilds entries authoritatively anyway.
82
+ row.start_byte += delta;
83
+ row.end_byte += delta;
84
+ }
85
+ }
86
+ store.tx(() => {
87
+ store.db
88
+ .prepare(
89
+ `UPDATE functions SET full_code = ?, full_code_hash = ?, render_code = ?, render_code_hash = ?
90
+ WHERE file_dir = ? AND file_name = ? AND function_name = ?`,
91
+ )
92
+ .run(opts.fullCode, newHash, opts.fullCode, newHash, opts.fileDir, opts.fileName, opts.functionName);
93
+ for (const row of manifest) {
94
+ store.db
95
+ .prepare(`UPDATE file_entries SET text = ?, start_byte = ?, end_byte = ? WHERE file_dir = ? AND file_name = ? AND ordinal = ?`)
96
+ .run(row.text, row.start_byte, row.ordinal === first.ordinal ? row.end_byte + delta : row.end_byte, opts.fileDir, opts.fileName, row.ordinal);
97
+ }
98
+ store.db
99
+ .prepare(`UPDATE files SET source_text = ?, source_hash = ?, pending_kind = 'update', updated_at = ? WHERE file_dir = ? AND file_name = ?`)
100
+ .run(rebuilt, hashText(rebuilt), new Date().toISOString(), opts.fileDir, opts.fileName);
101
+ store.db
102
+ .prepare(`INSERT INTO change_log (kind, detail, recorded_at) VALUES (?, ?, ?)`)
103
+ .run("update-function", `${opts.fileDir}/${opts.fileName}:${opts.functionName}`, new Date().toISOString());
104
+ });
105
+ return { ok: true, updated: `${opts.fileDir}/${opts.fileName}:${opts.functionName}`, pending: "update" };
106
+ }
107
+
108
+ export function updateFile(
109
+ store: Store,
110
+ opts: { fileDir: string; fileName: string; text: string; language?: Language },
111
+ ): MutationResult {
112
+ const exists = store.read(() =>
113
+ store.db.prepare(`SELECT 1 AS one FROM files WHERE file_dir = ? AND file_name = ?`).get(opts.fileDir, opts.fileName),
114
+ );
115
+ const now = new Date().toISOString();
116
+ store.tx(() => {
117
+ if (exists) {
118
+ // Whole-file replace: entries would be stale; drop them so the
119
+ // materializer falls back to source_text. The loop-end reindex rebuilds.
120
+ store.db.prepare(`DELETE FROM file_entries WHERE file_dir = ? AND file_name = ?`).run(opts.fileDir, opts.fileName);
121
+ store.db
122
+ .prepare(`UPDATE files SET source_text = ?, source_hash = ?, pending_kind = 'update', updated_at = ? WHERE file_dir = ? AND file_name = ?`)
123
+ .run(opts.text, hashText(opts.text), now, opts.fileDir, opts.fileName);
124
+ } else {
125
+ store.db
126
+ .prepare(
127
+ `INSERT INTO files (file_dir, file_name, language, source_hash, source_text, pending_kind, updated_at)
128
+ VALUES (?, ?, ?, ?, ?, 'update', ?)`,
129
+ )
130
+ .run(opts.fileDir, opts.fileName, opts.language ?? "javascript", hashText(opts.text), opts.text, now);
131
+ }
132
+ store.db
133
+ .prepare(`INSERT INTO change_log (kind, detail, recorded_at) VALUES (?, ?, ?)`)
134
+ .run("update-file", `${opts.fileDir}/${opts.fileName}`, now);
135
+ });
136
+ return { ok: true, updated: `${opts.fileDir}/${opts.fileName}`, pending: "update", created: !exists };
137
+ }
138
+
139
+ export function deleteFile(store: Store, opts: { fileDir: string; fileName: string }): MutationResult {
140
+ const exists = store.read(() =>
141
+ store.db.prepare(`SELECT 1 AS one FROM files WHERE file_dir = ? AND file_name = ?`).get(opts.fileDir, opts.fileName),
142
+ );
143
+ if (!exists) return { ok: false, reason: "file not indexed" };
144
+ store.tx(() => {
145
+ store.db.prepare(`UPDATE files SET pending_kind = 'delete' WHERE file_dir = ? AND file_name = ?`).run(opts.fileDir, opts.fileName);
146
+ store.db
147
+ .prepare(`INSERT INTO change_log (kind, detail, recorded_at) VALUES (?, ?, ?)`)
148
+ .run("delete-file", `${opts.fileDir}/${opts.fileName}`, new Date().toISOString());
149
+ });
150
+ return { ok: true, updated: `${opts.fileDir}/${opts.fileName}`, pending: "delete" };
151
+ }
152
+
153
+ export function listPending(store: Store): Array<{ path: string; kind: string }> {
154
+ const rows = store.read(() =>
155
+ store.db
156
+ .prepare(`SELECT file_dir, file_name, pending_kind FROM files WHERE pending_kind IS NOT NULL ORDER BY file_dir, file_name`)
157
+ .all(),
158
+ ) as Array<{ file_dir: string; file_name: string; pending_kind: string }>;
159
+ return rows.map((row) => ({ path: `${row.file_dir}/${row.file_name}`, kind: row.pending_kind }));
160
+ }