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.
- package/README.md +74 -21
- package/index.ts +115 -9
- package/package.json +7 -1
- package/references/pi-planning-workflow.md +18 -3
- package/references/state-and-config.md +34 -2
- package/scripts/validate.ts +4 -0
- package/src/code-graph/commands.ts +437 -0
- package/src/code-graph/discovery.ts +118 -0
- package/src/code-graph/git.ts +108 -0
- package/src/code-graph/identity.ts +59 -0
- package/src/code-graph/indexer.ts +281 -0
- package/src/code-graph/materialize.ts +166 -0
- package/src/code-graph/mode.ts +28 -0
- package/src/code-graph/mutations.ts +160 -0
- package/src/code-graph/parser.ts +51 -0
- package/src/code-graph/parsers/javascript.ts +35 -0
- package/src/code-graph/parsers/python.ts +160 -0
- package/src/code-graph/parsers/tree-sitter.ts +316 -0
- package/src/code-graph/paths.ts +85 -0
- package/src/code-graph/prompts.ts +18 -0
- package/src/code-graph/resolver.ts +69 -0
- package/src/code-graph/runtime.ts +158 -0
- package/src/code-graph/schema.ts +135 -0
- package/src/code-graph/screening.ts +82 -0
- package/src/code-graph/store.ts +278 -0
- package/src/code-graph/summary.ts +435 -0
- package/src/code-graph/types.ts +163 -0
- package/src/compaction.ts +1125 -371
- package/src/config-command.ts +326 -0
- package/src/exec.ts +356 -686
- package/src/refine-prompts.ts +50 -0
- package/src/refine-ui-helpers.ts +71 -18
- package/src/refine-ui-state.ts +87 -21
- package/src/refine-ui.ts +210 -102
- package/src/state.ts +19 -6
- package/src/subagent.ts +163 -61
- package/tests/ask-choice.test.ts +263 -0
- package/tests/autocomplete.test.ts +6 -1
- package/tests/code-graph-apply.test.ts +185 -0
- package/tests/code-graph-commands.test.ts +211 -0
- package/tests/code-graph-db.test.ts +166 -0
- package/tests/code-graph-discovery.test.ts +38 -0
- package/tests/code-graph-git.test.ts +94 -0
- package/tests/code-graph-index.test.ts +175 -0
- package/tests/code-graph-loop.e2e.test.ts +159 -0
- package/tests/code-graph-mutations.test.ts +117 -0
- package/tests/code-graph-parser.test.ts +85 -0
- package/tests/code-graph-rollback.test.ts +100 -0
- package/tests/code-graph-summary-batching.test.ts +518 -0
- package/tests/code-graph-summary.test.ts +148 -0
- package/tests/compaction.test.ts +371 -57
- package/tests/config-command.test.ts +255 -0
- package/tests/exec.test.ts +665 -241
- package/tests/fixtures/code-graph/sample.js +36 -0
- package/tests/fixtures/code-graph/sample.py +20 -0
- package/tests/fixtures/code-graph/sample.ts +15 -0
- package/tests/graph-aware-file-tools.test.ts +411 -0
- package/tests/refine-prompts.test.ts +67 -2
- package/tests/refine-ui.test.ts +337 -72
- package/tests/subagent.test.ts +26 -20
- package/tools/ask-choice.ts +158 -11
- package/tools/code-graph.ts +254 -0
- package/tools/graph-aware-file-tools.ts +392 -0
- package/tools/plans.ts +84 -1
- 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
|
+
}
|