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.
Files changed (75) hide show
  1. package/README.md +90 -26
  2. package/agents/ref-analyst.md +18 -0
  3. package/index.ts +121 -9
  4. package/package.json +16 -1
  5. package/references/pi-planning-workflow.md +21 -6
  6. package/references/state-and-config.md +52 -5
  7. package/scripts/validate.ts +5 -0
  8. package/skills/plan-with-refs/SKILL.md +3 -3
  9. package/src/code-graph/commands.ts +483 -0
  10. package/src/code-graph/discovery.ts +118 -0
  11. package/src/code-graph/git.ts +108 -0
  12. package/src/code-graph/identity.ts +59 -0
  13. package/src/code-graph/indexer.ts +281 -0
  14. package/src/code-graph/materialize.ts +166 -0
  15. package/src/code-graph/mode.ts +28 -0
  16. package/src/code-graph/mutations.ts +160 -0
  17. package/src/code-graph/parser.ts +51 -0
  18. package/src/code-graph/parsers/javascript.ts +35 -0
  19. package/src/code-graph/parsers/python.ts +160 -0
  20. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  21. package/src/code-graph/paths.ts +85 -0
  22. package/src/code-graph/prompts.ts +18 -0
  23. package/src/code-graph/resolver.ts +69 -0
  24. package/src/code-graph/runtime.ts +158 -0
  25. package/src/code-graph/schema.ts +135 -0
  26. package/src/code-graph/screening.ts +82 -0
  27. package/src/code-graph/store.ts +278 -0
  28. package/src/code-graph/summary.ts +435 -0
  29. package/src/code-graph/types.ts +163 -0
  30. package/src/compaction.ts +1125 -371
  31. package/src/config-command.ts +361 -0
  32. package/src/exec.ts +508 -693
  33. package/src/guard.ts +14 -1
  34. package/src/refine-prompts.ts +109 -0
  35. package/src/refine-ui-helpers.ts +71 -18
  36. package/src/refine-ui-state.ts +88 -22
  37. package/src/refine-ui.ts +210 -102
  38. package/src/state.ts +36 -7
  39. package/src/subagent.ts +164 -61
  40. package/src/termination-prompt.ts +22 -0
  41. package/tests/analyze-refs.test.ts +265 -0
  42. package/tests/ask-choice.test.ts +264 -0
  43. package/tests/autocomplete.test.ts +6 -1
  44. package/tests/code-graph-apply-action.test.ts +173 -0
  45. package/tests/code-graph-apply.test.ts +185 -0
  46. package/tests/code-graph-commands.test.ts +211 -0
  47. package/tests/code-graph-db.test.ts +166 -0
  48. package/tests/code-graph-discovery.test.ts +38 -0
  49. package/tests/code-graph-git.test.ts +94 -0
  50. package/tests/code-graph-index.test.ts +175 -0
  51. package/tests/code-graph-loop.e2e.test.ts +159 -0
  52. package/tests/code-graph-mutations.test.ts +117 -0
  53. package/tests/code-graph-parser.test.ts +85 -0
  54. package/tests/code-graph-rollback.test.ts +100 -0
  55. package/tests/code-graph-summary-batching.test.ts +518 -0
  56. package/tests/code-graph-summary.test.ts +148 -0
  57. package/tests/compaction.test.ts +371 -57
  58. package/tests/config-command.test.ts +263 -0
  59. package/tests/exec.test.ts +808 -241
  60. package/tests/fixtures/code-graph/sample.js +36 -0
  61. package/tests/fixtures/code-graph/sample.py +20 -0
  62. package/tests/fixtures/code-graph/sample.ts +15 -0
  63. package/tests/graph-aware-file-tools.test.ts +411 -0
  64. package/tests/guard.test.ts +27 -1
  65. package/tests/plans.test.ts +10 -0
  66. package/tests/refine-prompts.test.ts +101 -2
  67. package/tests/refine-ui.test.ts +371 -72
  68. package/tests/state.test.ts +32 -0
  69. package/tests/subagent.test.ts +48 -20
  70. package/tools/analyze-refs.ts +263 -0
  71. package/tools/ask-choice.ts +159 -11
  72. package/tools/code-graph.ts +277 -0
  73. package/tools/graph-aware-file-tools.ts +392 -0
  74. package/tools/plans.ts +97 -2
  75. package/tools/refine.ts +61 -15
