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,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies the DB-to-source materializer refuses stale source unless force,
|
|
3
|
+
* and roundtrips bytes for unchanged content.
|
|
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 { Store } from "../src/code-graph/store.ts";
|
|
12
|
+
import { materialize, materializeFile } from "../src/code-graph/materialize.ts";
|
|
13
|
+
import { runIndex } from "../src/code-graph/indexer.ts";
|
|
14
|
+
import { makeBackend } from "../src/code-graph/parsers/javascript.ts";
|
|
15
|
+
import { PythonBackend } from "../src/code-graph/parsers/python.ts";
|
|
16
|
+
import { loadGraphRuntime } from "../src/code-graph/runtime.ts";
|
|
17
|
+
import type { ParserBackend } from "../src/code-graph/parser.ts";
|
|
18
|
+
import type { Language } from "../src/code-graph/types.ts";
|
|
19
|
+
|
|
20
|
+
async function setup(): Promise<{ store: Store; cleanup: () => void } | null> {
|
|
21
|
+
try {
|
|
22
|
+
const sqlite = await import("node:sqlite");
|
|
23
|
+
const worktreeRoot = fs.realpathSync(os.tmpdir());
|
|
24
|
+
const dbPath = path.join(os.tmpdir(), `code-graph-apply-${process.pid}-${Date.now()}.db`);
|
|
25
|
+
const store = new Store({ dbPath, worktreeRoot, gitCommonDir: worktreeRoot }, sqlite);
|
|
26
|
+
store.close();
|
|
27
|
+
const reopened = new Store({ dbPath, worktreeRoot, gitCommonDir: worktreeRoot }, sqlite);
|
|
28
|
+
return { store: reopened, cleanup: () => {
|
|
29
|
+
reopened.close();
|
|
30
|
+
try { fs.unlinkSync(dbPath); } catch { /* ignore */ }
|
|
31
|
+
} };
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hashText(text: string): string {
|
|
38
|
+
let h = 0x811c9dc5;
|
|
39
|
+
for (let i = 0; i < text.length; i++) {
|
|
40
|
+
h ^= text.charCodeAt(i);
|
|
41
|
+
h = Math.imul(h, 0x01000193);
|
|
42
|
+
}
|
|
43
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("materialize refuses stale source unless force is set", async (t) => {
|
|
47
|
+
const opened = await setup();
|
|
48
|
+
if (!opened) return;
|
|
49
|
+
t.after(() => opened.cleanup());
|
|
50
|
+
const { store } = opened;
|
|
51
|
+
const worktreeRoot = fs.realpathSync(os.tmpdir());
|
|
52
|
+
const fileDir = "graph-apply-fixture";
|
|
53
|
+
const fileName = "demo.txt";
|
|
54
|
+
const absoluteDir = path.join(worktreeRoot, fileDir);
|
|
55
|
+
fs.mkdirSync(absoluteDir, { recursive: true });
|
|
56
|
+
const original = "hello graph";
|
|
57
|
+
fs.writeFileSync(path.join(absoluteDir, fileName), original);
|
|
58
|
+
const sourceHash = hashText(original);
|
|
59
|
+
store.tx(() => {
|
|
60
|
+
store.db
|
|
61
|
+
.prepare(
|
|
62
|
+
`INSERT INTO files (file_dir, file_name, language, source_hash, source_text, updated_at)
|
|
63
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
64
|
+
)
|
|
65
|
+
.run(fileDir, fileName, "javascript", sourceHash, original, new Date().toISOString());
|
|
66
|
+
store.db
|
|
67
|
+
.prepare(
|
|
68
|
+
`INSERT INTO file_entries (file_dir, file_name, ordinal, kind, function_name, start_byte, end_byte, text)
|
|
69
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
70
|
+
)
|
|
71
|
+
.run(fileDir, fileName, 0, "raw", null, 0, original.length, original);
|
|
72
|
+
});
|
|
73
|
+
// File matches DB hash: write succeeds.
|
|
74
|
+
const okResult = materializeFile(store, worktreeRoot, fileDir, fileName);
|
|
75
|
+
assert.equal(okResult.status, "ok");
|
|
76
|
+
// Now mutate the source on disk to be stale.
|
|
77
|
+
fs.writeFileSync(path.join(absoluteDir, fileName), "external change");
|
|
78
|
+
const staleResult = materializeFile(store, worktreeRoot, fileDir, fileName);
|
|
79
|
+
assert.equal(staleResult.status, "stale");
|
|
80
|
+
const forceResult = materializeFile(store, worktreeRoot, fileDir, fileName, { force: true });
|
|
81
|
+
assert.equal(forceResult.status, "ok");
|
|
82
|
+
assert.equal(fs.readFileSync(path.join(absoluteDir, fileName), "utf8"), original);
|
|
83
|
+
fs.rmSync(absoluteDir, { recursive: true, force: true });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("pending_kind drives convergence: update writes/creates, delete purges, missing skips", async (t) => {
|
|
87
|
+
const sqliteAvailable = (async () => { try { await import("node:sqlite"); return true; } catch { return false; } })();
|
|
88
|
+
if (!(await sqliteAvailable)) return;
|
|
89
|
+
const sqlite = await import("node:sqlite");
|
|
90
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-pending-"));
|
|
91
|
+
t.after(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } });
|
|
92
|
+
const store = new Store({ dbPath: path.join(dir, "g.db"), worktreeRoot: dir, gitCommonDir: path.join(dir, ".git") }, sqlite);
|
|
93
|
+
t.after(() => store.close());
|
|
94
|
+
const now = new Date().toISOString();
|
|
95
|
+
|
|
96
|
+
// 1. pending update creates a missing file (new-file path).
|
|
97
|
+
store.tx(() => {
|
|
98
|
+
store.db.prepare(
|
|
99
|
+
`INSERT INTO files (file_dir, file_name, language, source_hash, source_text, pending_kind, updated_at)
|
|
100
|
+
VALUES ('.', 'new.js', 'javascript', 'x', 'function n() { return 1; }\n', 'update', ?)`,
|
|
101
|
+
).run(now);
|
|
102
|
+
});
|
|
103
|
+
const created = materializeFile(store, dir, ".", "new.js");
|
|
104
|
+
assert.equal(created.status, "ok");
|
|
105
|
+
assert.equal(fs.readFileSync(path.join(dir, "new.js"), "utf8"), "function n() { return 1; }\n");
|
|
106
|
+
const cleared = store.read(() => store.db.prepare(`SELECT pending_kind FROM files WHERE file_name = 'new.js'`).get()) as { pending_kind: string | null };
|
|
107
|
+
assert.equal(cleared.pending_kind, null, "pending cleared after apply");
|
|
108
|
+
|
|
109
|
+
// 2. pending delete removes disk file and DB rows.
|
|
110
|
+
fs.writeFileSync(path.join(dir, "doomed.js"), "function d() {}\n");
|
|
111
|
+
store.tx(() => {
|
|
112
|
+
store.db.prepare(
|
|
113
|
+
`INSERT INTO files (file_dir, file_name, language, source_hash, source_text, pending_kind, updated_at)
|
|
114
|
+
VALUES ('.', 'doomed.js', 'javascript', 'x', 'function d() {}\n', 'delete', ?)`,
|
|
115
|
+
).run(now);
|
|
116
|
+
store.db.prepare(
|
|
117
|
+
`INSERT INTO functions (file_dir, file_name, function_name, language, kind, full_code, full_code_hash, render_code, render_code_hash,
|
|
118
|
+
move_supported, is_primary, provenance_start_byte, provenance_end_byte, provenance_start_line, provenance_start_col,
|
|
119
|
+
provenance_end_line, provenance_end_col, version)
|
|
120
|
+
VALUES ('.', 'doomed.js', 'd', 'javascript', 'declaration', 'function d() {}', 'h', 'function d() {}', 'h', 1, 1, 0, 18, 1, 1, 1, 19, 1)`,
|
|
121
|
+
).run();
|
|
122
|
+
});
|
|
123
|
+
const deleted = materializeFile(store, dir, ".", "doomed.js");
|
|
124
|
+
assert.equal(deleted.status, "deleted");
|
|
125
|
+
assert.equal(fs.existsSync(path.join(dir, "doomed.js")), false, "disk file removed");
|
|
126
|
+
const rows = store.read(() => store.db.prepare(`SELECT COUNT(*) AS c FROM files WHERE file_name = 'doomed.js'`).get()) as { c: number };
|
|
127
|
+
assert.equal(rows.c, 0, "DB rows purged");
|
|
128
|
+
|
|
129
|
+
// 3. pending-NULL + missing on disk: skipped, not resurrected.
|
|
130
|
+
store.tx(() => {
|
|
131
|
+
store.db.prepare(
|
|
132
|
+
`INSERT INTO files (file_dir, file_name, language, source_hash, source_text, pending_kind, updated_at)
|
|
133
|
+
VALUES ('.', 'ghost.js', 'javascript', 'x', 'function g() {}\n', NULL, ?)`,
|
|
134
|
+
).run(now);
|
|
135
|
+
});
|
|
136
|
+
const skipped = materializeFile(store, dir, ".", "ghost.js");
|
|
137
|
+
assert.equal(skipped.status, "skipped-missing");
|
|
138
|
+
assert.equal(fs.existsSync(path.join(dir, "ghost.js")), false, "ghost not resurrected");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("materialize never rewrites pending-NULL files from real-index manifests", async (t) => {
|
|
142
|
+
let sqlite: typeof import("node:sqlite");
|
|
143
|
+
try {
|
|
144
|
+
sqlite = await import("node:sqlite");
|
|
145
|
+
} catch {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const runtime = await loadGraphRuntime();
|
|
149
|
+
if (!runtime.status.parserAvailable || !runtime.status.sqliteAvailable) return;
|
|
150
|
+
const ParserCtor = runtime.runtime.parser.Parser as unknown as new () => {
|
|
151
|
+
parse(input: string | Buffer): unknown;
|
|
152
|
+
setLanguage(language: unknown): void;
|
|
153
|
+
};
|
|
154
|
+
const parsers: Record<Language, ParserBackend> = {
|
|
155
|
+
javascript: makeBackend("javascript", ParserCtor, runtime.runtime.parser.javascript),
|
|
156
|
+
typescript: makeBackend("typescript", ParserCtor, runtime.runtime.parser.typescript),
|
|
157
|
+
tsx: makeBackend("tsx", ParserCtor, runtime.runtime.parser.tsx),
|
|
158
|
+
python: new PythonBackend(ParserCtor, runtime.runtime.parser.python),
|
|
159
|
+
};
|
|
160
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-apply-real-"));
|
|
161
|
+
t.after(() => {
|
|
162
|
+
try {
|
|
163
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
164
|
+
} catch {
|
|
165
|
+
/* ignore */
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
const source = "export function add(a: number, b: number): number { return a + b; }\n";
|
|
169
|
+
fs.writeFileSync(path.join(dir, "math.ts"), source);
|
|
170
|
+
const store = new Store({ dbPath: path.join(dir, "g.db"), worktreeRoot: dir, gitCommonDir: path.join(dir, ".git") }, sqlite);
|
|
171
|
+
t.after(() => store.close());
|
|
172
|
+
await runIndex({ store, worktreeRoot: dir, parsers });
|
|
173
|
+
|
|
174
|
+
// Real indexes store empty/overlapping render-unit texts; a pending-NULL
|
|
175
|
+
// apply must treat source_text as canonical and not rewrite the file.
|
|
176
|
+
const report = materialize({ store, worktreeRoot: dir });
|
|
177
|
+
assert.ok(report.files.every((file) => file.status === "ok"), JSON.stringify(report.files));
|
|
178
|
+
assert.equal(fs.readFileSync(path.join(dir, "math.ts"), "utf8"), source);
|
|
179
|
+
|
|
180
|
+
// force converges from source_text, never from manifest reassembly.
|
|
181
|
+
fs.writeFileSync(path.join(dir, "math.ts"), "tampered\n");
|
|
182
|
+
const forced = materialize({ store, worktreeRoot: dir, force: true });
|
|
183
|
+
assert.ok(forced.files.every((file) => file.status === "ok"), JSON.stringify(forced.files));
|
|
184
|
+
assert.equal(fs.readFileSync(path.join(dir, "math.ts"), "utf8"), source);
|
|
185
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
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 { spawnSync } from "node:child_process";
|
|
6
|
+
import { after, test } from "node:test";
|
|
7
|
+
import { Store } from "../src/code-graph/store.ts";
|
|
8
|
+
import { runIndex } from "../src/code-graph/indexer.ts";
|
|
9
|
+
import { makeBackend } from "../src/code-graph/parsers/javascript.ts";
|
|
10
|
+
import { PythonBackend } from "../src/code-graph/parsers/python.ts";
|
|
11
|
+
import { loadGraphRuntime } from "../src/code-graph/runtime.ts";
|
|
12
|
+
import { gitHead } from "../src/code-graph/git.ts";
|
|
13
|
+
import { initGraphCommand } from "../src/code-graph/commands.ts";
|
|
14
|
+
import { hashText } from "../src/code-graph/parser.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[]): void {
|
|
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
|
+
assert.equal(result.status, 0, `${args.join(" ")} failed: ${result.stderr}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function initRepo(): { root: string; cleanup: () => void } {
|
|
26
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-init-graph-"));
|
|
27
|
+
git(root, ["init", "--initial-branch=main"]);
|
|
28
|
+
git(root, ["config", "user.email", "test@example.com"]);
|
|
29
|
+
git(root, ["config", "user.name", "test"]);
|
|
30
|
+
fs.writeFileSync(path.join(root, "math.js"), "function add(a, b) { return a + b; }\n");
|
|
31
|
+
fs.writeFileSync(path.join(root, "helper.ts"), "export function twice(n: number): number { return n * 2; }\n");
|
|
32
|
+
git(root, ["add", "-A"]);
|
|
33
|
+
git(root, ["commit", "-m", "init"]);
|
|
34
|
+
fs.mkdirSync(path.join(root, ".git", "pi_plans"), { recursive: true });
|
|
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(root: string, confirmResult: boolean, hasUI = true) {
|
|
72
|
+
const confirmations: Array<{ title: string; body: string }> = [];
|
|
73
|
+
const notifications: Array<{ message: string; kind?: string }> = [];
|
|
74
|
+
return {
|
|
75
|
+
cwd: root,
|
|
76
|
+
hasUI,
|
|
77
|
+
ui: {
|
|
78
|
+
notify: (message: string, kind?: "info" | "warning" | "error") => {
|
|
79
|
+
notifications.push({ message, kind });
|
|
80
|
+
},
|
|
81
|
+
confirm: async (title: string, body: string) => {
|
|
82
|
+
confirmations.push({ title, body });
|
|
83
|
+
return confirmResult;
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
confirmations,
|
|
87
|
+
notifications,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
test("initGraphCommand keeps the fresh-init path prompt-free", async (t) => {
|
|
92
|
+
const loaded = await loadParsers();
|
|
93
|
+
if (!loaded) return;
|
|
94
|
+
const { runtime, parsers } = loaded;
|
|
95
|
+
const { root, cleanup } = initRepo();
|
|
96
|
+
t.after(cleanup);
|
|
97
|
+
const ctx = makeCtx(root, false);
|
|
98
|
+
await initGraphCommand("", ctx as never);
|
|
99
|
+
assert.equal(ctx.confirmations.length, 0, "fresh init must not prompt");
|
|
100
|
+
|
|
101
|
+
const store = openStore(root, runtime.runtime.sqlite);
|
|
102
|
+
t.after(() => store.close());
|
|
103
|
+
const fileCount = store.read(() => store.db.prepare("SELECT COUNT(*) AS c FROM files").get()) as { c: number };
|
|
104
|
+
assert.ok(fileCount.c > 0, "fresh init should populate files");
|
|
105
|
+
const functionsCount = store.read(() => store.db.prepare("SELECT COUNT(*) AS c FROM functions").get()) as { c: number };
|
|
106
|
+
assert.ok(functionsCount.c > 0, "fresh init should populate functions");
|
|
107
|
+
void parsers;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("initGraphCommand routes an existing DB to changed-path sync and ignores rebuild-only flags", async (t) => {
|
|
111
|
+
const loaded = await loadParsers();
|
|
112
|
+
if (!loaded) return;
|
|
113
|
+
const { runtime, parsers } = loaded;
|
|
114
|
+
const { root, cleanup } = initRepo();
|
|
115
|
+
t.after(cleanup);
|
|
116
|
+
const sqlite = runtime.runtime.sqlite;
|
|
117
|
+
const seedStore = openStore(root, sqlite);
|
|
118
|
+
t.after(() => seedStore.close());
|
|
119
|
+
await runIndex({ store: seedStore, worktreeRoot: root, parsers });
|
|
120
|
+
seedStore.upsertSnapshot(gitHead(root), []);
|
|
121
|
+
seedStore.close();
|
|
122
|
+
|
|
123
|
+
const updatedSource = "function add(a, b) { return a + b + 100; }\n";
|
|
124
|
+
fs.writeFileSync(path.join(root, "math.js"), updatedSource);
|
|
125
|
+
const headBefore = gitHead(root);
|
|
126
|
+
const ctx = makeCtx(root, false);
|
|
127
|
+
await initGraphCommand("--no-summary --no-commit", ctx as never);
|
|
128
|
+
|
|
129
|
+
assert.equal(ctx.confirmations.length, 1, "existing DB should prompt once");
|
|
130
|
+
assert.match(ctx.confirmations[0]!.body, /\/update-graph/);
|
|
131
|
+
assert.equal(gitHead(root), headBefore, "incremental branch must not create the pre-init commit");
|
|
132
|
+
|
|
133
|
+
const checkStore = openStore(root, sqlite);
|
|
134
|
+
t.after(() => checkStore.close());
|
|
135
|
+
const row = checkStore.read(() =>
|
|
136
|
+
checkStore.db.prepare("SELECT source_hash FROM files WHERE file_dir = '.' AND file_name = 'math.js'").get(),
|
|
137
|
+
) as { source_hash: string } | undefined;
|
|
138
|
+
assert.ok(row, "math.js should remain indexed");
|
|
139
|
+
assert.equal(row!.source_hash, hashText(updatedSource));
|
|
140
|
+
const conflicts = checkStore.read(() => checkStore.db.prepare("SELECT COUNT(*) AS c FROM reindex_conflicts").get()) as { c: number };
|
|
141
|
+
assert.equal(conflicts.c, 0, "incremental sync should ignore rebuild-only reindex conflicts");
|
|
142
|
+
void runtime;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("initGraphCommand rebuild branch skips prompt when --reindex is explicit", async (t) => {
|
|
146
|
+
const loaded = await loadParsers();
|
|
147
|
+
if (!loaded) return;
|
|
148
|
+
const { runtime, parsers } = loaded;
|
|
149
|
+
const { root, cleanup } = initRepo();
|
|
150
|
+
t.after(cleanup);
|
|
151
|
+
const sqlite = runtime.runtime.sqlite;
|
|
152
|
+
const seedStore = openStore(root, sqlite);
|
|
153
|
+
t.after(() => seedStore.close());
|
|
154
|
+
await runIndex({ store: seedStore, worktreeRoot: root, parsers });
|
|
155
|
+
seedStore.upsertSnapshot(gitHead(root), []);
|
|
156
|
+
seedStore.close();
|
|
157
|
+
|
|
158
|
+
const changedSource = "function add(a, b) { return a + b + 200; }\n";
|
|
159
|
+
fs.writeFileSync(path.join(root, "math.js"), changedSource);
|
|
160
|
+
const headBefore = gitHead(root);
|
|
161
|
+
const ctx = makeCtx(root, true);
|
|
162
|
+
await initGraphCommand("--reindex --no-commit", ctx as never);
|
|
163
|
+
|
|
164
|
+
assert.equal(ctx.confirmations.length, 0, "existing DB with --reindex should skip the prompt");
|
|
165
|
+
assert.equal(gitHead(root), headBefore, "--no-commit must still suppress the pre-init commit on rebuild");
|
|
166
|
+
|
|
167
|
+
const checkStore = openStore(root, sqlite);
|
|
168
|
+
t.after(() => checkStore.close());
|
|
169
|
+
const row = checkStore.read(() =>
|
|
170
|
+
checkStore.db.prepare("SELECT source_hash FROM files WHERE file_dir = '.' AND file_name = 'math.js'").get(),
|
|
171
|
+
) as { source_hash: string } | undefined;
|
|
172
|
+
assert.ok(row, "math.js should remain indexed");
|
|
173
|
+
assert.equal(row!.source_hash, hashText(changedSource));
|
|
174
|
+
const conflicts = checkStore.read(() => checkStore.db.prepare("SELECT COUNT(*) AS c FROM reindex_conflicts").get()) as { c: number };
|
|
175
|
+
assert.ok(conflicts.c > 0, "--reindex must still record conflicts on the rebuild path");
|
|
176
|
+
void runtime;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("initGraphCommand rebuilds without prompting in headless mode", async (t) => {
|
|
180
|
+
const loaded = await loadParsers();
|
|
181
|
+
if (!loaded) return;
|
|
182
|
+
const { runtime, parsers } = loaded;
|
|
183
|
+
const { root, cleanup } = initRepo();
|
|
184
|
+
t.after(cleanup);
|
|
185
|
+
const sqlite = runtime.runtime.sqlite;
|
|
186
|
+
const seedStore = openStore(root, sqlite);
|
|
187
|
+
t.after(() => seedStore.close());
|
|
188
|
+
await runIndex({ store: seedStore, worktreeRoot: root, parsers });
|
|
189
|
+
seedStore.upsertSnapshot(gitHead(root), []);
|
|
190
|
+
seedStore.close();
|
|
191
|
+
|
|
192
|
+
const updatedSource = "function add(a, b) { return a + b + 300; }\n";
|
|
193
|
+
fs.writeFileSync(path.join(root, "math.js"), updatedSource);
|
|
194
|
+
const headBefore = gitHead(root);
|
|
195
|
+
const ctx = makeCtx(root, false, false);
|
|
196
|
+
await initGraphCommand("", ctx as never);
|
|
197
|
+
|
|
198
|
+
assert.equal(ctx.confirmations.length, 0, "headless runs must not prompt");
|
|
199
|
+
assert.notEqual(gitHead(root), headBefore, "headless existing DB should rebuild and create the pre-init commit");
|
|
200
|
+
|
|
201
|
+
const checkStore = openStore(root, sqlite);
|
|
202
|
+
t.after(() => checkStore.close());
|
|
203
|
+
const row = checkStore.read(() =>
|
|
204
|
+
checkStore.db.prepare("SELECT source_hash FROM files WHERE file_dir = '.' AND file_name = 'math.js'").get(),
|
|
205
|
+
) as { source_hash: string } | undefined;
|
|
206
|
+
assert.ok(row, "math.js should remain indexed");
|
|
207
|
+
assert.equal(row!.source_hash, hashText(updatedSource));
|
|
208
|
+
const conflicts = checkStore.read(() => checkStore.db.prepare("SELECT COUNT(*) AS c FROM reindex_conflicts").get()) as { c: number };
|
|
209
|
+
assert.equal(conflicts.c, 0, "headless rebuild without --reindex should keep the normal rebuild path");
|
|
210
|
+
void runtime;
|
|
211
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies the SQLite schema migrations, worktree binding, and BEGIN
|
|
3
|
+
* IMMEDIATE transactions. Skipped when node:sqlite is not available.
|
|
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 { Store } from "../src/code-graph/store.ts";
|
|
12
|
+
import { PathError, resolveCanonicalWorktree } from "../src/code-graph/paths.ts";
|
|
13
|
+
|
|
14
|
+
async function openInMemory(): Promise<{ store: Store; cleanup: () => void } | null> {
|
|
15
|
+
try {
|
|
16
|
+
const sqlite = await import("node:sqlite");
|
|
17
|
+
const worktreeRoot = fs.realpathSync(os.tmpdir());
|
|
18
|
+
const dbPath = path.join(os.tmpdir(), `code-graph-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
|
19
|
+
const store = new Store({ dbPath, worktreeRoot, gitCommonDir: worktreeRoot }, sqlite);
|
|
20
|
+
store.close();
|
|
21
|
+
const reopened = new Store({ dbPath, worktreeRoot, gitCommonDir: worktreeRoot }, sqlite);
|
|
22
|
+
return { store: reopened, cleanup: () => {
|
|
23
|
+
reopened.close();
|
|
24
|
+
try { fs.unlinkSync(dbPath); } catch { /* ignore */ }
|
|
25
|
+
} };
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test("schema migrations are idempotent and create required tables", async (t) => {
|
|
32
|
+
const opened = await openInMemory();
|
|
33
|
+
if (!opened) return;
|
|
34
|
+
t.after(() => opened.cleanup());
|
|
35
|
+
const { store } = opened;
|
|
36
|
+
const tables = store.read(() =>
|
|
37
|
+
store.db.prepare(`SELECT name FROM sqlite_master WHERE type IN ('table', 'view') ORDER BY name`).all(),
|
|
38
|
+
) as Array<{ name: string }>;
|
|
39
|
+
const names = new Set(tables.map((row) => row.name));
|
|
40
|
+
for (const required of ["call_edges", "files", "file_entries", "functions", "graph_meta", "function_records", "code_graph_snapshot"]) {
|
|
41
|
+
assert.ok(names.has(required), `expected table or view ${required}`);
|
|
42
|
+
}
|
|
43
|
+
const meta = store.readMeta();
|
|
44
|
+
assert.ok(meta, "graph_meta should be populated");
|
|
45
|
+
assert.equal(meta.worktreeRoot.length > 0, true);
|
|
46
|
+
const columns = store.db.prepare("PRAGMA table_info(files)").all() as Array<{ name: string }>;
|
|
47
|
+
assert.ok(columns.some((column) => column.name === "pending_kind"), "files.pending_kind must exist");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("v1 database upgrades to v2 with initial snapshot and re-entrant migration", async (t) => {
|
|
51
|
+
let sqlite: typeof import("node:sqlite");
|
|
52
|
+
try {
|
|
53
|
+
sqlite = await import("node:sqlite");
|
|
54
|
+
} catch {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const dbPath = path.join(os.tmpdir(), `code-graph-v1-upgrade-${process.pid}-${Date.now()}.db`);
|
|
58
|
+
t.after(() => {
|
|
59
|
+
try { fs.unlinkSync(dbPath); } catch { /* ignore */ }
|
|
60
|
+
});
|
|
61
|
+
// Build a v1 database: old files schema (no pending_kind), no snapshot table.
|
|
62
|
+
const raw = new sqlite.DatabaseSync(dbPath, { open: true });
|
|
63
|
+
raw.exec(`
|
|
64
|
+
CREATE TABLE graph_meta (schema_version INTEGER PRIMARY KEY, worktree_root TEXT NOT NULL, git_common_dir TEXT NOT NULL, parser_versions TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
65
|
+
CREATE TABLE files (file_dir TEXT NOT NULL, file_name TEXT NOT NULL, language TEXT NOT NULL, source_hash TEXT NOT NULL, source_text TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (file_dir, file_name));
|
|
66
|
+
INSERT INTO graph_meta VALUES (1, '${fs.realpathSync(os.tmpdir())}', '${fs.realpathSync(os.tmpdir())}', '{}', '2026-01-01T00:00:00Z');
|
|
67
|
+
INSERT INTO files (file_dir, file_name, language, source_hash, source_text, updated_at) VALUES ('.', 'a.js', 'javascript', 'h1', 'source', '2026-01-01T00:00:00Z');
|
|
68
|
+
`);
|
|
69
|
+
raw.close();
|
|
70
|
+
const worktreeRoot = fs.realpathSync(os.tmpdir());
|
|
71
|
+
const store = new Store({ dbPath, worktreeRoot, gitCommonDir: worktreeRoot }, sqlite);
|
|
72
|
+
try {
|
|
73
|
+
assert.equal(store.readMeta()?.schemaVersion, 2, "v1 DB must upgrade to v2 on open");
|
|
74
|
+
const columns = store.db.prepare("PRAGMA table_info(files)").all() as Array<{ name: string }>;
|
|
75
|
+
assert.ok(columns.some((column) => column.name === "pending_kind"));
|
|
76
|
+
const snapshot = store.readLatestSnapshot();
|
|
77
|
+
assert.ok(snapshot, "initial snapshot row must exist after migration");
|
|
78
|
+
assert.deepEqual(snapshot.uncommittedPaths, []);
|
|
79
|
+
// Re-open again: migration must be a safe no-op (step-idempotent).
|
|
80
|
+
store.close();
|
|
81
|
+
const reopened = new Store({ dbPath, worktreeRoot, gitCommonDir: worktreeRoot }, sqlite);
|
|
82
|
+
assert.equal(reopened.readMeta()?.schemaVersion, 2);
|
|
83
|
+
assert.ok(reopened.readLatestSnapshot(), "snapshot survives re-open");
|
|
84
|
+
const snapshots = reopened.read(() => reopened.db.prepare("SELECT COUNT(*) AS c FROM code_graph_snapshot").get()) as { c: number };
|
|
85
|
+
assert.equal(snapshots.c, 1, "no duplicate snapshot rows on re-entry");
|
|
86
|
+
reopened.close();
|
|
87
|
+
} finally {
|
|
88
|
+
try { store.close(); } catch { /* already closed */ }
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("snapshot upsert and readLatestSnapshot round-trip", async (t) => {
|
|
93
|
+
const opened = await openInMemory();
|
|
94
|
+
if (!opened) return;
|
|
95
|
+
t.after(() => opened.cleanup());
|
|
96
|
+
const { store } = opened;
|
|
97
|
+
store.upsertSnapshot("abc123", ["src/a.ts", "README.md"]);
|
|
98
|
+
store.upsertSnapshot("def456", []);
|
|
99
|
+
const latest = store.readLatestSnapshot();
|
|
100
|
+
assert.ok(latest);
|
|
101
|
+
assert.equal(latest.headCommit, "def456");
|
|
102
|
+
assert.deepEqual(latest.uncommittedPaths, []);
|
|
103
|
+
const prior = store.read(() => store.db.prepare("SELECT head_commit FROM code_graph_snapshot ORDER BY id ASC").all()) as Array<{ head_commit: string }>;
|
|
104
|
+
assert.equal(prior[0]?.head_commit, "abc123");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("worktree mismatch is rejected on open", async (t) => {
|
|
108
|
+
const opened = await openInMemory();
|
|
109
|
+
if (!opened) return;
|
|
110
|
+
t.after(() => opened.cleanup());
|
|
111
|
+
const { store } = opened;
|
|
112
|
+
const meta = store.readMeta();
|
|
113
|
+
assert.ok(meta);
|
|
114
|
+
// Same path: no throw.
|
|
115
|
+
store.checkWorktree(meta.worktreeRoot, meta.gitCommonDir);
|
|
116
|
+
assert.throws(
|
|
117
|
+
() => store.checkWorktree("/totally/different", meta.gitCommonDir),
|
|
118
|
+
(err: Error) => {
|
|
119
|
+
assert.ok(err instanceof PathError);
|
|
120
|
+
return true;
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("write transactions use BEGIN IMMEDIATE and roll back on error", async (t) => {
|
|
126
|
+
const opened = await openInMemory();
|
|
127
|
+
if (!opened) return;
|
|
128
|
+
t.after(() => opened.cleanup());
|
|
129
|
+
const { store } = opened;
|
|
130
|
+
store.tx(() => {
|
|
131
|
+
store.db
|
|
132
|
+
.prepare(
|
|
133
|
+
`INSERT INTO files (file_dir, file_name, language, source_hash, source_text, updated_at)
|
|
134
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
135
|
+
)
|
|
136
|
+
.run("dir", "a.js", "javascript", "hash", "source", new Date().toISOString());
|
|
137
|
+
});
|
|
138
|
+
const ok = store.read(() =>
|
|
139
|
+
store.db.prepare("SELECT COUNT(*) AS c FROM files WHERE file_dir = 'dir' AND file_name = 'a.js'").get(),
|
|
140
|
+
) as { c: number };
|
|
141
|
+
assert.equal(ok.c, 1);
|
|
142
|
+
assert.throws(() => {
|
|
143
|
+
store.tx(() => {
|
|
144
|
+
store.db
|
|
145
|
+
.prepare(
|
|
146
|
+
`INSERT INTO files (file_dir, file_name, language, source_hash, source_text, updated_at)
|
|
147
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
148
|
+
)
|
|
149
|
+
.run("dir", "a.js", "javascript", "dup", "source", new Date().toISOString());
|
|
150
|
+
throw new Error("fail");
|
|
151
|
+
});
|
|
152
|
+
}, /fail/);
|
|
153
|
+
const stillOne = store.read(() =>
|
|
154
|
+
store.db.prepare("SELECT COUNT(*) AS c FROM files WHERE file_dir = 'dir'").get(),
|
|
155
|
+
) as { c: number };
|
|
156
|
+
assert.equal(stillOne.c, 1, "duplicate insert must be rolled back");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("resolveCanonicalWorktree requires a git worktree", () => {
|
|
160
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-no-git-"));
|
|
161
|
+
try {
|
|
162
|
+
assert.throws(() => resolveCanonicalWorktree(dir));
|
|
163
|
+
} finally {
|
|
164
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Discovery regression tests for tracked, untracked, and excluded source paths. */
|
|
2
|
+
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import * as assert from "node:assert/strict";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as os from "node:os";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { discoverFiles } from "../src/code-graph/discovery.ts";
|
|
10
|
+
|
|
11
|
+
function git(cwd: string, args: string[]): void {
|
|
12
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
13
|
+
assert.equal(result.status, 0, `${args.join(" ")} failed: ${result.stderr}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("discovery applies product exclusions to Git and filesystem paths", () => {
|
|
17
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-discovery-"));
|
|
18
|
+
try {
|
|
19
|
+
git(root, ["init", "--initial-branch=main"]);
|
|
20
|
+
git(root, ["config", "user.email", "test@example.com"]);
|
|
21
|
+
git(root, ["config", "user.name", "test"]);
|
|
22
|
+
fs.writeFileSync(path.join(root, "tracked.js"), "function tracked() {}\n");
|
|
23
|
+
git(root, ["add", "tracked.js"]);
|
|
24
|
+
git(root, ["commit", "-m", "tracked"]);
|
|
25
|
+
fs.mkdirSync(path.join(root, "src"), { recursive: true });
|
|
26
|
+
fs.mkdirSync(path.join(root, "node_modules", "dependency"), { recursive: true });
|
|
27
|
+
fs.writeFileSync(path.join(root, "src", "untracked.ts"), "export function untracked() {}\n");
|
|
28
|
+
fs.writeFileSync(path.join(root, "node_modules", "dependency", "ignored.js"), "function ignored() {}\n");
|
|
29
|
+
fs.mkdirSync(path.join(root, "dist"), { recursive: true });
|
|
30
|
+
fs.writeFileSync(path.join(root, "dist", "built.py"), "def built(): pass\n");
|
|
31
|
+
|
|
32
|
+
const files = discoverFiles({ worktreeRoot: root });
|
|
33
|
+
const names = files.map((file) => `${file.fileDir}/${file.fileName}`);
|
|
34
|
+
assert.deepEqual(names, ["./tracked.js", "src/untracked.ts"]);
|
|
35
|
+
} finally {
|
|
36
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
});
|