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,100 @@
|
|
|
1
|
+
/** End-to-end indexer rollback: an injected failure during write must leave no
|
|
2
|
+
* half-written rows for the file being indexed. */
|
|
3
|
+
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import * as assert from "node:assert/strict";
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { Store } from "../src/code-graph/store.ts";
|
|
11
|
+
import { runIndex } from "../src/code-graph/indexer.ts";
|
|
12
|
+
import { loadGraphRuntime } from "../src/code-graph/runtime.ts";
|
|
13
|
+
import { makeBackend } from "../src/code-graph/parsers/javascript.ts";
|
|
14
|
+
import { PythonBackend } from "../src/code-graph/parsers/python.ts";
|
|
15
|
+
import type { ParserBackend } from "../src/code-graph/parser.ts";
|
|
16
|
+
import type { Language } from "../src/code-graph/types.ts";
|
|
17
|
+
|
|
18
|
+
function git(cwd: string, args: string[]): string {
|
|
19
|
+
const env: Record<string, string | undefined> = { ...process.env };
|
|
20
|
+
for (const key of ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE"]) delete env[key];
|
|
21
|
+
const result = spawnSync("git", args, { cwd, env, encoding: "utf8" });
|
|
22
|
+
if (args[0] === "rev-parse") return (result.stdout ?? "").trim();
|
|
23
|
+
assert.equal(result.status, 0, `${args.join(" ")} failed: ${result.stderr}`);
|
|
24
|
+
return (result.stdout ?? "").trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function initRepo(): { worktreeRoot: string; cleanup: () => void } {
|
|
28
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-rollback-"));
|
|
29
|
+
git(dir, ["init", "--initial-branch=main"]);
|
|
30
|
+
git(dir, ["config", "user.email", "test@example.com"]);
|
|
31
|
+
git(dir, ["config", "user.name", "test"]);
|
|
32
|
+
const subdir = path.join(dir, "pkg");
|
|
33
|
+
fs.mkdirSync(subdir, { recursive: true });
|
|
34
|
+
fs.writeFileSync(path.join(subdir, "math.js"), "function add(a, b) { return a + b; }\n");
|
|
35
|
+
git(subdir, ["add", "-A"]);
|
|
36
|
+
git(subdir, ["commit", "-m", "init"]);
|
|
37
|
+
return {
|
|
38
|
+
worktreeRoot: subdir,
|
|
39
|
+
cleanup: () => {
|
|
40
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("indexer rolls back the whole file batch when an emit step throws", async (t) => {
|
|
46
|
+
const { worktreeRoot, cleanup } = initRepo();
|
|
47
|
+
t.after(cleanup);
|
|
48
|
+
const runtime = await loadGraphRuntime();
|
|
49
|
+
if (!runtime.status.parserAvailable || !runtime.status.sqliteAvailable) return;
|
|
50
|
+
const ParserCtor = runtime.runtime.parser.Parser as unknown as new () => {
|
|
51
|
+
parse(input: string | Buffer): unknown;
|
|
52
|
+
setLanguage(language: unknown): void;
|
|
53
|
+
};
|
|
54
|
+
const parsers: Record<Language, ParserBackend> = {
|
|
55
|
+
javascript: makeBackend("javascript", ParserCtor, runtime.runtime.parser.javascript),
|
|
56
|
+
typescript: makeBackend("typescript", ParserCtor, runtime.runtime.parser.typescript),
|
|
57
|
+
tsx: makeBackend("tsx", ParserCtor, runtime.runtime.parser.tsx),
|
|
58
|
+
python: new PythonBackend(ParserCtor, runtime.runtime.parser.python),
|
|
59
|
+
};
|
|
60
|
+
const sqlite = await import("node:sqlite");
|
|
61
|
+
const commonDir = git(worktreeRoot, ["rev-parse", "--git-common-dir"]);
|
|
62
|
+
const dbDir = path.join(path.resolve(worktreeRoot, commonDir), "pi_plans");
|
|
63
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
64
|
+
const dbPath = path.join(dbDir, "code_graph.db");
|
|
65
|
+
try { fs.unlinkSync(dbPath); } catch { /* ignore */ }
|
|
66
|
+
const store = new Store(
|
|
67
|
+
{ dbPath, worktreeRoot, gitCommonDir: path.resolve(worktreeRoot, commonDir) },
|
|
68
|
+
sqlite,
|
|
69
|
+
);
|
|
70
|
+
t.after(() => {
|
|
71
|
+
store.close();
|
|
72
|
+
try { fs.unlinkSync(dbPath); } catch { /* ignore */ }
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const original = store.prepare.bind(store);
|
|
76
|
+
const prepare = (key: string, sql: string) => {
|
|
77
|
+
if (key === "insert_edge") throw new Error("injected emit failure");
|
|
78
|
+
return original(key, sql);
|
|
79
|
+
};
|
|
80
|
+
(store as unknown as { prepare: typeof prepare }).prepare = prepare;
|
|
81
|
+
|
|
82
|
+
await assert.rejects(() => runIndex({ store, worktreeRoot, parsers }), /injected emit failure/);
|
|
83
|
+
|
|
84
|
+
const fileRows = store.read(() =>
|
|
85
|
+
store.db.prepare("SELECT file_dir, file_name FROM files WHERE file_dir = '.' AND file_name = 'math.js'").all(),
|
|
86
|
+
) as Array<{ file_dir: string; file_name: string }>;
|
|
87
|
+
const functionRows = store.read(() =>
|
|
88
|
+
store.db.prepare("SELECT function_name FROM functions WHERE file_dir = '.' AND file_name = 'math.js'").all(),
|
|
89
|
+
) as Array<{ function_name: string }>;
|
|
90
|
+
const entryRows = store.read(() =>
|
|
91
|
+
store.db.prepare("SELECT function_name FROM file_entries WHERE file_dir = '.' AND file_name = 'math.js'").all(),
|
|
92
|
+
) as Array<{ function_name: string }>;
|
|
93
|
+
const edgeRows = store.read(() =>
|
|
94
|
+
store.db.prepare("SELECT from_function FROM call_edges WHERE from_file_dir = '.' AND from_file_name = 'math.js'").all(),
|
|
95
|
+
) as Array<{ from_function: string }>;
|
|
96
|
+
assert.equal(fileRows.length, 0);
|
|
97
|
+
assert.equal(functionRows.length, 0);
|
|
98
|
+
assert.equal(entryRows.length, 0);
|
|
99
|
+
assert.equal(edgeRows.length, 0);
|
|
100
|
+
});
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates the summary batching behavior introduced by the
|
|
3
|
+
* summary-batching plan: bounded requests, batch isolation, error
|
|
4
|
+
* persistence, and oversize singleton handling. Uses a fake completion
|
|
5
|
+
* handle so no real model or network is contacted.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import * as assert from "node:assert/strict";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { Store } from "../src/code-graph/store.ts";
|
|
14
|
+
import {
|
|
15
|
+
buildBatches,
|
|
16
|
+
generateSummaries,
|
|
17
|
+
type CompletionHandle,
|
|
18
|
+
type PendingSummary,
|
|
19
|
+
} from "../src/code-graph/summary.ts";
|
|
20
|
+
|
|
21
|
+
function makeEntry(name: string, fullCode: string): PendingSummary {
|
|
22
|
+
return {
|
|
23
|
+
fileDir: "pkg",
|
|
24
|
+
fileName: "math.js",
|
|
25
|
+
functionName: name,
|
|
26
|
+
fullCodeHash: name,
|
|
27
|
+
language: "javascript",
|
|
28
|
+
fullCode,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function seedEntries(store: Store, entries: PendingSummary[]): void {
|
|
33
|
+
store.tx(() => {
|
|
34
|
+
const stmt = store.db.prepare(
|
|
35
|
+
`INSERT INTO functions (file_dir, file_name, function_name, language, kind,
|
|
36
|
+
full_code, full_code_hash, render_code, render_code_hash,
|
|
37
|
+
move_supported, is_primary,
|
|
38
|
+
provenance_start_byte, provenance_end_byte, provenance_start_line,
|
|
39
|
+
provenance_start_col, provenance_end_line, provenance_end_col, version)
|
|
40
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
41
|
+
);
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
stmt.run(
|
|
44
|
+
entry.fileDir,
|
|
45
|
+
entry.fileName,
|
|
46
|
+
entry.functionName,
|
|
47
|
+
entry.language,
|
|
48
|
+
"declaration",
|
|
49
|
+
entry.fullCode,
|
|
50
|
+
entry.fullCodeHash,
|
|
51
|
+
entry.fullCode,
|
|
52
|
+
entry.fullCodeHash,
|
|
53
|
+
1,
|
|
54
|
+
1,
|
|
55
|
+
0,
|
|
56
|
+
entry.fullCode.length,
|
|
57
|
+
1,
|
|
58
|
+
0,
|
|
59
|
+
1,
|
|
60
|
+
0,
|
|
61
|
+
1,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function openTemp(): Promise<{ store: Store; cleanup: () => void } | null> {
|
|
68
|
+
try {
|
|
69
|
+
const sqlite = await import("node:sqlite");
|
|
70
|
+
const worktreeRoot = fs.realpathSync(os.tmpdir());
|
|
71
|
+
const dbPath = path.join(os.tmpdir(), `code-graph-batching-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.db`);
|
|
72
|
+
const store = new Store({ dbPath, worktreeRoot, gitCommonDir: worktreeRoot }, sqlite);
|
|
73
|
+
return {
|
|
74
|
+
store,
|
|
75
|
+
cleanup: () => {
|
|
76
|
+
store.close();
|
|
77
|
+
try { fs.unlinkSync(dbPath); } catch { /* ignore */ }
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function allowConsent(): CompletionHandle {
|
|
86
|
+
return {
|
|
87
|
+
complete: async () => ({ content: [{ type: "text", text: "" }] }),
|
|
88
|
+
model: () => ({ provider: "test", id: "test-model", api: "openai-completions", reasoning: false }),
|
|
89
|
+
thinkingLevel: () => "low",
|
|
90
|
+
hasUI: true,
|
|
91
|
+
confirm: async () => true,
|
|
92
|
+
notify: () => {},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test("buildBatches splits entries across batches by token budget", () => {
|
|
97
|
+
const small = makeEntry("small", "function small() { return 1; }");
|
|
98
|
+
const medium = makeEntry("medium", "function medium() {\n // pad\n".repeat(120) + "}");
|
|
99
|
+
const large = makeEntry("large", "// " + "x".repeat(20_000));
|
|
100
|
+
const batches = buildBatches([small, medium, large], 512);
|
|
101
|
+
assert.equal(batches.length >= 2, true);
|
|
102
|
+
const flat = batches.flat();
|
|
103
|
+
assert.equal(flat.length, 3);
|
|
104
|
+
assert.deepEqual(flat.map((entry) => entry.functionName), ["small", "medium", "large"]);
|
|
105
|
+
const largeBatch = batches.find((batch) => batch.some((entry) => entry.functionName === "large"));
|
|
106
|
+
assert.equal(largeBatch?.length, 1, "oversize entry must remain a singleton batch");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("generateSummaries issues multiple completion calls and aggregates counts", async (t) => {
|
|
110
|
+
const opened = await openTemp();
|
|
111
|
+
if (!opened) return;
|
|
112
|
+
t.after(() => opened.cleanup());
|
|
113
|
+
const { store } = opened;
|
|
114
|
+
const entries = Array.from({ length: 6 }, (_, i) => makeEntry(`alpha${i}`, `function alpha${i}() { return ${i}; }`));
|
|
115
|
+
seedEntries(store, entries);
|
|
116
|
+
|
|
117
|
+
let callCount = 0;
|
|
118
|
+
const seenNames: string[][] = [];
|
|
119
|
+
const handle: CompletionHandle = {
|
|
120
|
+
complete: async (request) => {
|
|
121
|
+
callCount++;
|
|
122
|
+
const names = request.messages[0]?.content.split("\n---\n").map((chunk) => chunk.split("::").pop()?.split("\n")[0] ?? "") ?? [];
|
|
123
|
+
seenNames.push(names);
|
|
124
|
+
const records = names.map((name) => ({
|
|
125
|
+
description: `summarizes ${name}`,
|
|
126
|
+
inputs: ["x"],
|
|
127
|
+
outputs: ["y"],
|
|
128
|
+
}));
|
|
129
|
+
return { content: [{ type: "text", text: records.map((record) => JSON.stringify(record)).join("\n") }] };
|
|
130
|
+
},
|
|
131
|
+
model: () => ({ provider: "test", id: "test-model", api: "openai-completions", reasoning: false }),
|
|
132
|
+
thinkingLevel: () => "low",
|
|
133
|
+
hasUI: true,
|
|
134
|
+
confirm: async () => true,
|
|
135
|
+
notify: () => {},
|
|
136
|
+
};
|
|
137
|
+
const report = await generateSummaries({ store, ctx: handle, batchTokens: 200 });
|
|
138
|
+
assert.equal(report.batches >= 2, true, `expected multiple batches, got ${report.batches}`);
|
|
139
|
+
assert.equal(callCount, report.batches);
|
|
140
|
+
assert.equal(report.processed, 6);
|
|
141
|
+
assert.equal(report.ok, 6);
|
|
142
|
+
assert.equal(report.failed, 0);
|
|
143
|
+
assert.equal(report.declined, 0);
|
|
144
|
+
const flat = seenNames.flat();
|
|
145
|
+
assert.deepEqual(flat, ["alpha0", "alpha1", "alpha2", "alpha3", "alpha4", "alpha5"]);
|
|
146
|
+
const okCount = store.read(() =>
|
|
147
|
+
store.db.prepare("SELECT COUNT(*) AS n FROM functions WHERE summary_status = 'ok'").get(),
|
|
148
|
+
) as { n: number };
|
|
149
|
+
assert.equal(okCount.n, 6);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("generateSummaries isolates a failing batch and continues the rest", async (t) => {
|
|
153
|
+
const opened = await openTemp();
|
|
154
|
+
if (!opened) return;
|
|
155
|
+
t.after(() => opened.cleanup());
|
|
156
|
+
const { store } = opened;
|
|
157
|
+
const entries = Array.from({ length: 4 }, (_, i) => makeEntry(`beta${i}`, `function beta${i}() { return ${i}; }`));
|
|
158
|
+
seedEntries(store, entries);
|
|
159
|
+
|
|
160
|
+
const notified: string[] = [];
|
|
161
|
+
let callIndex = 0;
|
|
162
|
+
const handle: CompletionHandle = {
|
|
163
|
+
complete: async () => {
|
|
164
|
+
callIndex++;
|
|
165
|
+
if (callIndex === 1) {
|
|
166
|
+
return { content: [{ type: "text", text: "not jsonl" }] };
|
|
167
|
+
}
|
|
168
|
+
const records = entries.slice(2, 4).map((entry) => ({
|
|
169
|
+
description: `summarizes ${entry.functionName}`,
|
|
170
|
+
inputs: ["x"],
|
|
171
|
+
outputs: ["y"],
|
|
172
|
+
}));
|
|
173
|
+
return { content: [{ type: "text", text: records.map((record) => JSON.stringify(record)).join("\n") }] };
|
|
174
|
+
},
|
|
175
|
+
model: () => ({ provider: "test", id: "test-model", api: "openai-completions", reasoning: false }),
|
|
176
|
+
thinkingLevel: () => "low",
|
|
177
|
+
hasUI: true,
|
|
178
|
+
confirm: async () => true,
|
|
179
|
+
notify: (message) => { notified.push(message); },
|
|
180
|
+
};
|
|
181
|
+
const report = await generateSummaries({ store, ctx: handle, batchTokens: 100 });
|
|
182
|
+
assert.equal(report.batches >= 2, true);
|
|
183
|
+
assert.equal(callIndex, report.batches);
|
|
184
|
+
assert.equal(report.ok, 2);
|
|
185
|
+
assert.equal(report.failed, 2);
|
|
186
|
+
const failed = store.read(() =>
|
|
187
|
+
store.db.prepare("SELECT function_name, summary_error FROM functions WHERE summary_status = 'failed' ORDER BY function_name").all(),
|
|
188
|
+
) as Array<{ function_name: string; summary_error: string | null }>;
|
|
189
|
+
assert.ok(failed.every((row) => row.summary_error && row.summary_error.length > 0), "every failed row must persist a non-null summary_error");
|
|
190
|
+
const okNames = (store.read(() =>
|
|
191
|
+
store.db.prepare("SELECT function_name FROM functions WHERE summary_status = 'ok' ORDER BY function_name").all(),
|
|
192
|
+
) as Array<{ function_name: string }>).map((row) => row.function_name);
|
|
193
|
+
assert.deepEqual(okNames, ["beta2", "beta3"]);
|
|
194
|
+
assert.ok(notified.some((line) => line.includes("batch 1/")));
|
|
195
|
+
assert.ok(notified.some((line) => line.includes("batch 2/")));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("successful retry clears stale summary_error from prior failure", async (t) => {
|
|
199
|
+
const opened = await openTemp();
|
|
200
|
+
if (!opened) return;
|
|
201
|
+
t.after(() => opened.cleanup());
|
|
202
|
+
const { store } = opened;
|
|
203
|
+
const entry = makeEntry("gamma", "function gamma() { return 42; }");
|
|
204
|
+
seedEntries(store, [entry]);
|
|
205
|
+
store.tx(() => {
|
|
206
|
+
store.db.prepare(
|
|
207
|
+
"UPDATE functions SET summary_status='pending', summary_error='boom', summary_updated_at=? WHERE function_name='gamma'",
|
|
208
|
+
).run(new Date().toISOString());
|
|
209
|
+
});
|
|
210
|
+
const handle: CompletionHandle = {
|
|
211
|
+
complete: async () => ({
|
|
212
|
+
content: [{ type: "text", text: JSON.stringify({ description: "ok", inputs: ["a"], outputs: ["b"] }) }],
|
|
213
|
+
}),
|
|
214
|
+
model: () => ({ provider: "test", id: "test-model", api: "openai-completions", reasoning: false }),
|
|
215
|
+
thinkingLevel: () => "low",
|
|
216
|
+
hasUI: true,
|
|
217
|
+
confirm: async () => true,
|
|
218
|
+
notify: () => {},
|
|
219
|
+
};
|
|
220
|
+
const report = await generateSummaries({ store, ctx: handle });
|
|
221
|
+
assert.equal(report.ok, 1);
|
|
222
|
+
const row = store.read(() =>
|
|
223
|
+
store.db.prepare("SELECT summary_status, summary_error FROM functions WHERE function_name='gamma'").get(),
|
|
224
|
+
) as { summary_status: string; summary_error: string | null };
|
|
225
|
+
assert.equal(row.summary_status, "ok");
|
|
226
|
+
assert.equal(row.summary_error, null);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("completion throw marks only the current batch as failed and continues", async (t) => {
|
|
230
|
+
const opened = await openTemp();
|
|
231
|
+
if (!opened) return;
|
|
232
|
+
t.after(() => opened.cleanup());
|
|
233
|
+
const { store } = opened;
|
|
234
|
+
const entries = Array.from({ length: 4 }, (_, i) =>
|
|
235
|
+
makeEntry(`delta${i}`, `function delta${i}() { return ${i}; }\n`),
|
|
236
|
+
);
|
|
237
|
+
seedEntries(store, entries);
|
|
238
|
+
let callIndex = 0;
|
|
239
|
+
const batchSizes: number[] = [];
|
|
240
|
+
const handle: CompletionHandle = {
|
|
241
|
+
complete: async (request) => {
|
|
242
|
+
callIndex++;
|
|
243
|
+
const prompt = request.messages[0]?.content ?? "";
|
|
244
|
+
const size = prompt.split("\n---\n").length;
|
|
245
|
+
batchSizes.push(size);
|
|
246
|
+
if (callIndex === 1) throw new Error("provider outage");
|
|
247
|
+
return { content: [{ type: "text", text: JSON.stringify({ description: "ok", inputs: ["a"], outputs: ["b"] }) }] };
|
|
248
|
+
},
|
|
249
|
+
model: () => ({ provider: "test", id: "test-model", api: "openai-completions", reasoning: false }),
|
|
250
|
+
thinkingLevel: () => "low",
|
|
251
|
+
hasUI: true,
|
|
252
|
+
confirm: async () => true,
|
|
253
|
+
notify: () => {},
|
|
254
|
+
};
|
|
255
|
+
const report = await generateSummaries({ store, ctx: handle, batchTokens: 50 });
|
|
256
|
+
assert.equal(report.batches >= 2, true, `expected multiple batches, got ${report.batches}`);
|
|
257
|
+
assert.equal(report.failed, batchSizes[0]);
|
|
258
|
+
assert.equal(report.ok, report.processed - report.failed);
|
|
259
|
+
assert.equal(report.ok + report.failed, entries.length);
|
|
260
|
+
const failed = store.read(() =>
|
|
261
|
+
store.db.prepare("SELECT COUNT(*) AS n FROM functions WHERE summary_status='failed'").get(),
|
|
262
|
+
) as { n: number };
|
|
263
|
+
assert.equal(failed.n, batchSizes[0]);
|
|
264
|
+
const errorRow = store.read(() =>
|
|
265
|
+
store.db.prepare("SELECT summary_error FROM functions WHERE summary_status='failed' LIMIT 1").get(),
|
|
266
|
+
) as { summary_error: string | null };
|
|
267
|
+
assert.equal(errorRow.summary_error, "provider outage");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("summary_error is truncated to the bounded length", async (t) => {
|
|
271
|
+
const opened = await openTemp();
|
|
272
|
+
if (!opened) return;
|
|
273
|
+
t.after(() => opened.cleanup());
|
|
274
|
+
const { store } = opened;
|
|
275
|
+
const entry = makeEntry("epsilon", "function epsilon() {}");
|
|
276
|
+
seedEntries(store, [entry]);
|
|
277
|
+
const longMessage = "x".repeat(5_000);
|
|
278
|
+
const handle: CompletionHandle = {
|
|
279
|
+
complete: async () => { throw new Error(longMessage); },
|
|
280
|
+
model: () => ({ provider: "test", id: "test-model", api: "openai-completions", reasoning: false }),
|
|
281
|
+
thinkingLevel: () => "low",
|
|
282
|
+
hasUI: true,
|
|
283
|
+
confirm: async () => true,
|
|
284
|
+
notify: () => {},
|
|
285
|
+
};
|
|
286
|
+
await generateSummaries({ store, ctx: handle, batchTokens: 50 });
|
|
287
|
+
const row = store.read(() =>
|
|
288
|
+
store.db.prepare("SELECT summary_error FROM functions WHERE function_name='epsilon'").get(),
|
|
289
|
+
) as { summary_error: string | null };
|
|
290
|
+
assert.ok(row.summary_error);
|
|
291
|
+
assert.ok(row.summary_error!.length <= 240, `expected <=240, got ${row.summary_error!.length}`);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("oversize entry still produces a singleton batch completion", async (t) => {
|
|
295
|
+
const opened = await openTemp();
|
|
296
|
+
if (!opened) return;
|
|
297
|
+
t.after(() => opened.cleanup());
|
|
298
|
+
const { store } = opened;
|
|
299
|
+
const huge = makeEntry("huge", "// " + "x".repeat(40_000));
|
|
300
|
+
const small = makeEntry("tiny", "function tiny() {}");
|
|
301
|
+
seedEntries(store, [huge, small]);
|
|
302
|
+
const observed: number[] = [];
|
|
303
|
+
const handle: CompletionHandle = {
|
|
304
|
+
complete: async () => {
|
|
305
|
+
observed.push(1);
|
|
306
|
+
return { content: [{ type: "text", text: JSON.stringify({ description: "ok", inputs: ["a"], outputs: ["b"] }) }] };
|
|
307
|
+
},
|
|
308
|
+
model: () => ({ provider: "test", id: "test-model", api: "openai-completions", reasoning: false }),
|
|
309
|
+
thinkingLevel: () => "low",
|
|
310
|
+
hasUI: true,
|
|
311
|
+
confirm: async () => true,
|
|
312
|
+
notify: () => {},
|
|
313
|
+
};
|
|
314
|
+
const report = await generateSummaries({ store, ctx: handle, batchTokens: 200 });
|
|
315
|
+
assert.equal(report.batches, 2);
|
|
316
|
+
assert.equal(report.ok, 2);
|
|
317
|
+
assert.deepEqual(observed, [1, 1]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("declined consent still short-circuits before batching", async (t) => {
|
|
321
|
+
const opened = await openTemp();
|
|
322
|
+
if (!opened) return;
|
|
323
|
+
t.after(() => opened.cleanup());
|
|
324
|
+
const { store } = opened;
|
|
325
|
+
seedEntries(store, [makeEntry("zeta", "function zeta() {}")]);
|
|
326
|
+
let completeCalled = false;
|
|
327
|
+
const handle: CompletionHandle = {
|
|
328
|
+
complete: async () => { completeCalled = true; return { content: [{ type: "text", text: "" }] }; },
|
|
329
|
+
model: () => ({ provider: "test", id: "test-model", api: "openai-completions", reasoning: false }),
|
|
330
|
+
thinkingLevel: () => "low",
|
|
331
|
+
hasUI: true,
|
|
332
|
+
confirm: async () => false,
|
|
333
|
+
notify: () => {},
|
|
334
|
+
};
|
|
335
|
+
const report = await generateSummaries({ store, ctx: handle });
|
|
336
|
+
assert.equal(report.declined, 1);
|
|
337
|
+
assert.equal(completeCalled, false);
|
|
338
|
+
const row = store.read(() =>
|
|
339
|
+
store.db.prepare("SELECT summary_status FROM functions WHERE function_name='zeta'").get(),
|
|
340
|
+
) as { summary_status: string };
|
|
341
|
+
assert.equal(row.summary_status, "declined");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// ref alignment + object-stream parsing (summary-jsonl-alignment plan)
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
import { alignByRef, buildRef, parseSummaryObjects, pendingFunctions } from "../src/code-graph/summary.ts";
|
|
349
|
+
|
|
350
|
+
function rec(ref: string | null, description = "d"): Record<string, unknown> {
|
|
351
|
+
return ref === null ? { description, inputs: [], outputs: [] } : { ref, description, inputs: [], outputs: [] };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
test("parseSummaryObjects tolerates pretty-print, parallel objects, garbage, and escapes", () => {
|
|
355
|
+
const raw = [
|
|
356
|
+
"Here you go:",
|
|
357
|
+
JSON.stringify({ ref: "./a.ts::f", description: "brace } inside { string", inputs: [], outputs: [{ nested: true }] }, null, 1),
|
|
358
|
+
JSON.stringify(rec("./a.ts::g")) + " " + JSON.stringify(rec("./a.ts::h")),
|
|
359
|
+
"trailing garbage { unclosed",
|
|
360
|
+
].join("\n");
|
|
361
|
+
const objects = parseSummaryObjects(raw);
|
|
362
|
+
assert.equal(objects.length, 3);
|
|
363
|
+
assert.deepEqual(
|
|
364
|
+
objects.map((o) => (o as { ref?: string }).ref),
|
|
365
|
+
["./a.ts::f", "./a.ts::g", "./a.ts::h"],
|
|
366
|
+
);
|
|
367
|
+
assert.deepEqual((objects[0] as { outputs: unknown[] }).outputs, [{ nested: true }]);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("parseSummaryObjects drops a truncated final object without affecting earlier ones", () => {
|
|
371
|
+
const raw = `${JSON.stringify(rec("./a.ts::f"))}\n{"ref": "./a.ts::g", "description": "trunc`;
|
|
372
|
+
const objects = parseSummaryObjects(raw);
|
|
373
|
+
assert.equal(objects.length, 1);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("alignByRef maps by ref, drops unknown/duplicate-later, and falls back to order only when zero refs and counts equal", () => {
|
|
377
|
+
const updates = [makeEntry("f", "c1"), makeEntry("g", "c2"), makeEntry("h", "c3")];
|
|
378
|
+
const refs = updates.map((e) => buildRef(e.fileDir, e.fileName, e.functionName));
|
|
379
|
+
// unknown ref + duplicate later + missing h
|
|
380
|
+
const outcome = alignByRef(updates, [rec("nope::x"), rec(refs[0]!), rec(refs[1]!), rec(refs[1]!)]);
|
|
381
|
+
assert.equal(outcome.orderFallback, false);
|
|
382
|
+
assert.ok(outcome.aligned[0]?.record, "f matched (unknown record dropped, uncounted)");
|
|
383
|
+
assert.ok(outcome.aligned[1]?.record, "g matched first duplicate");
|
|
384
|
+
assert.equal(outcome.aligned[2]?.record === null, true, "h missing");
|
|
385
|
+
|
|
386
|
+
// zero refs, counts equal → order fallback (legacy contract)
|
|
387
|
+
const legacy = alignByRef(updates, [rec(null), rec(null), rec(null)]);
|
|
388
|
+
assert.equal(legacy.orderFallback, true);
|
|
389
|
+
assert.ok(legacy.aligned.every((slot) => slot.record !== null));
|
|
390
|
+
|
|
391
|
+
// zero refs, counts differ → no fallback, all unmatched
|
|
392
|
+
const mismatch = alignByRef(updates, [rec(null)]);
|
|
393
|
+
assert.equal(mismatch.orderFallback, false);
|
|
394
|
+
assert.equal(mismatch.aligned.every((slot) => slot.record === null), true);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("buildRef edge seeds stay distinct", () => {
|
|
398
|
+
const refs = [
|
|
399
|
+
buildRef("pkg", "math.js", "same"),
|
|
400
|
+
buildRef("pkg", "math.js", "same#2"),
|
|
401
|
+
buildRef("pkg", "anon.ts", "<anonymous:1>"),
|
|
402
|
+
buildRef("weird", "a::b.ts", "fn::weird"),
|
|
403
|
+
];
|
|
404
|
+
assert.equal(new Set(refs).size, refs.length);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("generateSummaries applies ref-aligned records record-by-record despite format drift", async (t) => {
|
|
408
|
+
const opened = await openTemp();
|
|
409
|
+
if (!opened) return;
|
|
410
|
+
t.after(() => opened.cleanup());
|
|
411
|
+
const { store } = opened;
|
|
412
|
+
const entries = [makeEntry("f", "function f() { return 1; }"), makeEntry("g", "function g() { return 2; }"), makeEntry("h", "function h() { return 3; }")];
|
|
413
|
+
seedEntries(store, entries);
|
|
414
|
+
const refs = entries.map((e) => buildRef(e.fileDir, e.fileName, e.functionName));
|
|
415
|
+
const handle: CompletionHandle = {
|
|
416
|
+
...allowConsent(),
|
|
417
|
+
complete: async () => ({
|
|
418
|
+
content: [{
|
|
419
|
+
type: "text",
|
|
420
|
+
// pretty-print f across lines; g normal; h missing; garbage around
|
|
421
|
+
text: [
|
|
422
|
+
"Sure:",
|
|
423
|
+
JSON.stringify({ ref: refs[0], description: "f summary", inputs: ["a"], outputs: ["b"] }, null, 2),
|
|
424
|
+
JSON.stringify({ ref: refs[1], description: "g summary", inputs: [], outputs: [] }),
|
|
425
|
+
"thanks!",
|
|
426
|
+
].join("\n"),
|
|
427
|
+
}],
|
|
428
|
+
}),
|
|
429
|
+
};
|
|
430
|
+
const report = await generateSummaries({ store, ctx: handle, skipConsent: true, batchTokens: 100_000 });
|
|
431
|
+
assert.equal(report.ok, 2);
|
|
432
|
+
assert.equal(report.failed, 1);
|
|
433
|
+
assert.equal(report.processed, 3);
|
|
434
|
+
const statuses = store.read(() =>
|
|
435
|
+
store.db.prepare(`SELECT function_name, summary_status, summary_error FROM functions ORDER BY function_name`).all(),
|
|
436
|
+
) as Array<{ function_name: string; summary_status: string; summary_error: string | null }>;
|
|
437
|
+
const byName = new Map(statuses.map((row) => [row.function_name, row]));
|
|
438
|
+
assert.equal(byName.get("f")?.summary_status, "ok");
|
|
439
|
+
assert.equal(byName.get("g")?.summary_status, "ok");
|
|
440
|
+
assert.equal(byName.get("h")?.summary_status, "failed");
|
|
441
|
+
assert.match(byName.get("h")?.summary_error ?? "", /no aligned summary record/);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("pendingFunctions retries previously failed summaries", async (t) => {
|
|
445
|
+
const opened = await openTemp();
|
|
446
|
+
if (!opened) return;
|
|
447
|
+
t.after(() => opened.cleanup());
|
|
448
|
+
const { store } = opened;
|
|
449
|
+
const entries = [makeEntry("ok1", "function ok1() {}")];
|
|
450
|
+
seedEntries(store, entries);
|
|
451
|
+
store.tx(() => {
|
|
452
|
+
store.db.prepare(`UPDATE functions SET summary_status = 'failed', summary_error = 'summary response returned 62 record(s) for batch of 63'`).run();
|
|
453
|
+
});
|
|
454
|
+
const pending = pendingFunctions(store);
|
|
455
|
+
assert.equal(pending.length, 1, "failed rows must be re-selected");
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("parseSummaryObjects handles escaped quotes, escaped backslashes, and CRLF", () => {
|
|
459
|
+
const tricky = JSON.stringify({ ref: "./e.ts::esc", description: 'quote " brace } backslash \\ end', inputs: [], outputs: [] });
|
|
460
|
+
const raw = ["prefix", tricky, '{"ref": "./e.ts::trail", "description": "ends with backslash \\\\", "inputs": [], "outputs": []}'].join("\r\n");
|
|
461
|
+
const objects = parseSummaryObjects(raw);
|
|
462
|
+
assert.equal(objects.length, 2);
|
|
463
|
+
assert.equal((objects[0] as { description?: string }).description, 'quote " brace } backslash \\ end');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("all-unaligned batch marks every function failed with the aligned-record reason", async (t) => {
|
|
467
|
+
const opened = await openTemp();
|
|
468
|
+
if (!opened) return;
|
|
469
|
+
t.after(() => opened.cleanup());
|
|
470
|
+
const { store } = opened;
|
|
471
|
+
const entries = [makeEntry("x", "function x() {}"), makeEntry("y", "function y() {}")];
|
|
472
|
+
seedEntries(store, entries);
|
|
473
|
+
const handle: CompletionHandle = {
|
|
474
|
+
...allowConsent(),
|
|
475
|
+
complete: async () => ({ content: [{ type: "text", text: "not jsonl at all" }] }),
|
|
476
|
+
};
|
|
477
|
+
const report = await generateSummaries({ store, ctx: handle, skipConsent: true, batchTokens: 100_000 });
|
|
478
|
+
assert.equal(report.ok, 0);
|
|
479
|
+
assert.equal(report.failed, 2);
|
|
480
|
+
const rows = store.read(() =>
|
|
481
|
+
store.db.prepare(`SELECT function_name, summary_error FROM functions ORDER BY function_name`).all(),
|
|
482
|
+
) as Array<{ function_name: string; summary_error: string | null }>;
|
|
483
|
+
for (const row of rows) {
|
|
484
|
+
assert.match(row.summary_error ?? "", /no aligned summary record for pkg\/math\.js::(x|y)/);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("validate clamps oversized fields instead of failing (documented contract)", async (t) => {
|
|
489
|
+
const opened = await openTemp();
|
|
490
|
+
if (!opened) return;
|
|
491
|
+
t.after(() => opened.cleanup());
|
|
492
|
+
const { store } = opened;
|
|
493
|
+
const entries = [makeEntry("clamp", "function clamp() {}")];
|
|
494
|
+
seedEntries(store, entries);
|
|
495
|
+
const refs = entries.map((e) => buildRef(e.fileDir, e.fileName, e.functionName));
|
|
496
|
+
const handle: CompletionHandle = {
|
|
497
|
+
...allowConsent(),
|
|
498
|
+
complete: async () => ({
|
|
499
|
+
content: [{
|
|
500
|
+
type: "text",
|
|
501
|
+
text: JSON.stringify({
|
|
502
|
+
ref: refs[0],
|
|
503
|
+
description: "d".repeat(500),
|
|
504
|
+
inputs: Array.from({ length: 20 }, (_, i) => `in${i}-${"x".repeat(100)}`),
|
|
505
|
+
outputs: [],
|
|
506
|
+
}),
|
|
507
|
+
}],
|
|
508
|
+
}),
|
|
509
|
+
};
|
|
510
|
+
const report = await generateSummaries({ store, ctx: handle, skipConsent: true, batchTokens: 100_000 });
|
|
511
|
+
assert.equal(report.ok, 1, "clamped record counts as ok (R-003 reuse-existing bounds)");
|
|
512
|
+
const row = store.read(() =>
|
|
513
|
+
store.db.prepare(`SELECT summary_description, summary_inputs FROM functions`).get(),
|
|
514
|
+
) as { summary_description: string; summary_inputs: string };
|
|
515
|
+
assert.equal(row.summary_description.length, 280);
|
|
516
|
+
assert.equal((JSON.parse(row.summary_inputs) as string[]).length, 8);
|
|
517
|
+
assert.equal((JSON.parse(row.summary_inputs) as string[])[0]!.length, 80);
|
|
518
|
+
});
|