@@ -0,0 +1,264 @@
1
+ /** Behavior tests for the ask_choice trailing option (post-execution
2
+ * amelioration prompt): Auto-refine loop replaces Auto-complete, the
3
+ * auto-answer mode is suppressed, and headless sessions stop instead of
4
+ * auto-answering. */
5
+
6
+ import * as assert from "node:assert/strict";
7
+ import * as fs from "node:fs";
8
+ import * as os from "node:os";
9
+ import * as path from "node:path";
10
+ import { after, before, describe, it } from "node:test";
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { fitAskChoicePanel, OPTION_MAX_LINES, PANEL_CHROME_LINES, PANEL_SAFETY_MARGIN, SELECTOR_WIDTH_OVERHEAD, STATUS_BAR_HEIGHT, registerAskChoiceTool } from "../tools/ask-choice.ts";
13
+ import { visibleWidth } from "../src/refine-ui-helpers.ts";
14
+
15
+ type ToolDef = {
16
+ execute: (id: string, params: any, signal: undefined, update: undefined, ctx: any) => Promise<any>;
17
+ };
18
+
19
+ let root: string;
20
+
21
+ before(() => {
22
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-ask-choice-"));
23
+ });
24
+
25
+ after(() => {
26
+ fs.rmSync(root, { recursive: true, force: true });
27
+ });
28
+
29
+ function loadTool(): ToolDef {
30
+ let tool: ToolDef | undefined;
31
+ const pi = {
32
+ registerTool: (definition: ToolDef) => {
33
+ tool = definition;
34
+ },
35
+ } as unknown as ExtensionAPI;
36
+ registerAskChoiceTool(pi);
37
+ if (!tool) throw new Error("ask_choice tool not registered");
38
+ return tool;
39
+ }
40
+
41
+ let notifyCalls: Array<{ message: string; severity?: string }> = [];
42
+
43
+ function makeCtx(opts: {
44
+ hasUI?: boolean;
45
+ select: (question: string, labels: string[]) => Promise<string | undefined>;
46
+ }) {
47
+ notifyCalls = [];
48
+ return {
49
+ cwd: root,
50
+ hasUI: opts.hasUI ?? true,
51
+ sessionManager: {},
52
+ ui: {
53
+ select: opts.select,
54
+ input: async () => "typed",
55
+ notify: (message: string, severity?: string) => {
56
+ notifyCalls.push({ message, severity });
57
+ },
58
+ },
59
+ };
60
+ }
61
+
62
+ const OPTIONS = [
63
+ { label: "Run one Reviewer round", recommended: true },
64
+ { label: "Run a Criticizer round" },
65
+ { label: "Finish here" },
66
+ ];
67
+
68
+ describe("ask_choice trailing option", () => {
69
+ it("renders Auto-refine loop as the trailing option and suppresses Auto-complete", async () => {
70
+ const tool = loadTool();
71
+ let seenLabels: string[] = [];
72
+ const ctx = makeCtx({
73
+ select: async (_question, labels) => {
74
+ seenLabels = labels;
75
+ return labels.find((label) => label.startsWith("Auto-refine loop"));
76
+ },
77
+ });
78
+ const result = await tool.execute("t1", {
79
+ question: "Ameliorate?",
80
+ options: OPTIONS,
81
+ autoComplete: true, // deliberately erroneous: trailing must suppress it
82
+ trailing: "auto-refine-loop",
83
+ }, undefined, undefined, ctx);
84
+
85
+ assert.equal(seenLabels.at(-1)?.startsWith("Auto-refine loop"), true, "Auto-refine loop must be last");
86
+ assert.equal(seenLabels.some((label) => label.startsWith("Auto-complete")), false, "Auto-complete must be absent");
87
+ const text = result.content[0].text as string;
88
+ assert.match(text, /User selected Auto-refine loop/);
89
+ assert.match(text, /until no high-severity finding \(hard cap 5 rounds\)/);
90
+ assert.match(text, /goal wait: continue until no unpassed VCs remain/);
91
+ assert.match(text, /refine \(role: "reviewer", target: "implementation"\)/);
92
+ assert.equal(result.details.source, "user");
93
+ assert.equal(result.details.answer, "Auto-refine loop");
94
+ });
95
+
96
+ it("keeps Auto-complete as the trailing option without the trailing param", async () => {
97
+ const tool = loadTool();
98
+ let seenLabels: string[] = [];
99
+ const ctx = makeCtx({
100
+ select: async (_question, labels) => {
101
+ seenLabels = labels;
102
+ return labels[0];
103
+ },
104
+ });
105
+ await tool.execute("t2", {
106
+ question: "Planning question?",
107
+ options: OPTIONS,
108
+ autoComplete: true,
109
+ }, undefined, undefined, ctx);
110
+ assert.equal(seenLabels.at(-1)?.startsWith("Auto-complete"), true);
111
+ assert.equal(seenLabels.some((label) => label.startsWith("Auto-refine loop")), false);
112
+ });
113
+
114
+ it("never auto-answers a trailing question in headless sessions", async () => {
115
+ const tool = loadTool();
116
+ const ctx = makeCtx({ hasUI: false, select: async () => undefined });
117
+ await assert.rejects(
118
+ tool.execute("t3", {
119
+ question: "Ameliorate?",
120
+ options: OPTIONS,
121
+ autoComplete: true,
122
+ trailing: "auto-refine-loop",
123
+ }, undefined, undefined, ctx),
124
+ /No UI available/,
125
+ );
126
+ });
127
+ });
128
+
129
+ describe("ask_choice panel fitting", () => {
130
+ it("sanitizes newlines in the question and labels", () => {
131
+ const fitted = fitAskChoicePanel("Q line1\nQ line2", [{ core: "1. a\nb", display: "1. a\nb — desc" }], 100, 30);
132
+ assert.doesNotMatch(fitted.question, /\r?\n/);
133
+ for (const label of fitted.labels) assert.doesNotMatch(label, /\r?\n/);
134
+ });
135
+
136
+ it("caps each label at the 3-line budget with a .. marker (CJK included)", () => {
137
+ const columns = 100;
138
+ const budget = OPTION_MAX_LINES * Math.max(20, columns - SELECTOR_WIDTH_OVERHEAD);
139
+ const cjkLabel = `1. ${"选项".repeat(200)}`; // 800 visible columns, far over the budget
140
+ const fitted = fitAskChoicePanel("q", [{ core: cjkLabel, display: cjkLabel }], columns, 60);
141
+ assert.equal(fitted.labels.length, 1);
142
+ assert.ok(visibleWidth(fitted.labels[0]) <= budget, `label exceeds 3-line budget: ${visibleWidth(fitted.labels[0])}`);
143
+ assert.match(fitted.labels[0], /\.\.$/);
144
+ });
145
+
146
+ it("degrades: stage 1 strips descriptions before touching labels", () => {
147
+ const columns = 100;
148
+ const rows = 30; // rowBudget = 27 → content budget 17 lines after chrome
149
+ const items = Array.from({ length: 12 }, (_, i) => ({
150
+ core: `${i + 1}. option-${i}`,
151
+ display: `${i + 1}. option-${i} — ${"detail ".repeat(40)}`,
152
+ }));
153
+ const fitted = fitAskChoicePanel("q", items, columns, rows);
154
+ for (const label of fitted.labels) assert.doesNotMatch(label, /detail/);
155
+ assert.match(fitted.labels[0] ?? "", /^1\. option-0/);
156
+ });
157
+
158
+ it("degrades: stage 2 collapses labels to one line before truncating the question", () => {
159
+ const columns = 100;
160
+ const rows = 30;
161
+ const items = Array.from({ length: 15 }, (_, i) => ({
162
+ core: `${i + 1}. ${"x".repeat(170)}`,
163
+ display: `${i + 1}. ${"x".repeat(170)} — description`,
164
+ }));
165
+ const fitted = fitAskChoicePanel("the question", items, columns, rows);
166
+ for (const label of fitted.labels) assert.ok(visibleWidth(label) <= Math.max(20, columns - SELECTOR_WIDTH_OVERHEAD));
167
+ assert.equal(fitted.question, "the question"); // question untouched at stage 2
168
+ });
169
+
170
+ it("warns once when even the minimal form exceeds a tiny terminal", () => {
171
+ const columns = 60;
172
+ const rows = 12; // rowBudget = 9; 30 options can never fit
173
+ const items = Array.from({ length: 30 }, (_, i) => ({ core: `${i + 1}. opt-${i}`, display: `${i + 1}. opt-${i}` }));
174
+ const fitted = fitAskChoicePanel("q", items, columns, rows);
175
+ assert.equal(fitted.overflowWarned, true);
176
+ for (const label of fitted.labels) assert.ok(visibleWidth(label) <= 20);
177
+ });
178
+
179
+ it("degrades: stage 3 truncates the question only after labels are single-lined", () => {
180
+ const columns = 100;
181
+ const rows = 32; // rowBudget = 29; chrome 9 → content budget 20 lines
182
+ const longQuestion = "q".repeat(300);
183
+ const items = Array.from({ length: 18 }, (_, i) => ({ core: `${i + 1}. opt-${i}`, display: `${i + 1}. opt-${i}` }));
184
+ const fitted = fitAskChoicePanel(longQuestion, items, columns, rows);
185
+ assert.equal(fitted.overflowWarned, false); // stops at stage 3, not the minimal form
186
+ assert.ok(visibleWidth(fitted.question) <= Math.max(20, columns - SELECTOR_WIDTH_OVERHEAD));
187
+ assert.match(fitted.question, /\.\.$/);
188
+ assert.match(fitted.labels[0] ?? "", /^1\. opt-0$/); // labels stay single-line and untruncated
189
+ });
190
+
191
+ it("keeps Other/Auto-complete/Auto-refine prefixes alive even on tiny terminals", () => {
192
+ const columns = 26;
193
+ const rows = 12; // minimal form: 20-column floor
194
+ const items = [
195
+ { core: "1. keep-current", display: "1. keep-current" },
196
+ { core: "Other… (type your own answer)", display: "Other… (type your own answer)", fixed: true },
197
+ { core: "Auto-complete (take the recommended option)", display: "Auto-complete (take the recommended option)", fixed: true },
198
+ { core: "Auto-refine loop (run refinement rounds until no high-severity finding or the 5-round cap)", display: "Auto-refine loop (run refinement rounds until no high-severity finding or the 5-round cap)", fixed: true },
199
+ ];
200
+ const fitted = fitAskChoicePanel("q", items, columns, rows);
201
+ assert.match(fitted.labels[1] ?? "", /^Other…/);
202
+ assert.match(fitted.labels[2] ?? "", /^Auto-complete/);
203
+ assert.match(fitted.labels[3] ?? "", /^Auto-refine loop/);
204
+ });
205
+
206
+ it("execute notifies once when the minimal form still overflows a tiny terminal", async () => {
207
+ const tool = loadTool();
208
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-ask-choice-overflow-"));
209
+ const rowsDesc = Object.getOwnPropertyDescriptor(process.stdout, "rows");
210
+ const colsDesc = Object.getOwnPropertyDescriptor(process.stdout, "columns");
211
+ Object.defineProperty(process.stdout, "rows", { value: 12, configurable: true });
212
+ Object.defineProperty(process.stdout, "columns", { value: 60, configurable: true });
213
+ try {
214
+ const ctx = makeCtx({
215
+ select: async (_question, labels) => labels[0],
216
+ });
217
+ // makeCtx binds cwd at construction; point it at the temp dir via workdir param instead.
218
+ await tool.execute(
219
+ "id",
220
+ {
221
+ question: "Pick one",
222
+ options: Array.from({ length: 30 }, (_, i) => ({ label: `opt-${i}`, description: `${"detail ".repeat(20)}` })),
223
+ autoComplete: false,
224
+ allowOther: false,
225
+ workdir: dir,
226
+ },
227
+ undefined,
228
+ undefined,
229
+ ctx,
230
+ );
231
+ assert.equal(notifyCalls.length, 1);
232
+ assert.match(notifyCalls[0]!.message, /Terminal too small/);
233
+ } finally {
234
+ if (rowsDesc) Object.defineProperty(process.stdout, "rows", rowsDesc);
235
+ if (colsDesc) Object.defineProperty(process.stdout, "columns", colsDesc);
236
+ fs.rmSync(dir, { recursive: true, force: true });
237
+ }
238
+ });
239
+
240
+ it("pi-tui Text renders every fitted label within 3 lines at real panel width", async () => {
241
+ let Text: any;
242
+ try {
243
+ ({ Text } = await import("@earendil-works/pi-tui"));
244
+ } catch {
245
+ return; // D-007 sanctioned fallback: pi-tui not resolvable in this layout
246
+ }
247
+ const columns = 100;
248
+ const rows = 60;
249
+ const realWidth = columns - 4; // border + padding + marker, per extension-selector.js
250
+ const items = Array.from({ length: 8 }, (_, i) => ({
251
+ core: `${i + 1}. option-${i}`,
252
+ display: `${i + 1}. option-${i} — ${"中文描述 " .repeat(30)}mixed ascii tail ${"x".repeat(60)}`,
253
+ }));
254
+ const fitted = fitAskChoicePanel("q", items, columns, rows);
255
+ let totalLines = PANEL_CHROME_LINES + 1; // chrome + question line
256
+ for (const label of fitted.labels) {
257
+ const component = new Text(label, 1, 0);
258
+ const rendered = component.render(realWidth);
259
+ assert.ok(rendered.length <= OPTION_MAX_LINES, `label renders ${rendered.length} lines: ${JSON.stringify(label.slice(0, 60))}`);
260
+ totalLines += rendered.length;
261
+ }
262
+ assert.ok(totalLines < rows - STATUS_BAR_HEIGHT - PANEL_SAFETY_MARGIN);
263
+ });
264
+ });
@@ -132,11 +132,16 @@ describe("ask_choice Auto-complete wiring", () => {
132
132
  assert.match(source, /isAutoCompleteEnabled/);
133
133
  assert.match(source, /recordAskChoice\(ctx, true\)/);
134
134
  assert.match(source, /autoComplete && selected\.startsWith\("Auto-complete"\)/);
135
+ // Trailing option: Auto-refine loop replaces Auto-complete and is suppressed in headless sessions.
136
+ assert.match(source, /trailing === undefined/);
137
+ assert.match(source, /AUTO_REFINE_LOOP_LABEL/);
138
+ assert.match(source, /trailing && selected\.startsWith\("Auto-refine loop"\)/);
135
139
  const indexSource = fs.readFileSync(path.join(process.cwd(), "index.ts"), "utf8");
136
140
  assert.match(indexSource, /plans-autocomplete-stop/);
137
141
  assert.match(indexSource, /autoCompleteStatus\(ctx\)/);
138
142
  assert.match(indexSource, /restoreAutoCompleteFromSession/);
139
143
  const execSource = fs.readFileSync(path.join(process.cwd(), "src", "exec.ts"), "utf8");
140
- assert.match(execSource, /customType === AUTOCOMPLETE_ENTRY/);
144
+ assert.doesNotMatch(execSource, /customType === AUTOCOMPLETE_ENTRY/);
145
+ assert.match(execSource, /shouldTriggerPlanningCompaction\(_ctx: ExtensionContext\): boolean \{\n\s+return false;/);
141
146
  });
142
147
  });
