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,36 @@
|
|
|
1
|
+
// JavaScript fixture used by the code-graph parser tests. Includes nested
|
|
2
|
+
// arrow functions, classes with methods, getters/setters, and anonymous
|
|
3
|
+
// expressions assigned to variables.
|
|
4
|
+
|
|
5
|
+
function alpha(a, b) {
|
|
6
|
+
return a + b;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const beta = function (x) {
|
|
10
|
+
return x * 2;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const gamma = (y) => y + 1;
|
|
14
|
+
|
|
15
|
+
class Container {
|
|
16
|
+
delta() {
|
|
17
|
+
return 4;
|
|
18
|
+
}
|
|
19
|
+
get foo() {
|
|
20
|
+
return "getter";
|
|
21
|
+
}
|
|
22
|
+
set foo(value) {
|
|
23
|
+
this._foo = value;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function epsilon(value) {
|
|
28
|
+
const inner = (v) => v;
|
|
29
|
+
return inner(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const factory = {
|
|
33
|
+
make() {
|
|
34
|
+
return () => 1;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Fixture for Python code graph tests."""
|
|
2
|
+
|
|
3
|
+
def alpha(a, b):
|
|
4
|
+
return a + b
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def beta(x):
|
|
8
|
+
return x * 2
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Container:
|
|
12
|
+
def delta(self):
|
|
13
|
+
return 4
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def helper(value):
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
gamma = lambda y: y + 1
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// TypeScript fixture for overload metadata and accessor parsing.
|
|
2
|
+
function overloaded(value: string): string;
|
|
3
|
+
function overloaded(value: number): number;
|
|
4
|
+
function overloaded(value: string | number): string | number {
|
|
5
|
+
return value;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
class Accessors {
|
|
9
|
+
get value(): string {
|
|
10
|
+
return "value";
|
|
11
|
+
}
|
|
12
|
+
set value(next: string) {
|
|
13
|
+
void next;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import * as assert from "node:assert/strict";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import * as url from "node:url";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { Store } from "../src/code-graph/store.ts";
|
|
9
|
+
import { runIndex } from "../src/code-graph/indexer.ts";
|
|
10
|
+
import { makeBackend } from "../src/code-graph/parsers/javascript.ts";
|
|
11
|
+
import { PythonBackend } from "../src/code-graph/parsers/python.ts";
|
|
12
|
+
import { loadGraphRuntime } from "../src/code-graph/runtime.ts";
|
|
13
|
+
import { setGraphEnabled } from "../src/state.ts";
|
|
14
|
+
import type { ParserBackend } from "../src/code-graph/parser.ts";
|
|
15
|
+
import type { Language } from "../src/code-graph/types.ts";
|
|
16
|
+
import { createGraphAwareFileTools } from "../tools/graph-aware-file-tools.ts";
|
|
17
|
+
|
|
18
|
+
const ROOT = path.dirname(path.dirname(url.fileURLToPath(import.meta.url)));
|
|
19
|
+
|
|
20
|
+
function git(cwd: string, args: string[]): void {
|
|
21
|
+
const env: Record<string, string | undefined> = { ...process.env };
|
|
22
|
+
for (const key of ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE"]) delete env[key];
|
|
23
|
+
const result = spawnSync("git", args, { cwd, env, encoding: "utf8" });
|
|
24
|
+
assert.equal(result.status, 0, `${args.join(" ")} failed: ${result.stderr}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function initRepo(): { root: string; cleanup: () => void } {
|
|
28
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-graph-tools-"));
|
|
29
|
+
git(root, ["init", "--initial-branch=main"]);
|
|
30
|
+
git(root, ["config", "user.email", "test@example.com"]);
|
|
31
|
+
git(root, ["config", "user.name", "test"]);
|
|
32
|
+
fs.writeFileSync(path.join(root, "math.ts"), "export function add(a: number, b: number): number { return a + b; }\n");
|
|
33
|
+
git(root, ["add", "-A"]);
|
|
34
|
+
git(root, ["commit", "-m", "init"]);
|
|
35
|
+
const canonicalRoot = fs.realpathSync(root);
|
|
36
|
+
return {
|
|
37
|
+
root: canonicalRoot,
|
|
38
|
+
cleanup: () => {
|
|
39
|
+
try {
|
|
40
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
41
|
+
} catch {
|
|
42
|
+
/* ignore */
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function loadParsers() {
|
|
49
|
+
const runtime = await loadGraphRuntime();
|
|
50
|
+
if (!runtime.status.parserAvailable || !runtime.status.sqliteAvailable) return null;
|
|
51
|
+
const ParserCtor = runtime.runtime.parser.Parser as unknown as new () => {
|
|
52
|
+
parse(input: string | Buffer): unknown;
|
|
53
|
+
setLanguage(language: unknown): void;
|
|
54
|
+
};
|
|
55
|
+
const parsers: Record<Language, ParserBackend> = {
|
|
56
|
+
javascript: makeBackend("javascript", ParserCtor, runtime.runtime.parser.javascript),
|
|
57
|
+
typescript: makeBackend("typescript", ParserCtor, runtime.runtime.parser.typescript),
|
|
58
|
+
tsx: makeBackend("tsx", ParserCtor, runtime.runtime.parser.tsx),
|
|
59
|
+
python: new PythonBackend(ParserCtor, runtime.runtime.parser.python),
|
|
60
|
+
};
|
|
61
|
+
return { runtime, parsers };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function openStore(root: string, sqlite: typeof import("node:sqlite")): Store {
|
|
65
|
+
const canonicalRoot = fs.realpathSync(root);
|
|
66
|
+
const dbPath = path.join(canonicalRoot, ".git", "pi_plans", "code_graph.db");
|
|
67
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
68
|
+
return new Store({ dbPath, worktreeRoot: canonicalRoot, gitCommonDir: path.join(canonicalRoot, ".git") }, sqlite);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function makeCtx(workdir: string) {
|
|
72
|
+
return {
|
|
73
|
+
cwd: workdir,
|
|
74
|
+
ui: {
|
|
75
|
+
notify: () => {},
|
|
76
|
+
},
|
|
77
|
+
} as any;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function firstText(result: { content: Array<{ type: string; text?: string }> }): string {
|
|
81
|
+
return result.content.find((part) => part.type === "text" && typeof part.text === "string")?.text ?? "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
test("index.ts wires graph-aware read/write/edit overrides", () => {
|
|
85
|
+
const source = fs.readFileSync(path.join(ROOT, "index.ts"), "utf8");
|
|
86
|
+
assert.match(source, /import \{ registerGraphAwareFileTools \} from "\.\/tools\/graph-aware-file-tools\.ts";/);
|
|
87
|
+
assert.match(source, /registerGraphAwareFileTools\(pi\);/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("graph-aware read/write/edit stage indexed code in the DB instead of on disk", async (t) => {
|
|
91
|
+
const loaded = await loadParsers();
|
|
92
|
+
if (!loaded) return;
|
|
93
|
+
const { runtime, parsers } = loaded;
|
|
94
|
+
const { root, cleanup } = initRepo();
|
|
95
|
+
setGraphEnabled(root, true);
|
|
96
|
+
const store = openStore(root, runtime.runtime.sqlite);
|
|
97
|
+
t.after(() => {
|
|
98
|
+
try {
|
|
99
|
+
store.close();
|
|
100
|
+
} finally {
|
|
101
|
+
cleanup();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
await runIndex({ store, worktreeRoot: root, parsers });
|
|
105
|
+
|
|
106
|
+
const tools = createGraphAwareFileTools(root);
|
|
107
|
+
const ctx = makeCtx(root);
|
|
108
|
+
const filePath = path.join(root, "math.ts");
|
|
109
|
+
const originalDisk = fs.readFileSync(filePath, "utf8");
|
|
110
|
+
|
|
111
|
+
const initialRead = await tools.read.execute("read-1", { path: "math.ts" }, undefined, undefined, ctx);
|
|
112
|
+
assert.match(firstText(initialRead), /return a \+ b;/);
|
|
113
|
+
|
|
114
|
+
const stagedWrite = "export function add(a: number, b: number): number { return a + b + 100; }\n";
|
|
115
|
+
await tools.write.execute("write-1", { path: "math.ts", content: stagedWrite }, undefined, undefined, ctx);
|
|
116
|
+
assert.equal(fs.readFileSync(filePath, "utf8"), originalDisk, "graph-aware write must not touch the worktree yet");
|
|
117
|
+
|
|
118
|
+
const stagedRead = await tools.read.execute("read-2", { path: "math.ts" }, undefined, undefined, ctx);
|
|
119
|
+
assert.match(firstText(stagedRead), /return a \+ b \+ 100;/);
|
|
120
|
+
|
|
121
|
+
const editResult = await tools.edit.execute(
|
|
122
|
+
"edit-1",
|
|
123
|
+
{
|
|
124
|
+
path: "math.ts",
|
|
125
|
+
edits: [{ oldText: "return a + b + 100;", newText: "return a + b + 200;" }],
|
|
126
|
+
},
|
|
127
|
+
undefined,
|
|
128
|
+
undefined,
|
|
129
|
+
ctx,
|
|
130
|
+
);
|
|
131
|
+
assert.match(firstText(editResult), /staged in code graph/);
|
|
132
|
+
assert.equal(fs.readFileSync(filePath, "utf8"), originalDisk, "graph-aware edit must stay DB-first until /apply-graph");
|
|
133
|
+
|
|
134
|
+
const editedRead = await tools.read.execute("read-3", { path: "math.ts" }, undefined, undefined, ctx);
|
|
135
|
+
assert.match(firstText(editedRead), /return a \+ b \+ 200;/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
function makeRepoWith(files: Record<string, string>): { root: string } {
|
|
139
|
+
const root0 = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-graph-read-"));
|
|
140
|
+
git(root0, ["init", "--initial-branch=main"]);
|
|
141
|
+
git(root0, ["config", "user.email", "test@example.com"]);
|
|
142
|
+
git(root0, ["config", "user.name", "test"]);
|
|
143
|
+
for (const [name, content] of Object.entries(files)) {
|
|
144
|
+
fs.writeFileSync(path.join(root0, name), content);
|
|
145
|
+
}
|
|
146
|
+
git(root0, ["add", "-A"]);
|
|
147
|
+
git(root0, ["commit", "-m", "init"]);
|
|
148
|
+
fs.mkdirSync(path.join(root0, ".git", "pi_plans"), { recursive: true });
|
|
149
|
+
return { root: fs.realpathSync(root0) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function bigSource(functionCount: number): string {
|
|
153
|
+
const parts: string[] = [];
|
|
154
|
+
for (let i = 0; i < functionCount; i++) {
|
|
155
|
+
parts.push(`export function fn${i}(x: number): number {`);
|
|
156
|
+
parts.push(`\treturn ${i} + x;`);
|
|
157
|
+
parts.push(`}`);
|
|
158
|
+
parts.push("");
|
|
159
|
+
}
|
|
160
|
+
return parts.join("\n");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
test("graph read defaults to a capped function digest; full/offset/beyond-EOF follow native semantics", async (t) => {
|
|
164
|
+
const loaded = await loadParsers();
|
|
165
|
+
if (!loaded) return;
|
|
166
|
+
const { runtime, parsers } = loaded;
|
|
167
|
+
const { root, cleanup } = initRepo();
|
|
168
|
+
setGraphEnabled(root, true);
|
|
169
|
+
const store = openStore(root, runtime.runtime.sqlite);
|
|
170
|
+
t.after(() => {
|
|
171
|
+
try {
|
|
172
|
+
store.close();
|
|
173
|
+
} finally {
|
|
174
|
+
cleanup();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
fs.writeFileSync(path.join(root, "big.ts"), bigSource(120));
|
|
178
|
+
git(root, ["add", "-A"]);
|
|
179
|
+
git(root, ["commit", "-m", "big"]);
|
|
180
|
+
await runIndex({ store, worktreeRoot: root, parsers });
|
|
181
|
+
|
|
182
|
+
const tools = createGraphAwareFileTools(root);
|
|
183
|
+
const ctx = makeCtx(root);
|
|
184
|
+
|
|
185
|
+
const digest = firstText(await tools.read.execute("d1", { path: "big.ts" }, undefined, undefined, ctx));
|
|
186
|
+
const digestLines = digest.split("\n");
|
|
187
|
+
assert.ok(digestLines.length <= 50, `digest must be capped, got ${digestLines.length}`);
|
|
188
|
+
assert.match(digestLines[0] ?? "", /big\.ts · typescript · 120 functions/);
|
|
189
|
+
assert.match(digest, /fn0 \(1-3\) function fn0\(x: number\): number \{/);
|
|
190
|
+
assert.match(digest, /\u2026\+\d+ more \(code_graph screening \/ get-function\)/);
|
|
191
|
+
assert.match(digest, /Use full:true for the whole file/);
|
|
192
|
+
assert.doesNotMatch(digest, /return 0 \+ x;/);
|
|
193
|
+
|
|
194
|
+
const whole = firstText(await tools.read.execute("d2", { path: "big.ts", full: true }, undefined, undefined, ctx));
|
|
195
|
+
assert.match(whole, /return 119 \+ x;/);
|
|
196
|
+
assert.ok(whole.split("\n").length > 400);
|
|
197
|
+
|
|
198
|
+
const slice = firstText(await tools.read.execute("d3", { path: "big.ts", full: true, offset: 477, limit: 3 }, undefined, undefined, ctx));
|
|
199
|
+
assert.match(slice, /export function fn119\(x: number\): number \{/);
|
|
200
|
+
|
|
201
|
+
await assert.rejects(
|
|
202
|
+
tools.read.execute("d4", { path: "big.ts", offset: 481 }, undefined, undefined, ctx),
|
|
203
|
+
/beyond end of file/,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
fs.writeFileSync(path.join(root, "consts.ts"), Array.from({ length: 250 }, (_, i) => `export const c${i} = ${i};`).join("\n") + "\n");
|
|
207
|
+
git(root, ["add", "-A"]);
|
|
208
|
+
git(root, ["commit", "-m", "consts"]);
|
|
209
|
+
await runIndex({ store, worktreeRoot: root, parsers });
|
|
210
|
+
const constsRead = firstText(await tools.read.execute("d5", { path: "consts.ts" }, undefined, undefined, ctx));
|
|
211
|
+
assert.match(constsRead, /c249 = 249/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("graph read folds synthetic anonymous functions and keeps named non-primary rows", async (t) => {
|
|
215
|
+
const loaded = await loadParsers();
|
|
216
|
+
if (!loaded) return;
|
|
217
|
+
const { runtime, parsers } = loaded;
|
|
218
|
+
const { root, cleanup } = initRepo();
|
|
219
|
+
setGraphEnabled(root, true);
|
|
220
|
+
const store = openStore(root, runtime.runtime.sqlite);
|
|
221
|
+
t.after(() => {
|
|
222
|
+
try {
|
|
223
|
+
store.close();
|
|
224
|
+
} finally {
|
|
225
|
+
cleanup();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
fs.writeFileSync(path.join(root, "big.ts"), bigSource(120));
|
|
229
|
+
git(root, ["add", "-A"]);
|
|
230
|
+
git(root, ["commit", "-m", "big"]);
|
|
231
|
+
await runIndex({ store, worktreeRoot: root, parsers });
|
|
232
|
+
store.tx(() => {
|
|
233
|
+
for (let i = 0; i < 5; i++) {
|
|
234
|
+
store.db
|
|
235
|
+
.prepare(
|
|
236
|
+
`INSERT INTO functions (file_dir, file_name, function_name, language, kind, full_code, full_code_hash, render_code, render_code_hash,
|
|
237
|
+
move_supported, is_primary, provenance_start_byte, provenance_end_byte, provenance_start_line, provenance_start_col,
|
|
238
|
+
provenance_end_line, provenance_end_col, version)
|
|
239
|
+
VALUES ('.', 'big.ts', ?, 'typescript', 'arrow', '', 'h', '', 'h', 1, 0, 0, 4, 1, 1, 1, 1, 1)`,
|
|
240
|
+
)
|
|
241
|
+
.run(`outer.<anonymous:arrow_function#${i}>`);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const tools = createGraphAwareFileTools(root);
|
|
246
|
+
const ctx = makeCtx(root);
|
|
247
|
+
const digest = firstText(await tools.read.execute("a1", { path: "big.ts" }, undefined, undefined, ctx));
|
|
248
|
+
assert.match(digest.split("\n")[0] ?? "", /120 functions \(\+5 anonymous\)/);
|
|
249
|
+
assert.match(digest, /…\+\d+ more/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("graph read marks unexpected fallbacks and stays silent for flag-off", async (t) => {
|
|
253
|
+
const loaded = await loadParsers();
|
|
254
|
+
if (!loaded) return;
|
|
255
|
+
const { runtime, parsers } = loaded;
|
|
256
|
+
const { root, cleanup } = initRepo();
|
|
257
|
+
setGraphEnabled(root, true);
|
|
258
|
+
const store = openStore(root, runtime.runtime.sqlite);
|
|
259
|
+
t.after(() => {
|
|
260
|
+
try {
|
|
261
|
+
store.close();
|
|
262
|
+
} finally {
|
|
263
|
+
cleanup();
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
await runIndex({ store, worktreeRoot: root, parsers });
|
|
267
|
+
// Close before the foreign-DB swap below: an open connection keeps wal/shm
|
|
268
|
+
// siblings alive and SQLite recovery would resurrect the old rows.
|
|
269
|
+
store.close();
|
|
270
|
+
fs.writeFileSync(path.join(root, "late.ts"), "export function late(): number { return 42; }\n");
|
|
271
|
+
|
|
272
|
+
const tools = createGraphAwareFileTools(root);
|
|
273
|
+
const ctx = makeCtx(root);
|
|
274
|
+
|
|
275
|
+
const notIndexed = firstText(await tools.read.execute("f1", { path: "late.ts" }, undefined, undefined, ctx));
|
|
276
|
+
assert.match(notIndexed, /^\[graph-read fallback: not indexed → native\]/);
|
|
277
|
+
assert.match(notIndexed, /return 42;/);
|
|
278
|
+
|
|
279
|
+
const normal = firstText(await tools.read.execute("f2", { path: "math.ts" }, undefined, undefined, ctx));
|
|
280
|
+
assert.doesNotMatch(normal, /^\[graph-read fallback/);
|
|
281
|
+
assert.match(normal, /return a \+ b;/);
|
|
282
|
+
|
|
283
|
+
setGraphEnabled(root, false);
|
|
284
|
+
const off = firstText(await tools.read.execute("f3", { path: "math.ts" }, undefined, undefined, ctx));
|
|
285
|
+
assert.doesNotMatch(off, /^\[graph-read fallback/);
|
|
286
|
+
assert.match(off, /return a \+ b;/);
|
|
287
|
+
|
|
288
|
+
fs.writeFileSync(path.join(root, ".git", "pi_plans", "config.json"), "{broken");
|
|
289
|
+
const broken = firstText(await tools.read.execute("f4", { path: "math.ts" }, undefined, undefined, ctx));
|
|
290
|
+
assert.match(broken, /^\[graph-read fallback: config read failed → native\]/);
|
|
291
|
+
|
|
292
|
+
// runtime unavailable: DB whose stored worktree is another directory.
|
|
293
|
+
// ensureRuntime caches by db path within a process, so probe in a child
|
|
294
|
+
// process with fresh module state: checkWorktree must reject the foreign DB.
|
|
295
|
+
fs.writeFileSync(path.join(root, ".git", "pi_plans", "config.json"), JSON.stringify({ schema: 1, graph_enabled: true }));
|
|
296
|
+
const otherRoot0 = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-graph-other-"));
|
|
297
|
+
git(otherRoot0, ["init", "--initial-branch=main"]);
|
|
298
|
+
const otherRoot = fs.realpathSync(otherRoot0);
|
|
299
|
+
const foreignStore = new Store(
|
|
300
|
+
{ dbPath: path.join(otherRoot, "foreign.db"), worktreeRoot: otherRoot, gitCommonDir: path.join(otherRoot, ".git") },
|
|
301
|
+
runtime.runtime.sqlite,
|
|
302
|
+
);
|
|
303
|
+
foreignStore.close();
|
|
304
|
+
fs.copyFileSync(path.join(otherRoot, "foreign.db"), path.join(root, ".git", "pi_plans", "code_graph.db"));
|
|
305
|
+
fs.writeFileSync(path.join(root, "math.ts"), "export function add(a: number, b: number): number { return a + b; }\n");
|
|
306
|
+
const probeScript = [
|
|
307
|
+
"const fs = await import('node:fs');",
|
|
308
|
+
"const { pathToFileURL } = await import('node:url');",
|
|
309
|
+
`const mod = await import(pathToFileURL(${JSON.stringify(path.join(ROOT, "tools", "graph-aware-file-tools.ts"))}));`,
|
|
310
|
+
`const tools = mod.createGraphAwareFileTools(${JSON.stringify(root)});`,
|
|
311
|
+
"const ctx = { cwd: process.cwd(), ui: { notify: () => {} } };",
|
|
312
|
+
"const r = await tools.read.execute('probe', { path: 'math.ts' }, undefined, undefined, ctx);",
|
|
313
|
+
"const text = r.content.find((c) => c.type === 'text').text;",
|
|
314
|
+
"console.log(JSON.stringify(text));",
|
|
315
|
+
].join("\n");
|
|
316
|
+
const probe = spawnSync(process.execPath, ["--experimental-strip-types", "--input-type=module", "-e", probeScript], {
|
|
317
|
+
cwd: root,
|
|
318
|
+
encoding: "utf8",
|
|
319
|
+
timeout: 60000,
|
|
320
|
+
});
|
|
321
|
+
let probeText = "";
|
|
322
|
+
try {
|
|
323
|
+
probeText = JSON.parse(probe.stdout.trim().split("\n").filter((l) => l.startsWith("\"") || l.startsWith("["))[0] ?? "\"\"");
|
|
324
|
+
} catch {
|
|
325
|
+
probeText = probe.stderr;
|
|
326
|
+
}
|
|
327
|
+
assert.match(probeText, /^\[graph-read fallback: runtime unavailable → native\]/);
|
|
328
|
+
|
|
329
|
+
fs.rmSync(otherRoot0, { recursive: true, force: true });
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("full read triggers the native-equivalent safety valve and marks truncation in details", async (t) => {
|
|
333
|
+
const loaded = await loadParsers();
|
|
334
|
+
if (!loaded) return;
|
|
335
|
+
const { runtime, parsers } = loaded;
|
|
336
|
+
const { root, cleanup } = initRepo();
|
|
337
|
+
setGraphEnabled(root, true);
|
|
338
|
+
const store = openStore(root, runtime.runtime.sqlite);
|
|
339
|
+
t.after(() => {
|
|
340
|
+
try {
|
|
341
|
+
store.close();
|
|
342
|
+
} finally {
|
|
343
|
+
cleanup();
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
fs.writeFileSync(path.join(root, "huge.ts"), bigSource(700)); // 2800 lines > DEFAULT_MAX_LINES
|
|
347
|
+
git(root, ["add", "-A"]);
|
|
348
|
+
git(root, ["commit", "-m", "huge"]);
|
|
349
|
+
await runIndex({ store, worktreeRoot: root, parsers });
|
|
350
|
+
|
|
351
|
+
const tools = createGraphAwareFileTools(root);
|
|
352
|
+
const ctx = makeCtx(root);
|
|
353
|
+
const result = await tools.read.execute("t1", { path: "huge.ts", full: true }, undefined, undefined, ctx);
|
|
354
|
+
assert.equal(result.details.truncated, true);
|
|
355
|
+
assert.equal(result.details.truncatedBy, "lines");
|
|
356
|
+
assert.ok(result.details.outputLines <= 2000);
|
|
357
|
+
assert.ok(result.details.totalLines >= 2800);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("digest descriptions decode multibyte slices and degrade across all three levels", async (t) => {
|
|
361
|
+
const loaded = await loadParsers();
|
|
362
|
+
if (!loaded) return;
|
|
363
|
+
const { runtime, parsers } = loaded;
|
|
364
|
+
const { root, cleanup } = initRepo();
|
|
365
|
+
setGraphEnabled(root, true);
|
|
366
|
+
const store = openStore(root, runtime.runtime.sqlite);
|
|
367
|
+
t.after(() => {
|
|
368
|
+
try {
|
|
369
|
+
store.close();
|
|
370
|
+
} finally {
|
|
371
|
+
cleanup();
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
fs.writeFileSync(
|
|
375
|
+
path.join(root, "mixed.ts"),
|
|
376
|
+
[
|
|
377
|
+
"// 中文注释:多字节字符用于锁定字节偏移切片的正确性。",
|
|
378
|
+
"export function 求和(a: number, b: number): number {",
|
|
379
|
+
"\treturn a + b;",
|
|
380
|
+
"}",
|
|
381
|
+
"",
|
|
382
|
+
"export function noProvenance() {",
|
|
383
|
+
"\treturn 1;",
|
|
384
|
+
"}",
|
|
385
|
+
"",
|
|
386
|
+
...Array.from({ length: 200 }, (_, i) => `export const pad${i} = ${i};`), // push past the 200-line full-text threshold
|
|
387
|
+
].join("\n") + "\n",
|
|
388
|
+
);
|
|
389
|
+
git(root, ["add", "-A"]);
|
|
390
|
+
git(root, ["commit", "-m", "mixed"]);
|
|
391
|
+
await runIndex({ store, worktreeRoot: root, parsers });
|
|
392
|
+
// Level 1: summary_description set → used verbatim.
|
|
393
|
+
store.tx(() => {
|
|
394
|
+
store.db
|
|
395
|
+
.prepare(`UPDATE functions SET summary_description = ? WHERE function_name = ?`)
|
|
396
|
+
.run("求和函数:返回两数之和", "求和");
|
|
397
|
+
store.db
|
|
398
|
+
.prepare(`UPDATE functions SET provenance_start_byte = provenance_end_byte WHERE function_name = ?`)
|
|
399
|
+
.run("noProvenance");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const tools = createGraphAwareFileTools(root);
|
|
403
|
+
const ctx = makeCtx(root);
|
|
404
|
+
const digest = firstText(await tools.read.execute("m1", { path: "mixed.ts" }, undefined, undefined, ctx));
|
|
405
|
+
// Level 1: summary_description wins over the byte slice.
|
|
406
|
+
assert.match(digest, /求和 .*求和函数:返回两数之和/);
|
|
407
|
+
// Level 3: NULL provenance bytes → name + line range, empty description.
|
|
408
|
+
assert.match(digest, /noProvenance \(6-8\)\n/);
|
|
409
|
+
// Level 2 sanity: no mojibake from byte-offset slicing on CJK sources.
|
|
410
|
+
assert.doesNotMatch(digest, /\uFFFD/);
|
|
411
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
1
|
+
import * as assert from "node:assert/strict";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
2
4
|
import { describe, it } from "node:test";
|
|
3
|
-
import { buildCriticizerTask, buildReviewerTask, reviewerLanes } from "../src/refine-prompts.ts";
|
|
5
|
+
import { buildCriticizerTask, buildImplementationCriticizerTask, buildImplementationReviewerTask, buildReviewerTask, reviewerLanes } from "../src/refine-prompts.ts";
|
|
4
6
|
|
|
5
7
|
describe("reviewerLanes", () => {
|
|
6
8
|
it("uses stable lane ids for the big-plan fanout", () => {
|
|
@@ -47,3 +49,66 @@ describe("buildCriticizerTask", () => {
|
|
|
47
49
|
assert.match(text, /never rewrite the plan/);
|
|
48
50
|
});
|
|
49
51
|
});
|
|
52
|
+
|
|
53
|
+
describe("buildImplementationReviewerTask", () => {
|
|
54
|
+
it("anchors findings to the plan and explicitly assesses delivery maturity", () => {
|
|
55
|
+
const text = buildImplementationReviewerTask({
|
|
56
|
+
planText: "# plan",
|
|
57
|
+
planPath: "/tmp/PLAN_v1.md",
|
|
58
|
+
lens: "correctness",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
assert.match(text, /Goal: review the implemented result in the worktree against the plan\./);
|
|
62
|
+
assert.match(text, /the IMPLEMENTATION in the worktree is under review/);
|
|
63
|
+
assert.match(text, /Judge the implementation against the plan's goals/);
|
|
64
|
+
assert.match(text, /did the executor ship a minimal MVP only, or refine for long-term growth/);
|
|
65
|
+
assert.match(text, /Out-of-scope improvement ideas are low severity by default/);
|
|
66
|
+
assert.match(text, /Review lens: correctness\./);
|
|
67
|
+
assert.match(text, /Surface at most five high-priority findings/);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("buildImplementationCriticizerTask", () => {
|
|
72
|
+
it("asks implementation-focused adversarial questions without rewriting the implementation", () => {
|
|
73
|
+
const text = buildImplementationCriticizerTask({
|
|
74
|
+
planText: "# plan",
|
|
75
|
+
planPath: "/tmp/PLAN_v1.md",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
assert.match(text, /Goal: stress-test the implemented result's assumptions\./);
|
|
79
|
+
assert.match(text, /the IMPLEMENTATION in the worktree is under review/);
|
|
80
|
+
assert.match(text, /never rewrite the plan or the implementation/);
|
|
81
|
+
assert.match(text, /at most five adaptive questions/);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("plan-mode builders are unchanged by the implementation-mode addition", () => {
|
|
86
|
+
it("buildReviewerTask output is byte-identical to its prior contract", () => {
|
|
87
|
+
// Snapshot regression guard: changing the plan-mode brief would silently
|
|
88
|
+
// break existing reviewer subagents. Keep this stable.
|
|
89
|
+
const before = buildReviewerTask({ planText: "PLAN", planPath: "/p/PLAN_v1.md" });
|
|
90
|
+
assert.match(before, /Goal: review the plan against the repository\./);
|
|
91
|
+
assert.doesNotMatch(before, /IMPLEMENTATION in the worktree/);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("refine tool wires target to the right builder", () => {
|
|
96
|
+
it("forwards target=implementation to the implementation builders", () => {
|
|
97
|
+
const source = fs.readFileSync(path.join(process.cwd(), "tools", "refine.ts"), "utf8");
|
|
98
|
+
assert.match(source, /buildImplementationReviewerTask/);
|
|
99
|
+
assert.match(source, /buildImplementationCriticizerTask/);
|
|
100
|
+
assert.match(source, /target\s*===\s*"implementation"/);
|
|
101
|
+
assert.match(source, /params\.target\s*\?\?\s*"plan"/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("criticizer spawn gets the graph tools and prompt like the reviewer", () => {
|
|
105
|
+
const source = fs.readFileSync(path.join(process.cwd(), "tools", "refine.ts"), "utf8");
|
|
106
|
+
const criticizerBlock = source.slice(
|
|
107
|
+
source.indexOf('params.role === "criticizer"'),
|
|
108
|
+
source.indexOf("const count = Math.min"),
|
|
109
|
+
);
|
|
110
|
+
assert.ok(criticizerBlock.length > 0, "criticizer block not found");
|
|
111
|
+
assert.match(criticizerBlock, /tools: subagentTools/);
|
|
112
|
+
assert.match(criticizerBlock, /graphPrompt/);
|
|
113
|
+
});
|
|
114
|
+
});
|