pi-plans 0.2.0 → 0.3.1
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 +90 -26
- package/agents/ref-analyst.md +18 -0
- package/index.ts +121 -9
- package/package.json +16 -1
- package/references/pi-planning-workflow.md +21 -6
- package/references/state-and-config.md +52 -5
- package/scripts/validate.ts +5 -0
- package/skills/plan-with-refs/SKILL.md +3 -3
- package/src/code-graph/commands.ts +483 -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 +361 -0
- package/src/exec.ts +508 -693
- package/src/guard.ts +14 -1
- package/src/refine-prompts.ts +109 -0
- package/src/refine-ui-helpers.ts +71 -18
- package/src/refine-ui-state.ts +88 -22
- package/src/refine-ui.ts +210 -102
- package/src/state.ts +36 -7
- package/src/subagent.ts +164 -61
- package/src/termination-prompt.ts +22 -0
- package/tests/analyze-refs.test.ts +265 -0
- package/tests/ask-choice.test.ts +264 -0
- package/tests/autocomplete.test.ts +6 -1
- package/tests/code-graph-apply-action.test.ts +173 -0
- 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 +263 -0
- package/tests/exec.test.ts +808 -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/guard.test.ts +27 -1
- package/tests/plans.test.ts +10 -0
- package/tests/refine-prompts.test.ts +101 -2
- package/tests/refine-ui.test.ts +371 -72
- package/tests/state.test.ts +32 -0
- package/tests/subagent.test.ts +48 -20
- package/tools/analyze-refs.ts +263 -0
- package/tools/ask-choice.ts +159 -11
- package/tools/code-graph.ts +277 -0
- package/tools/graph-aware-file-tools.ts +392 -0
- package/tools/plans.ts +97 -2
- package/tools/refine.ts +61 -15
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end index integration test: write a small fixture into a git
|
|
3
|
+
* worktree, run the indexer, and verify function rows and edges.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import * as assert from "node:assert/strict";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { Store } from "../src/code-graph/store.ts";
|
|
13
|
+
import { makeBackend } from "../src/code-graph/parsers/javascript.ts";
|
|
14
|
+
import { PythonBackend } from "../src/code-graph/parsers/python.ts";
|
|
15
|
+
import { runIndex } from "../src/code-graph/indexer.ts";
|
|
16
|
+
import { loadGraphRuntime } from "../src/code-graph/runtime.ts";
|
|
17
|
+
import { screeningQuery } from "../src/code-graph/screening.ts";
|
|
18
|
+
import { hashText } from "../src/code-graph/parser.ts";
|
|
19
|
+
import type { ParserBackend } from "../src/code-graph/parser.ts";
|
|
20
|
+
import type { Language } from "../src/code-graph/types.ts";
|
|
21
|
+
|
|
22
|
+
function git(cwd: string, args: string[]): string {
|
|
23
|
+
const env: Record<string, string | undefined> = { ...process.env };
|
|
24
|
+
for (const key of ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE"]) delete env[key];
|
|
25
|
+
const result = spawnSync("git", args, { cwd, env, encoding: "utf8" });
|
|
26
|
+
return (result.stdout ?? "").trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function initRepo(): { worktreeRoot: string; cleanup: () => void } {
|
|
30
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-index-"));
|
|
31
|
+
git(dir, ["init", "--initial-branch=main"]);
|
|
32
|
+
git(dir, ["config", "user.email", "test@example.com"]);
|
|
33
|
+
git(dir, ["config", "user.name", "test"]);
|
|
34
|
+
const subdir = path.join(dir, "pkg");
|
|
35
|
+
fs.mkdirSync(subdir, { recursive: true });
|
|
36
|
+
fs.writeFileSync(path.join(subdir, "math.js"), `function add(a, b) { return a + b; }\nfunction mul(a, b) { return a * b; }\nfunction same() { return 1; }\nfunction same() { return 2; }\n`);
|
|
37
|
+
fs.writeFileSync(path.join(subdir, "note.py"), `def hello(name):\n return f"hi {name}"\n`);
|
|
38
|
+
fs.writeFileSync(path.join(subdir, "api.ts"), `function overloaded(value: string): string;\nfunction overloaded(value: number): number;\nfunction overloaded(value: string | number): string | number { return value; }\n\nclass Accessors {\n\tget value(): string { return "value"; }\n\tset value(next: string) { void next; }\n}\n`);
|
|
39
|
+
git(subdir, ["add", "-A"]);
|
|
40
|
+
git(subdir, ["commit", "-m", "init"]);
|
|
41
|
+
return {
|
|
42
|
+
worktreeRoot: subdir,
|
|
43
|
+
cleanup: () => {
|
|
44
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("runIndex indexes files, writes rows, and screening excludes full_code", async (t) => {
|
|
50
|
+
const { worktreeRoot, cleanup } = initRepo();
|
|
51
|
+
t.after(cleanup);
|
|
52
|
+
const runtime = await loadGraphRuntime();
|
|
53
|
+
if (!runtime.status.parserAvailable || !runtime.status.sqliteAvailable) return;
|
|
54
|
+
const ParserCtor = runtime.runtime.parser.Parser as unknown as new () => {
|
|
55
|
+
parse(input: string | Buffer): unknown;
|
|
56
|
+
setLanguage(language: unknown): void;
|
|
57
|
+
};
|
|
58
|
+
const parsers: Record<Language, ParserBackend> = {
|
|
59
|
+
javascript: makeBackend("javascript", ParserCtor, runtime.runtime.parser.javascript),
|
|
60
|
+
typescript: makeBackend("typescript", ParserCtor, runtime.runtime.parser.typescript),
|
|
61
|
+
tsx: makeBackend("tsx", ParserCtor, runtime.runtime.parser.tsx),
|
|
62
|
+
python: new PythonBackend(ParserCtor, runtime.runtime.parser.python),
|
|
63
|
+
};
|
|
64
|
+
const sqlite = await import("node:sqlite");
|
|
65
|
+
const commonDir = git(worktreeRoot, ["rev-parse", "--git-common-dir"]);
|
|
66
|
+
const dbDir = path.join(path.resolve(worktreeRoot, commonDir), "pi_plans");
|
|
67
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
68
|
+
const dbPath = path.join(dbDir, "code_graph.db");
|
|
69
|
+
try { fs.unlinkSync(dbPath); } catch { /* ignore */ }
|
|
70
|
+
const store = new Store(
|
|
71
|
+
{ dbPath, worktreeRoot, gitCommonDir: path.resolve(worktreeRoot, commonDir) },
|
|
72
|
+
sqlite,
|
|
73
|
+
);
|
|
74
|
+
t.after(() => {
|
|
75
|
+
store.close();
|
|
76
|
+
try { fs.unlinkSync(dbPath); } catch { /* ignore */ }
|
|
77
|
+
});
|
|
78
|
+
const report = await runIndex({ store, worktreeRoot, parsers });
|
|
79
|
+
assert.equal(report.filesScanned >= 2, true, `expected >=2 files, got ${report.filesScanned}`);
|
|
80
|
+
assert.equal(report.functionsIndexed >= 3, true, `expected >=3 functions, got ${report.functionsIndexed}`);
|
|
81
|
+
const screening = screeningQuery({ store, functionNameLike: "add", limit: 50 });
|
|
82
|
+
assert.equal(screening.length, 1);
|
|
83
|
+
assert.equal(screening[0].fullCode, undefined);
|
|
84
|
+
assert.equal(screening[0].functionName, "add");
|
|
85
|
+
const same = screeningQuery({ store, functionNameLike: "same", limit: 50 });
|
|
86
|
+
assert.deepEqual(same.map((item) => item.functionName), ["same", "same#2"]);
|
|
87
|
+
const manifestNames = store.read(() =>
|
|
88
|
+
store.db.prepare("SELECT function_name FROM file_entries WHERE file_dir = '.' AND file_name = 'math.js' AND function_name IS NOT NULL ORDER BY ordinal").all(),
|
|
89
|
+
) as Array<{ function_name: string }>;
|
|
90
|
+
assert.deepEqual(manifestNames.map((row) => row.function_name), ["add", "mul", "same", "same#2"]);
|
|
91
|
+
const overload = store.read(() =>
|
|
92
|
+
store.db.prepare("SELECT function_name, overload_signatures FROM functions WHERE file_name = 'api.ts' AND function_name = 'overloaded'").all(),
|
|
93
|
+
) as Array<{ function_name: string; overload_signatures: string | null }>;
|
|
94
|
+
assert.equal(overload.length, 1);
|
|
95
|
+
assert.equal(overload[0].function_name, "overloaded");
|
|
96
|
+
assert.deepEqual(JSON.parse(overload[0].overload_signatures ?? "[]"), [
|
|
97
|
+
"function overloaded(value: string): string;",
|
|
98
|
+
"function overloaded(value: number): number;",
|
|
99
|
+
]);
|
|
100
|
+
const accessorRows = store.read(() =>
|
|
101
|
+
store.db.prepare("SELECT function_name, kind FROM functions WHERE file_name = 'api.ts' AND kind = 'accessor' ORDER BY provenance_start_byte").all(),
|
|
102
|
+
) as Array<{ function_name: string; kind: string }>;
|
|
103
|
+
assert.deepEqual(accessorRows.map((row) => [row.function_name, row.kind]), [
|
|
104
|
+
["Accessors.value", "accessor"],
|
|
105
|
+
["Accessors.value#2", "accessor"],
|
|
106
|
+
]);
|
|
107
|
+
const initialSource = fs.readFileSync(path.join(worktreeRoot, "math.js"), "utf8");
|
|
108
|
+
const initialFile = store.read(() =>
|
|
109
|
+
store.db.prepare("SELECT source_hash FROM files WHERE file_dir = '.' AND file_name = 'math.js'").get(),
|
|
110
|
+
) as { source_hash: string };
|
|
111
|
+
assert.equal(initialFile.source_hash, hashText(initialSource));
|
|
112
|
+
|
|
113
|
+
const mul = screeningQuery({ store, functionNameLike: "mul", limit: 50 });
|
|
114
|
+
assert.equal(mul.length, 1);
|
|
115
|
+
assert.equal(mul[0].functionName, "mul");
|
|
116
|
+
|
|
117
|
+
fs.writeFileSync(path.join(worktreeRoot, "math.js"), `function add(a, b) { return a + b; }\nfunction mul(a, b) { return a * b; }\nfunction same() { return 3; }\nfunction later() { return same(); }\n`);
|
|
118
|
+
const reindexReport = await runIndex({ store, worktreeRoot, parsers, reindex: true });
|
|
119
|
+
assert.equal(reindexReport.conflicts >= 1, true);
|
|
120
|
+
const updatedSource = fs.readFileSync(path.join(worktreeRoot, "math.js"), "utf8");
|
|
121
|
+
const updatedFile = store.read(() =>
|
|
122
|
+
store.db.prepare("SELECT source_hash FROM files WHERE file_dir = '.' AND file_name = 'math.js'").get(),
|
|
123
|
+
) as { source_hash: string };
|
|
124
|
+
assert.equal(updatedFile.source_hash, hashText(updatedSource));
|
|
125
|
+
const names = store.read(() =>
|
|
126
|
+
store.db.prepare("SELECT function_name FROM functions WHERE file_dir = '.' AND file_name = 'math.js' ORDER BY provenance_start_byte").all(),
|
|
127
|
+
) as Array<{ function_name: string }>;
|
|
128
|
+
const edgeRows = store.read(() =>
|
|
129
|
+
store.db.prepare("SELECT from_function FROM call_edges WHERE from_file_dir = '.' AND from_file_name = 'math.js' ORDER BY from_function").all(),
|
|
130
|
+
) as Array<{ from_function: string }>;
|
|
131
|
+
assert.ok(edgeRows.some((row) => row.from_function === "later"));
|
|
132
|
+
assert.deepEqual(names.map((row) => row.function_name), ["add", "mul", "same", "later"]);
|
|
133
|
+
const remainingManifest = store.read(() =>
|
|
134
|
+
store.db.prepare("SELECT function_name FROM file_entries WHERE file_dir = '.' AND file_name = 'math.js' AND function_name IS NOT NULL ORDER BY ordinal").all(),
|
|
135
|
+
) as Array<{ function_name: string }>;
|
|
136
|
+
assert.deepEqual(remainingManifest.map((row) => row.function_name), ["add", "mul", "same", "later"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("runIndex with opts.paths indexes only hit paths and purges missing ones", async (t) => {
|
|
140
|
+
const { worktreeRoot, cleanup } = initRepo();
|
|
141
|
+
t.after(cleanup);
|
|
142
|
+
const runtime = await loadGraphRuntime();
|
|
143
|
+
if (!runtime.status.parserAvailable || !runtime.status.sqliteAvailable) return;
|
|
144
|
+
const ParserCtor = runtime.runtime.parser.Parser as unknown as new () => {
|
|
145
|
+
parse(input: string | Buffer): unknown;
|
|
146
|
+
setLanguage(language: unknown): void;
|
|
147
|
+
};
|
|
148
|
+
const parsers: Record<Language, ParserBackend> = {
|
|
149
|
+
javascript: makeBackend("javascript", ParserCtor, runtime.runtime.parser.javascript),
|
|
150
|
+
typescript: makeBackend("typescript", ParserCtor, runtime.runtime.parser.typescript),
|
|
151
|
+
tsx: makeBackend("tsx", ParserCtor, runtime.runtime.parser.tsx),
|
|
152
|
+
python: new PythonBackend(ParserCtor, runtime.runtime.parser.python),
|
|
153
|
+
};
|
|
154
|
+
const stateRoot = path.join(worktreeRoot, ".git", "pi_plans");
|
|
155
|
+
fs.mkdirSync(stateRoot, { recursive: true });
|
|
156
|
+
const store = new Store({ dbPath: path.join(stateRoot, "code_graph.db"), worktreeRoot, gitCommonDir: path.join(worktreeRoot, ".git") }, runtime.runtime.sqlite);
|
|
157
|
+
t.after(() => store.close());
|
|
158
|
+
await runIndex({ store, worktreeRoot, parsers });
|
|
159
|
+
|
|
160
|
+
// Modify one file, delete another.
|
|
161
|
+
fs.writeFileSync(path.join(worktreeRoot, "math.js"), "function add(a, b) { return a + b + 1; }\n");
|
|
162
|
+
fs.rmSync(path.join(worktreeRoot, "note.py"));
|
|
163
|
+
|
|
164
|
+
const report = await runIndex({ store, worktreeRoot, parsers, paths: ["./math.js", "./note.py"] });
|
|
165
|
+
assert.deepEqual(report.reindexedPaths.sort(), ["./math.js"]);
|
|
166
|
+
assert.deepEqual(report.purgedPaths.sort(), ["./note.py"]);
|
|
167
|
+
const noteRows = store.read(() => store.db.prepare("SELECT COUNT(*) AS c FROM files WHERE file_name = 'note.py'").get()) as { c: number };
|
|
168
|
+
assert.equal(noteRows.c, 0, "deleted file rows must be purged");
|
|
169
|
+
const noteFunctions = store.read(() => store.db.prepare("SELECT COUNT(*) AS c FROM functions WHERE file_name = 'note.py'").get()) as { c: number };
|
|
170
|
+
assert.equal(noteFunctions.c, 0);
|
|
171
|
+
const apiRows = store.read(() => store.db.prepare("SELECT COUNT(*) AS c FROM files WHERE file_name = 'api.ts'").get()) as { c: number };
|
|
172
|
+
assert.equal(apiRows.c, 1, "untouched files must remain indexed");
|
|
173
|
+
const mathHash = store.read(() => store.db.prepare("SELECT source_hash FROM files WHERE file_name = 'math.js'").get()) as { source_hash: string };
|
|
174
|
+
assert.equal(mathHash.source_hash, hashText("function add(a, b) { return a + b + 1; }\n"));
|
|
175
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end code-graph loop test: chore commit + snapshot → DB-first
|
|
3
|
+
* mutation → apply → drift → final-commit → clean init; plus the human-edit
|
|
4
|
+
* (update-graph) and deletion branches.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import * as assert from "node:assert/strict";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { Store } from "../src/code-graph/store.ts";
|
|
14
|
+
import { runIndex } from "../src/code-graph/indexer.ts";
|
|
15
|
+
import { materializeFile } from "../src/code-graph/materialize.ts";
|
|
16
|
+
import { makeBackend } from "../src/code-graph/parsers/javascript.ts";
|
|
17
|
+
import { hashText } from "../src/code-graph/parser.ts";
|
|
18
|
+
import { loadGraphRuntime } from "../src/code-graph/runtime.ts";
|
|
19
|
+
import { computeDrift } from "../src/code-graph/commands.ts";
|
|
20
|
+
import { gitAddAllAndCommit, gitHead } from "../src/code-graph/git.ts";
|
|
21
|
+
import { finalCommit } from "../tools/plans.ts";
|
|
22
|
+
import type { ParserBackend } from "../src/code-graph/parser.ts";
|
|
23
|
+
import type { Language } from "../src/code-graph/types.ts";
|
|
24
|
+
|
|
25
|
+
function git(cwd: string, args: string[]): { code: number; stdout: string } {
|
|
26
|
+
const env: Record<string, string | undefined> = { ...process.env };
|
|
27
|
+
for (const key of ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE"]) delete env[key];
|
|
28
|
+
const result = spawnSync("git", args, { cwd, env, encoding: "utf8" });
|
|
29
|
+
return { code: result.status ?? 1, stdout: result.stdout ?? "" };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function initRepo(): { root: string; cleanup: () => void } {
|
|
33
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-loop-"));
|
|
34
|
+
git(dir, ["init", "--initial-branch=main"]);
|
|
35
|
+
git(dir, ["config", "user.email", "test@example.com"]);
|
|
36
|
+
git(dir, ["config", "user.name", "test"]);
|
|
37
|
+
fs.writeFileSync(path.join(dir, "math.js"), "function add(a, b) { return a + b; }\n");
|
|
38
|
+
fs.writeFileSync(path.join(dir, "helper.ts"), "function twice(n: number): number { return n * 2; }\n");
|
|
39
|
+
git(dir, ["add", "-A"]);
|
|
40
|
+
git(dir, ["commit", "-m", "init"]);
|
|
41
|
+
return {
|
|
42
|
+
root: dir,
|
|
43
|
+
cleanup: () => {
|
|
44
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makeNotify(): { lines: string[]; notify: (message: string, kind?: "info" | "warning" | "error") => void } {
|
|
50
|
+
const lines: string[] = [];
|
|
51
|
+
return { lines, notify: (message) => { lines.push(message); } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test("closed loop: init → mutation → apply → drift → final-commit → clean init", async (t) => {
|
|
55
|
+
const runtime = await loadGraphRuntime();
|
|
56
|
+
if (!runtime.status.parserAvailable || !runtime.status.sqliteAvailable) return;
|
|
57
|
+
const { root, cleanup } = initRepo();
|
|
58
|
+
t.after(cleanup);
|
|
59
|
+
|
|
60
|
+
const stateRoot = path.join(root, ".git", "pi_plans");
|
|
61
|
+
fs.mkdirSync(stateRoot, { recursive: true });
|
|
62
|
+
const dbPath = path.join(stateRoot, "code_graph.db");
|
|
63
|
+
const store = new Store({ dbPath, worktreeRoot: root, gitCommonDir: path.join(root, ".git") }, runtime.runtime.sqlite);
|
|
64
|
+
t.after(() => store.close());
|
|
65
|
+
|
|
66
|
+
const ParserCtor = runtime.runtime.parser.Parser as unknown as new () => {
|
|
67
|
+
parse(input: string | Buffer): unknown;
|
|
68
|
+
setLanguage(language: unknown): void;
|
|
69
|
+
};
|
|
70
|
+
const parsers: Record<Language, ParserBackend> = {
|
|
71
|
+
javascript: makeBackend("javascript", ParserCtor, runtime.runtime.parser.javascript),
|
|
72
|
+
typescript: makeBackend("typescript", ParserCtor, runtime.runtime.parser.typescript),
|
|
73
|
+
tsx: makeBackend("tsx", ParserCtor, runtime.runtime.parser.tsx),
|
|
74
|
+
python: new (await import("../src/code-graph/parsers/python.ts")).PythonBackend(ParserCtor, runtime.runtime.parser.python),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// 1. init-graph: dirty? no (we committed). index + snapshot.
|
|
78
|
+
fs.writeFileSync(path.join(root, "dirty.js"), "// stray\n");
|
|
79
|
+
const chore = gitAddAllAndCommit(root, "chore(code-graph): pre-init snapshot");
|
|
80
|
+
assert.notEqual(chore, "", "chore commit created for dirty tree");
|
|
81
|
+
await runIndex({ store, worktreeRoot: root, parsers });
|
|
82
|
+
store.upsertSnapshot(gitHead(root), []);
|
|
83
|
+
assert.ok(store.readLatestSnapshot(), "snapshot written");
|
|
84
|
+
|
|
85
|
+
// 2. DB-first mutation: update-function on math.js:add. Maintains
|
|
86
|
+
// functions.full_code and files.source_text (the canonical new content).
|
|
87
|
+
const newBody = "function add(a, b) { return a + b + 100; }\n";
|
|
88
|
+
const manifest = store.read(() =>
|
|
89
|
+
store.db.prepare(`SELECT ordinal, start_byte, end_byte, text FROM file_entries WHERE file_name = 'math.js' ORDER BY ordinal`).all(),
|
|
90
|
+
) as Array<{ ordinal: number; start_byte: number; end_byte: number; text: string }>;
|
|
91
|
+
void manifest;
|
|
92
|
+
store.tx(() => {
|
|
93
|
+
store.db
|
|
94
|
+
.prepare(`UPDATE functions SET full_code = ?, full_code_hash = ?, render_code = ?, render_code_hash = ? WHERE file_name = 'math.js' AND function_name = 'add'`)
|
|
95
|
+
.run(newBody, "h1", newBody, "h1");
|
|
96
|
+
const manifest2 = manifest;
|
|
97
|
+
for (const row of manifest2) {
|
|
98
|
+
if (!row.text.includes("return a + b;")) continue;
|
|
99
|
+
const delta = newBody.length - row.text.length;
|
|
100
|
+
store.db.prepare(`UPDATE file_entries SET text = ?, end_byte = ? WHERE file_name = 'math.js' AND ordinal = ?`).run(newBody, row.end_byte + delta, row.ordinal);
|
|
101
|
+
}
|
|
102
|
+
store.db.prepare(`UPDATE files SET source_text = ?, source_hash = ?, pending_kind = 'update' WHERE file_name = 'math.js'`).run(newBody, hashText(newBody));
|
|
103
|
+
store.db.prepare(`INSERT INTO change_log (kind, detail, recorded_at) VALUES ('update-function', './math.js:add', ?)`).run(new Date().toISOString());
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// 3. drift: pending reported, ok-with-note semantics → recommendation = apply.
|
|
107
|
+
let drift = computeDrift(store, root);
|
|
108
|
+
assert.ok(drift.pending.some((item) => item.path === "./math.js" && item.kind === "update"));
|
|
109
|
+
|
|
110
|
+
// 4. apply: writes the file, clears pending.
|
|
111
|
+
const applied = materializeFile(store, root, ".", "math.js");
|
|
112
|
+
assert.equal(applied.status, "ok");
|
|
113
|
+
assert.ok(fs.readFileSync(path.join(root, "math.js"), "utf8").includes("+ 100"));
|
|
114
|
+
drift = computeDrift(store, root);
|
|
115
|
+
assert.equal(drift.pending.length, 0, "pending cleared after apply");
|
|
116
|
+
|
|
117
|
+
// 5. final-commit: drift clean → commits; second call is a no-op.
|
|
118
|
+
const commit1 = await finalCommit(root, "feat(loop): add +100");
|
|
119
|
+
assert.equal(commit1.ok, true);
|
|
120
|
+
assert.equal(commit1.noop, false);
|
|
121
|
+
assert.ok(commit1.committed);
|
|
122
|
+
const commit2 = await finalCommit(root, "noop attempt");
|
|
123
|
+
assert.equal(commit2.ok, true);
|
|
124
|
+
assert.equal(commit2.noop, true, "clean tree is a safe no-op");
|
|
125
|
+
|
|
126
|
+
// 6. Human-edit branch: change source directly, update-graph reindexes.
|
|
127
|
+
fs.writeFileSync(path.join(root, "helper.ts"), "function twice(n: number): number { return n * 3; }\n");
|
|
128
|
+
drift = computeDrift(store, root);
|
|
129
|
+
assert.ok(drift.hashDrift.some((item) => item.path === "./helper.ts" && item.kind === "hash-mismatch"));
|
|
130
|
+
const upd = await runIndex({ store, worktreeRoot: root, parsers, paths: ["./helper.ts"] });
|
|
131
|
+
assert.deepEqual(upd.reindexedPaths, ["./helper.ts"]);
|
|
132
|
+
drift = computeDrift(store, root);
|
|
133
|
+
assert.ok(!drift.hashDrift.some((item) => item.path === "./helper.ts"));
|
|
134
|
+
|
|
135
|
+
// 7. Deletion branch: remove file, update-graph purges rows; apply does not resurrect.
|
|
136
|
+
fs.rmSync(path.join(root, "helper.ts"));
|
|
137
|
+
drift = computeDrift(store, root);
|
|
138
|
+
assert.ok(drift.hashDrift.some((item) => item.path === "./helper.ts"));
|
|
139
|
+
const upd2 = await runIndex({ store, worktreeRoot: root, parsers, paths: ["./helper.ts"] });
|
|
140
|
+
assert.deepEqual(upd2.purgedPaths, ["./helper.ts"]);
|
|
141
|
+
const applyDeleted = materializeFile(store, root, ".", "helper.ts");
|
|
142
|
+
assert.equal(applyDeleted.status, "error", "row gone; not resurrected");
|
|
143
|
+
assert.equal(fs.existsSync(path.join(root, "helper.ts")), false);
|
|
144
|
+
|
|
145
|
+
// 8. final-commit with pending set must refuse.
|
|
146
|
+
store.tx(() => {
|
|
147
|
+
store.db.prepare(`UPDATE files SET pending_kind = 'update' WHERE file_name = 'math.js'`).run();
|
|
148
|
+
});
|
|
149
|
+
const refused = await finalCommit(root, "should refuse");
|
|
150
|
+
assert.equal(refused.ok, false);
|
|
151
|
+
assert.match(refused.reason ?? "", /apply-graph/);
|
|
152
|
+
store.tx(() => {
|
|
153
|
+
store.db.prepare(`UPDATE files SET pending_kind = NULL WHERE file_name = 'math.js'`).run();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const { lines, notify } = makeNotify();
|
|
157
|
+
void notify;
|
|
158
|
+
assert.ok(lines !== undefined);
|
|
159
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-path mutation tests against src/code-graph/mutations.ts: real indexer
|
|
3
|
+
* output (multi-function file with inter-function gaps), exact post-apply
|
|
4
|
+
* bytes, zero-match refusal, and the full mutation→apply convergence.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import * as assert from "node:assert/strict";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { Store } from "../src/code-graph/store.ts";
|
|
14
|
+
import { runIndex } from "../src/code-graph/indexer.ts";
|
|
15
|
+
import { materializeFile } from "../src/code-graph/materialize.ts";
|
|
16
|
+
import { makeBackend } from "../src/code-graph/parsers/javascript.ts";
|
|
17
|
+
import { PythonBackend } from "../src/code-graph/parsers/python.ts";
|
|
18
|
+
import { loadGraphRuntime } from "../src/code-graph/runtime.ts";
|
|
19
|
+
import { updateFunction, updateFile, deleteFile, listPending } from "../src/code-graph/mutations.ts";
|
|
20
|
+
import type { ParserBackend } from "../src/code-graph/parser.ts";
|
|
21
|
+
import type { Language } from "../src/code-graph/types.ts";
|
|
22
|
+
|
|
23
|
+
function git(cwd: string, args: string[]): void {
|
|
24
|
+
const env: Record<string, string | undefined> = { ...process.env };
|
|
25
|
+
for (const key of ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE"]) delete env[key];
|
|
26
|
+
spawnSync("git", args, { cwd, env, encoding: "utf8" });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Two functions with a comment gap between them — the offset-propagation case.
|
|
30
|
+
const SOURCE = "function first(a) { return a + 1; }\n// inter-function gap\nfunction second(b) { return b * 2; }\n";
|
|
31
|
+
const NEW_FIRST = "function first(a) { return a + 42; }";
|
|
32
|
+
|
|
33
|
+
function initRepo(): { root: string; cleanup: () => void } {
|
|
34
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-mut-"));
|
|
35
|
+
git(dir, ["init", "--initial-branch=main"]);
|
|
36
|
+
git(dir, ["config", "user.email", "test@example.com"]);
|
|
37
|
+
git(dir, ["config", "user.name", "test"]);
|
|
38
|
+
fs.writeFileSync(path.join(dir, "two.js"), SOURCE);
|
|
39
|
+
git(dir, ["add", "-A"]);
|
|
40
|
+
git(dir, ["commit", "-m", "init"]);
|
|
41
|
+
return {
|
|
42
|
+
root: dir,
|
|
43
|
+
cleanup: () => {
|
|
44
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("updateFunction propagates offsets, rebuilds source exactly, and applies byte-for-byte", async (t) => {
|
|
50
|
+
const runtime = await loadGraphRuntime();
|
|
51
|
+
if (!runtime.status.parserAvailable || !runtime.status.sqliteAvailable) return;
|
|
52
|
+
const { root, cleanup } = initRepo();
|
|
53
|
+
t.after(cleanup);
|
|
54
|
+
const stateRoot = path.join(root, ".git", "pi_plans");
|
|
55
|
+
fs.mkdirSync(stateRoot, { recursive: true });
|
|
56
|
+
const store = new Store({ dbPath: path.join(stateRoot, "g.db"), worktreeRoot: root, gitCommonDir: path.join(root, ".git") }, runtime.runtime.sqlite);
|
|
57
|
+
t.after(() => store.close());
|
|
58
|
+
const ParserCtor = runtime.runtime.parser.Parser as unknown as new () => {
|
|
59
|
+
parse(input: string | Buffer): unknown;
|
|
60
|
+
setLanguage(language: unknown): void;
|
|
61
|
+
};
|
|
62
|
+
const parsers: Record<Language, ParserBackend> = {
|
|
63
|
+
javascript: makeBackend("javascript", ParserCtor, runtime.runtime.parser.javascript),
|
|
64
|
+
typescript: makeBackend("typescript", ParserCtor, runtime.runtime.parser.typescript),
|
|
65
|
+
tsx: makeBackend("tsx", ParserCtor, runtime.runtime.parser.tsx),
|
|
66
|
+
python: new PythonBackend(ParserCtor, runtime.runtime.parser.python),
|
|
67
|
+
};
|
|
68
|
+
await runIndex({ store, worktreeRoot: root, parsers });
|
|
69
|
+
|
|
70
|
+
const result = updateFunction(store, { fileDir: ".", fileName: "two.js", functionName: "first", fullCode: NEW_FIRST });
|
|
71
|
+
assert.equal(result.ok, true, result.reason ?? "");
|
|
72
|
+
|
|
73
|
+
// Rebuilt source keeps the gap and the untouched second function intact.
|
|
74
|
+
const rebuilt = store.read(() =>
|
|
75
|
+
store.db.prepare(`SELECT source_text, pending_kind FROM files WHERE file_name = 'two.js'`).get(),
|
|
76
|
+
) as { source_text: string; pending_kind: string };
|
|
77
|
+
assert.equal(rebuilt.pending_kind, "update");
|
|
78
|
+
const expected = NEW_FIRST + "\n// inter-function gap\nfunction second(b) { return b * 2; }\n";
|
|
79
|
+
assert.equal(rebuilt.source_text, expected, "gap + later function preserved with shifted offsets");
|
|
80
|
+
|
|
81
|
+
// Apply writes the exact bytes; pending cleared; second fn intact on disk.
|
|
82
|
+
const applied = materializeFile(store, root, ".", "two.js");
|
|
83
|
+
assert.equal(applied.status, "ok");
|
|
84
|
+
assert.equal(fs.readFileSync(path.join(root, "two.js"), "utf8"), expected);
|
|
85
|
+
|
|
86
|
+
// Zero-match refusal carries a repair path.
|
|
87
|
+
const bad = updateFunction(store, { fileDir: ".", fileName: "two.js", functionName: "nonexistent", fullCode: "x" });
|
|
88
|
+
assert.equal(bad.ok, false);
|
|
89
|
+
assert.match(bad.reason ?? "", /not found|manifest entry|get-function/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("updateFile / deleteFile / listPending round-trip", async (t) => {
|
|
93
|
+
const runtime = await loadGraphRuntime();
|
|
94
|
+
if (!runtime.status.sqliteAvailable) return;
|
|
95
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-mut2-"));
|
|
96
|
+
t.after(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } });
|
|
97
|
+
const store = new Store({ dbPath: path.join(dir, "g.db"), worktreeRoot: dir, gitCommonDir: path.join(dir, ".git") }, runtime.runtime.sqlite);
|
|
98
|
+
t.after(() => store.close());
|
|
99
|
+
|
|
100
|
+
const created = updateFile(store, { fileDir: ".", fileName: "brand-new.ts", text: "export const x = 1;\n", language: "typescript" });
|
|
101
|
+
assert.equal(created.ok, true);
|
|
102
|
+
assert.equal(created.created, true);
|
|
103
|
+
const applied = materializeFile(store, dir, ".", "brand-new.ts");
|
|
104
|
+
assert.equal(applied.status, "ok", "new file created from DB");
|
|
105
|
+
assert.equal(fs.readFileSync(path.join(dir, "brand-new.ts"), "utf8"), "export const x = 1;\n");
|
|
106
|
+
|
|
107
|
+
const replaced = updateFile(store, { fileDir: ".", fileName: "brand-new.ts", text: "export const x = 2;\n" });
|
|
108
|
+
assert.equal(replaced.ok, true);
|
|
109
|
+
assert.equal(replaced.created, false);
|
|
110
|
+
|
|
111
|
+
const marked = deleteFile(store, { fileDir: ".", fileName: "brand-new.ts" });
|
|
112
|
+
assert.equal(marked.ok, true);
|
|
113
|
+
assert.deepEqual(listPending(store), [{ path: "./brand-new.ts", kind: "delete" }]);
|
|
114
|
+
const purged = materializeFile(store, dir, ".", "brand-new.ts");
|
|
115
|
+
assert.equal(purged.status, "deleted");
|
|
116
|
+
assert.equal(fs.existsSync(path.join(dir, "brand-new.ts")), false);
|
|
117
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies the parser backends produce stable identities for the JS/TS/Python
|
|
3
|
+
* fixtures. Skipped when the runtime cannot provide node:sqlite or
|
|
4
|
+
* tree-sitter.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import * as assert from "node:assert/strict";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
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 { normalizeFunctionIdentities } from "../src/code-graph/identity.ts";
|
|
16
|
+
import type { ParserBackend } from "../../src/code-graph/parser.ts";
|
|
17
|
+
import type { Language } from "../../src/code-graph/types.ts";
|
|
18
|
+
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
20
|
+
const __dirname = path.dirname(__filename);
|
|
21
|
+
const fixturesDir = path.join(__dirname, "fixtures", "code-graph");
|
|
22
|
+
|
|
23
|
+
async function backends(): Promise<Record<Language, ParserBackend> | null> {
|
|
24
|
+
try {
|
|
25
|
+
const { runtime, status } = await loadGraphRuntime();
|
|
26
|
+
if (!status.parserAvailable) return null;
|
|
27
|
+
const ParserCtor = runtime.parser.Parser as unknown as new () => {
|
|
28
|
+
parse(input: string | Buffer): unknown;
|
|
29
|
+
setLanguage(language: unknown): void;
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
javascript: makeBackend("javascript", ParserCtor, runtime.parser.javascript),
|
|
33
|
+
typescript: makeBackend("typescript", ParserCtor, runtime.parser.typescript),
|
|
34
|
+
tsx: makeBackend("tsx", ParserCtor, runtime.parser.tsx),
|
|
35
|
+
python: new PythonBackend(ParserCtor, runtime.parser.python),
|
|
36
|
+
};
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("parsers yield function records without overlapping provenance", async () => {
|
|
43
|
+
const bs = await backends();
|
|
44
|
+
if (!bs) return; // skip when tree-sitter unavailable
|
|
45
|
+
const jsSource = fs.readFileSync(path.join(fixturesDir, "sample.js"), "utf8");
|
|
46
|
+
const pySource = fs.readFileSync(path.join(fixturesDir, "sample.py"), "utf8");
|
|
47
|
+
const js = bs.javascript.parse(jsSource);
|
|
48
|
+
const py = bs.python.parse(pySource);
|
|
49
|
+
assert.ok(js.functions.length > 0);
|
|
50
|
+
assert.ok(py.functions.length > 0);
|
|
51
|
+
const jsNames = js.functions.map((f) => f.functionName);
|
|
52
|
+
const pyNames = py.functions.map((f) => f.functionName);
|
|
53
|
+
assert.ok(jsNames.includes("alpha"));
|
|
54
|
+
assert.ok(pyNames.includes("alpha"));
|
|
55
|
+
const lambda = py.functions.find((f) => f.kind === "lambda");
|
|
56
|
+
assert.ok(lambda, "expected a lambda record in python fixture");
|
|
57
|
+
// Every function has a non-empty hash and provenance.
|
|
58
|
+
for (const fn of [...js.functions, ...py.functions]) {
|
|
59
|
+
assert.equal(typeof fn.fullCodeHash, "string");
|
|
60
|
+
assert.ok(fn.fullCodeHash.length > 0);
|
|
61
|
+
assert.ok(fn.provenance.startByte < fn.provenance.endByte);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("parser metadata and identity normalization handle overloads and accessors", async () => {
|
|
66
|
+
const bs = await backends();
|
|
67
|
+
if (!bs) return;
|
|
68
|
+
const js = bs.javascript.parse(fs.readFileSync(path.join(fixturesDir, "sample.js"), "utf8"));
|
|
69
|
+
const normalized = normalizeFunctionIdentities(js);
|
|
70
|
+
const accessorNames = normalized.functions
|
|
71
|
+
.filter((fn) => fn.kind === "accessor")
|
|
72
|
+
.map((fn) => fn.functionName);
|
|
73
|
+
assert.deepEqual(accessorNames, ["Container.foo", "Container.foo#2"]);
|
|
74
|
+
assert.ok(normalized.functions.some((fn) => fn.functionName === "Container.delta"));
|
|
75
|
+
|
|
76
|
+
const ts = bs.typescript.parse(fs.readFileSync(path.join(fixturesDir, "sample.ts"), "utf8"));
|
|
77
|
+
const overloaded = ts.functions.filter((fn) => fn.functionName === "overloaded");
|
|
78
|
+
assert.equal(overloaded.length, 1, "overload signatures must not create separate function rows");
|
|
79
|
+
assert.deepEqual(overloaded[0].overloadSignatures, [
|
|
80
|
+
"function overloaded(value: string): string;",
|
|
81
|
+
"function overloaded(value: number): number;",
|
|
82
|
+
]);
|
|
83
|
+
const tsAccessors = ts.functions.filter((fn) => fn.kind === "accessor");
|
|
84
|
+
assert.deepEqual(tsAccessors.map((fn) => fn.functionName), ["Accessors.value", "Accessors.value"]);
|
|
85
|
+
});
|
|
@@ -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
|
+
});
|