@@ -0,0 +1,173 @@
1
+ /** Tests for the agent-invokable code_graph "apply" action and its gates. */
2
+
3
+ import * as assert from "node:assert/strict";
4
+ import { spawnSync } from "node:child_process";
5
+ import * as fs from "node:fs";
6
+ import * as os from "node:os";
7
+ import * as path from "node:path";
8
+ import * as url from "node:url";
9
+ import { test } from "node:test";
10
+ import { Store } from "../src/code-graph/store.ts";
11
+ import { runIndex } from "../src/code-graph/indexer.ts";
12
+ import { applyGraphCore } from "../src/code-graph/commands.ts";
13
+ import { updateFile } from "../src/code-graph/mutations.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
+ import { initState, setRunStatus, startRun } from "../src/state.ts";
20
+ import { registerCodeGraphTool } from "../tools/code-graph.ts";
21
+
22
+ const ROOT = path.dirname(path.dirname(url.fileURLToPath(import.meta.url)));
23
+
24
+ function git(cwd: string, args: string[]): void {
25
+ const env: Record<string, string | undefined> = { ...process.env };
26
+ for (const key of ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE"]) delete env[key];
27
+ const result = spawnSync("git", args, { cwd, env, encoding: "utf8" });
28
+ assert.equal(result.status, 0, `${args.join(" ")} failed: ${result.stderr}`);
29
+ }
30
+
31
+ async function setupIndexedRepo(): Promise<{ root: string; store: Store; cleanup: () => void } | null> {
32
+ const runtime = await loadGraphRuntime();
33
+ if (!runtime.status.parserAvailable || !runtime.status.sqliteAvailable) return null;
34
+
35
+ const raw = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-apply-action-"));
36
+ git(raw, ["init", "--initial-branch=main"]);
37
+ git(raw, ["config", "user.email", "test@example.com"]);
38
+ git(raw, ["config", "user.name", "test"]);
39
+ fs.writeFileSync(path.join(raw, "math.js"), "function add(a, b) { return a + b; }\n");
40
+ git(raw, ["add", "-A"]);
41
+ git(raw, ["commit", "-m", "init"]);
42
+ const root = fs.realpathSync(raw);
43
+
44
+ const ParserCtor = runtime.runtime.parser.Parser as unknown as new () => {
45
+ parse(input: string | Buffer): unknown;
46
+ setLanguage(language: unknown): void;
47
+ };
48
+ const parsers: Record<Language, ParserBackend> = {
49
+ javascript: makeBackend("javascript", ParserCtor, runtime.runtime.parser.javascript),
50
+ typescript: makeBackend("typescript", ParserCtor, runtime.runtime.parser.typescript),
51
+ tsx: makeBackend("tsx", ParserCtor, runtime.runtime.parser.tsx),
52
+ python: new PythonBackend(ParserCtor, runtime.runtime.parser.python),
53
+ };
54
+ fs.mkdirSync(path.join(root, ".git", "pi_plans"), { recursive: true });
55
+ const store = new Store(
56
+ { dbPath: path.join(root, ".git", "pi_plans", "code_graph.db"), worktreeRoot: root, gitCommonDir: path.join(root, ".git") },
57
+ runtime.runtime.sqlite,
58
+ );
59
+ runIndex({ store, worktreeRoot: root, parsers, reindex: false });
60
+ return {
61
+ root,
62
+ store,
63
+ cleanup: () => {
64
+ store.close();
65
+ try {
66
+ fs.rmSync(raw, { recursive: true, force: true });
67
+ } catch {
68
+ /* ignore */
69
+ }
70
+ },
71
+ };
72
+ }
73
+
74
+ test("applyGraphCore refuses during planning runs and allows once executing or done", async (t) => {
75
+ const opened = await setupIndexedRepo();
76
+ if (!opened) return;
77
+ t.after(() => opened.cleanup());
78
+ const { root } = opened;
79
+
80
+ initState(root);
81
+ const { run } = startRun(root, { topic: "apply-gate", skill: "plan-small", requestText: "x" });
82
+
83
+ const planning = await applyGraphCore(root);
84
+ assert.match(planning.refused ?? "", /planning run is currently planning or accepted/);
85
+ assert.equal(planning.report, undefined);
86
+
87
+ setRunStatus(root, run.run_id, "executing");
88
+ const mathBefore = fs.readFileSync(path.join(root, "math.js"), "utf8");
89
+ const allowed = await applyGraphCore(root);
90
+ assert.equal(allowed.refused, undefined);
91
+ assert.ok(allowed.report, "expected a report once allowed");
92
+ assert.ok(allowed.drift, "expected a drift summary once allowed");
93
+ // Safe no-op with no pending staged edits: disk untouched, nothing stale or errored.
94
+ assert.equal(fs.readFileSync(path.join(root, "math.js"), "utf8"), mathBefore);
95
+ const noPendingCounts: Record<string, number> = { ok: 0, deleted: 0, stale: 0, "skipped-missing": 0, error: 0 };
96
+ for (const file of allowed.report!.files) noPendingCounts[file.status] = (noPendingCounts[file.status] ?? 0) + 1;
97
+ assert.equal(noPendingCounts.stale, 0);
98
+ assert.equal(noPendingCounts.error, 0);
99
+
100
+ setRunStatus(root, run.run_id, "done");
101
+ const doneAllowed = await applyGraphCore(root);
102
+ assert.equal(doneAllowed.refused, undefined);
103
+ assert.ok(doneAllowed.report, "done runs must still be allowed");
104
+ });
105
+
106
+ test("applyGraphCore materializes pending DB edits and reports counts plus drift", async (t) => {
107
+ const opened = await setupIndexedRepo();
108
+ if (!opened) return;
109
+ t.after(() => opened.cleanup());
110
+ const { root, store } = opened;
111
+ initState(root); // no active run → allowed
112
+
113
+ const next = "function add(a, b) { return a + b + 1; }\n";
114
+ const mutation = updateFile(store, { fileDir: ".", fileName: "math.js", text: next });
115
+ assert.equal(mutation.ok, true);
116
+ // Disk still has the original content before apply.
117
+ assert.equal(fs.readFileSync(path.join(root, "math.js"), "utf8"), "function add(a, b) { return a + b; }\n");
118
+
119
+ const core = await applyGraphCore(root);
120
+ assert.equal(core.refused, undefined);
121
+ assert.ok(core.report);
122
+ const counts: Record<string, number> = { ok: 0, deleted: 0, stale: 0, "skipped-missing": 0, error: 0 };
123
+ for (const file of core.report.files) counts[file.status] = (counts[file.status] ?? 0) + 1;
124
+ assert.ok(counts.ok >= 1, "at least the staged file must materialize");
125
+ assert.equal(fs.readFileSync(path.join(root, "math.js"), "utf8"), next);
126
+ assert.ok(core.drift);
127
+ assert.equal(core.drift.pending, 0);
128
+ assert.equal(core.drift.ok, true);
129
+ });
130
+
131
+ test("code_graph tool apply action: refiner env refuses, planning gate holds, wiring present", async (t) => {
132
+ const source = fs.readFileSync(path.join(ROOT, "tools", "code-graph.ts"), "utf8");
133
+ assert.match(source, /"apply"/);
134
+ assert.match(source, /PI_PLANS_REFINER/);
135
+ assert.match(source, /applyGraphCore/);
136
+
137
+ const opened = await setupIndexedRepo();
138
+ if (!opened) return;
139
+ t.after(() => opened.cleanup());
140
+ const { root } = opened;
141
+ initState(root);
142
+ const { run } = startRun(root, { topic: "apply-tool", skill: "plan-small", requestText: "x" });
143
+
144
+ let captured: {
145
+ execute: (id: string, params: any, signal: unknown, onUpdate: unknown, ctx: any) => Promise<{ content: Array<{ type: string; text: string }> }>;
146
+ } | undefined;
147
+ registerCodeGraphTool({
148
+ registerTool: (definition: never) => {
149
+ captured = definition as typeof captured;
150
+ },
151
+ } as never);
152
+ assert.ok(captured, "registerTool was not called");
153
+
154
+ // Refiner marker refuses before any run-state lookup.
155
+ const previous = process.env.PI_PLANS_REFINER;
156
+ process.env.PI_PLANS_REFINER = "1";
157
+ try {
158
+ const refused = await captured!.execute("c1", { action: "apply", workdir: root }, undefined, undefined, { cwd: root });
159
+ const payload = JSON.parse(refused.content[0]!.text) as { ok: boolean; reason: string };
160
+ assert.equal(payload.ok, false);
161
+ assert.match(payload.reason, /PI_PLANS_REFINER/);
162
+ } finally {
163
+ if (previous === undefined) delete process.env.PI_PLANS_REFINER;
164
+ else process.env.PI_PLANS_REFINER = previous;
165
+ }
166
+
167
+ // Planning run (without the marker) is refused by the run-state gate.
168
+ setRunStatus(root, run.run_id, "planning");
169
+ const gateRefused = await captured!.execute("c2", { action: "apply", workdir: root }, undefined, undefined, { cwd: root });
170
+ const gatePayload = JSON.parse(gateRefused.content[0]!.text) as { ok: boolean; reason: string };
171
+ assert.equal(gatePayload.ok, false);
172
+ assert.match(gatePayload.reason, /planning run is currently planning or accepted/);
173
+ });
@@ -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
+ });