@maxiaochao/pi-toolkit 0.9.4 → 0.9.6

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 (29) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +1 -1
  3. package/packages/codex-edit/CHANGELOG.md +15 -0
  4. package/packages/codex-edit/README.md +10 -3
  5. package/packages/codex-edit/TESTING.md +40 -9
  6. package/packages/codex-edit/benchmark/explicit-edit-100.json +2824 -0
  7. package/packages/codex-edit/benchmark/export-evidence.mjs +77 -0
  8. package/packages/codex-edit/benchmark/incident-cases.mjs +37 -0
  9. package/packages/codex-edit/benchmark/prepare.mjs +181 -0
  10. package/packages/codex-edit/benchmark/results/explicit-edit-100-sol-low-v3.json +4562 -0
  11. package/packages/codex-edit/benchmark/run-with-budget.mjs +77 -0
  12. package/packages/codex-edit/benchmark/schedule.mjs +19 -0
  13. package/packages/codex-edit/benchmark/select-tasks.mjs +167 -0
  14. package/packages/codex-edit/codex-edit-explainer.html +4 -4
  15. package/packages/codex-edit/docs/test-report.html +181 -0
  16. package/packages/codex-edit/docs/test-report.md +242 -0
  17. package/packages/codex-edit/docs/test-summary.md +9 -0
  18. package/packages/codex-edit/extensions/codex-edit.ts +4 -2
  19. package/packages/codex-edit/package.json +8 -2
  20. package/packages/codex-edit/src/parser.ts +26 -3
  21. package/packages/codex-edit/tests/benchmark.test.mjs +81 -0
  22. package/packages/codex-edit/tests/comparison-cases.mjs +396 -0
  23. package/packages/codex-edit/tests/comparison-harness.mjs +44 -0
  24. package/packages/codex-edit/tests/generate-test-report.mjs +589 -0
  25. package/packages/codex-edit/tests/legacy-parser-v0.1.7.mjs +128 -0
  26. package/packages/codex-edit/tests/native-vs-codex.ts +109 -0
  27. package/packages/codex-edit/tests/package.test.mjs +8 -0
  28. package/packages/codex-edit/tests/parser.test.mjs +123 -1
  29. package/packages/codex-edit/tests/released-vs-local.test.mjs +31 -0
@@ -0,0 +1,128 @@
1
+ // Frozen semantic baseline from @maxiaochao/pi-codex-edit v0.1.7.
2
+ // Source blob: codex-edit-v0.1.7:packages/codex-edit/src/parser.ts
3
+ // Git blob ID: e2896b59d40b5f6f01b3a4a7918476f4acc28cd8
4
+ // Keep this file unchanged; add cases to comparison-cases.mjs instead.
5
+
6
+ export function parseLegacyPatch(patch) {
7
+ const lines = patch.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim().split("\n");
8
+ if (lines[0]?.trim() !== "*** Begin Patch") throw new Error("The first line of the patch must be '*** Begin Patch'");
9
+ if (lines.at(-1)?.trim() !== "*** End Patch") throw new Error("The last line of the patch must be '*** End Patch'");
10
+ const operations = [];
11
+ let index = 1;
12
+ while (index < lines.length - 1) {
13
+ const line = lines[index];
14
+ if (line.startsWith("*** Add File: ")) {
15
+ const path = line.slice("*** Add File: ".length);
16
+ index += 1;
17
+ const content = [];
18
+ while (index < lines.length - 1 && !lines[index].startsWith("*** ")) {
19
+ if (!lines[index].startsWith("+")) throw new Error(`Invalid add line ${index + 1}`);
20
+ content.push(lines[index].slice(1));
21
+ index += 1;
22
+ }
23
+ if (content.length === 0) throw new Error(`Add file hunk for path '${path}' is empty`);
24
+ operations.push({ action: "add", path, content: `${content.join("\n")}\n` });
25
+ continue;
26
+ }
27
+ if (line.startsWith("*** Delete File: ")) {
28
+ const path = line.slice("*** Delete File: ".length);
29
+ if (!path) throw new Error(`Delete file hunk on line ${index + 1} has an empty path`);
30
+ operations.push({ action: "delete", path });
31
+ index += 1;
32
+ continue;
33
+ }
34
+ if (line.startsWith("*** Update File: ")) {
35
+ const path = line.slice("*** Update File: ".length);
36
+ index += 1;
37
+ let moveTo;
38
+ if (lines[index]?.startsWith("*** Move to: ")) {
39
+ moveTo = lines[index].slice("*** Move to: ".length);
40
+ index += 1;
41
+ }
42
+ const chunks = [];
43
+ while (index < lines.length - 1 && !lines[index].startsWith("*** ")) {
44
+ let context;
45
+ if (lines[index] === "@@" || lines[index].startsWith("@@ ")) {
46
+ context = lines[index].slice(2).trim() || undefined;
47
+ index += 1;
48
+ }
49
+ const oldLines = [];
50
+ const newLines = [];
51
+ const contextLineIndices = [];
52
+ while (index < lines.length - 1 && !lines[index].startsWith("*** ") && !lines[index].startsWith("@@")) {
53
+ const change = lines[index];
54
+ const marker = change[0];
55
+ if (!["+", "-", " "].includes(marker)) throw new Error(`Invalid update line ${index + 1}`);
56
+ const text = change.slice(1);
57
+ if (marker === "+") newLines.push(text);
58
+ else if (marker === "-") oldLines.push(text);
59
+ else {
60
+ contextLineIndices.push([oldLines.length, newLines.length]);
61
+ oldLines.push(text);
62
+ newLines.push(text);
63
+ }
64
+ index += 1;
65
+ }
66
+ const endOfFile = lines[index] === "*** End of File";
67
+ if (endOfFile) index += 1;
68
+ if (oldLines.length === 0 && newLines.length === 0) throw new Error(`Empty update chunk on line ${index + 1}`);
69
+ chunks.push({ context, oldLines, newLines, contextLineIndices, endOfFile });
70
+ }
71
+ if (chunks.length === 0) throw new Error(`Update file hunk for path '${path}' is empty`);
72
+ operations.push({ action: "update", path, moveTo, chunks });
73
+ continue;
74
+ }
75
+ throw new Error(`Invalid hunk header on line ${index + 1}: ${line}`);
76
+ }
77
+ if (operations.length === 0) throw new Error("Patch contains no file operations");
78
+ const targets = operations.flatMap((operation) => [operation.path, operation.moveTo].filter(Boolean));
79
+ if (new Set(targets).size !== targets.length) throw new Error("Multiple operations target the same path");
80
+ return operations;
81
+ }
82
+
83
+ function findCandidates(lines, pattern, start, eof, compare) {
84
+ const candidates = [];
85
+ for (let index = start; index + pattern.length <= lines.length; index += 1) {
86
+ if (pattern.every((line, offset) => compare(lines[index + offset], line))) candidates.push(index);
87
+ }
88
+ candidates.sort((left, right) => eof ? right - left : left - right);
89
+ return candidates;
90
+ }
91
+
92
+ function locate(lines, pattern, start, eof, label) {
93
+ if (pattern.length === 0) return Math.min(start, lines.length);
94
+ const comparisons = [
95
+ (left, right) => left === right,
96
+ (left, right) => left.trimEnd() === right.trimEnd(),
97
+ (left, right) => left.trim() === right.trim(),
98
+ ];
99
+ for (const compare of comparisons) {
100
+ const candidates = findCandidates(lines, pattern, start, eof, compare);
101
+ if (candidates.length > 0) return candidates[0];
102
+ }
103
+ throw new Error(`Failed to find expected lines${label ? ` in ${label}` : ""}: ${pattern.join("\\n")}`);
104
+ }
105
+
106
+ export function applyLegacyUpdate(content, chunks, path) {
107
+ const bom = content.startsWith("\ufeff") ? "\ufeff" : "";
108
+ const withoutBom = bom ? content.slice(1) : content;
109
+ const ending = withoutBom.includes("\r\n") ? "\r\n" : "\n";
110
+ const normalized = withoutBom.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
111
+ const hadFinalNewline = normalized.endsWith("\n");
112
+ const lines = normalized === "" ? [] : normalized.split("\n");
113
+ if (hadFinalNewline) lines.pop();
114
+ const replacements = [];
115
+ let cursor = 0;
116
+ for (const chunk of chunks) {
117
+ if (chunk.context !== undefined) cursor = locate(lines, [chunk.context], cursor, false, path) + 1;
118
+ const start = locate(lines, chunk.oldLines, cursor, chunk.endOfFile, path);
119
+ replacements.push({ start, length: chunk.oldLines.length, next: chunk.newLines });
120
+ cursor = start + chunk.oldLines.length;
121
+ }
122
+ const output = [...lines];
123
+ for (const replacement of [...replacements].reverse()) output.splice(replacement.start, replacement.length, ...replacement.next);
124
+ let next = output.join("\n");
125
+ if (hadFinalNewline || next.length > 0) next += "\n";
126
+ if (ending === "\r\n") next = next.replace(/\n/g, "\r\n");
127
+ return bom + next;
128
+ }
@@ -0,0 +1,109 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { createEditToolDefinition } from "@earendil-works/pi-coding-agent";
6
+ import registerCodexEdit from "../extensions/codex-edit.ts";
7
+ import { comparisonCases } from "./comparison-cases.mjs";
8
+
9
+ async function writeSnapshot(root, snapshot) {
10
+ for (const [path, content] of Object.entries(snapshot)) {
11
+ const absolute = join(root, path);
12
+ await mkdir(dirname(absolute), { recursive: true });
13
+ await writeFile(absolute, content, "utf8");
14
+ }
15
+ }
16
+
17
+ async function readSnapshot(root, relative = "") {
18
+ const snapshot = {};
19
+ for (const entry of await readdir(join(root, relative), { withFileTypes: true })) {
20
+ const path = relative ? `${relative}/${entry.name}` : entry.name;
21
+ if (entry.isDirectory()) Object.assign(snapshot, await readSnapshot(root, path));
22
+ else snapshot[path] = await readFile(join(root, path), "utf8");
23
+ }
24
+ return snapshot;
25
+ }
26
+
27
+ async function runNativeCase(testCase) {
28
+ if (testCase.outcomes.native === "unsupported") {
29
+ assert.equal(testCase.nativeCalls, null, `${testCase.id}: unsupported native case must not invent edit calls`);
30
+ return "unsupported";
31
+ }
32
+ const workspace = await mkdtemp(join(tmpdir(), "codex-edit-native-"));
33
+ try {
34
+ await writeSnapshot(workspace, testCase.input);
35
+ const edit = createEditToolDefinition(workspace);
36
+ try {
37
+ for (const [index, call] of testCase.nativeCalls.entries()) {
38
+ await edit.execute(`native-${index}`, call, undefined, undefined, { cwd: workspace });
39
+ }
40
+ if (testCase.outcomes.native !== "pass") {
41
+ assert.fail(`${testCase.id}: native edit unexpectedly succeeded`);
42
+ }
43
+ assert.deepEqual(await readSnapshot(workspace), testCase.expected);
44
+ return "pass";
45
+ } catch (error) {
46
+ if (testCase.outcomes.native === "pass") throw error;
47
+ return "fail";
48
+ }
49
+ } finally {
50
+ await rm(workspace, { recursive: true, force: true });
51
+ }
52
+ }
53
+
54
+ async function getCodexTool() {
55
+ let tool;
56
+ await registerCodexEdit({
57
+ registerTool(definition) { tool = definition; },
58
+ on() {},
59
+ getActiveTools() { return []; },
60
+ });
61
+ assert.ok(tool, "Codex Edit extension did not register apply_patch");
62
+ return tool;
63
+ }
64
+
65
+ async function runCodexCase(tool, testCase) {
66
+ const workspace = await mkdtemp(join(tmpdir(), "codex-edit-local-"));
67
+ try {
68
+ await writeSnapshot(workspace, testCase.input);
69
+ try {
70
+ await tool.execute("codex-local", { patch: testCase.patch }, undefined, undefined, {
71
+ cwd: workspace,
72
+ hasUI: false,
73
+ });
74
+ if (testCase.outcomes.local !== "pass") {
75
+ assert.fail(`${testCase.id}: local Codex Edit unexpectedly succeeded`);
76
+ }
77
+ assert.deepEqual(await readSnapshot(workspace), testCase.expected);
78
+ return "pass";
79
+ } catch (error) {
80
+ if (testCase.outcomes.local === "pass") throw error;
81
+ assert.deepEqual(
82
+ await readSnapshot(workspace),
83
+ testCase.input,
84
+ `${testCase.id}: failed Codex patch must not partially mutate the workspace`,
85
+ );
86
+ return "fail";
87
+ }
88
+ } finally {
89
+ await rm(workspace, { recursive: true, force: true });
90
+ }
91
+ }
92
+
93
+ export default async function () {
94
+ const codexTool = await getCodexTool();
95
+ const results = [];
96
+ for (const testCase of comparisonCases) {
97
+ const native = await runNativeCase(testCase);
98
+ const codex = await runCodexCase(codexTool, testCase);
99
+ results.push({ id: testCase.id, native, codex });
100
+ }
101
+ assert.equal(results.length, comparisonCases.length);
102
+ assert.equal(results.filter(({ native }) => native === "pass").length, 5);
103
+ assert.equal(results.filter(({ native }) => native === "fail").length, 2);
104
+ assert.equal(results.filter(({ native }) => native === "unsupported").length, 5);
105
+ assert.equal(results.filter(({ codex }) => codex === "pass").length, 7);
106
+ assert.equal(results.filter(({ codex }) => codex === "fail").length, 5);
107
+ for (const result of results) console.log(`${result.id}: native=${result.native}, codex=${result.codex}`);
108
+ console.log(`Native edit vs local Codex Edit: ${results.length} comparison cases passed`);
109
+ }
@@ -12,6 +12,8 @@ test("package points Pi at the extension and config is reachable from it", async
12
12
  const source = await readFile(join(packageDir, "extensions/codex-edit.ts"), "utf8");
13
13
  assert.match(source, /new URL\("\.\.\/config\.json", import\.meta\.url\)/);
14
14
  assert.match(source, /model-routing\.ts/);
15
+ assert.match(source, /parameters: Type\.Object\(\{\s*patch: Type\.String/);
16
+ assert.match(source, /applyUpdateGroups\(before, operation\.chunkGroups, operation\.path\)/);
15
17
  const config = JSON.parse(await readFile(join(packageDir, "config.json"), "utf8"));
16
18
  assert.equal(config.enabled, true);
17
19
  assert.deepEqual(config.models, ["gpt-5.6-*"]);
@@ -41,3 +43,9 @@ test("matches allowed model IDs with globs independently of provider and grammar
41
43
  assert.equal(modelIsAllowed({ ...baseModel, id: "GPT-5.6-terra" }, settings), false);
42
44
  assert.equal(modelIsAllowed(baseModel, { enabled: false, models: ["gpt-5.6-*"] }), false);
43
45
  });
46
+
47
+ test("execution keeps a resolved-path conflict guard after parser coalescing", async () => {
48
+ const source = await readFile(join(packageDir, "extensions/codex-edit.ts"), "utf8");
49
+ assert.match(source, /new Set\(targets\)\.size !== targets\.length/);
50
+ assert.match(source, /Multiple patch operations target the same resolved path/);
51
+ });
@@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import test from "node:test";
6
- import { applyUpdate, parsePatch, resolveWorkspacePath } from "../src/parser.ts";
6
+ import { applyUpdate, applyUpdateGroups, parsePatch, resolveWorkspacePath } from "../src/parser.ts";
7
7
 
8
8
  test("parses add, delete, update, and move operations", () => {
9
9
  const operations = parsePatch(`*** Begin Patch
@@ -162,6 +162,128 @@ test("parses multiple file operations used by structural refactors", () => {
162
162
  );
163
163
  });
164
164
 
165
+ test("coalesces repeated updates to one path and preserves operation order", () => {
166
+ const operations = parsePatch(`*** Begin Patch
167
+ *** Update File: src/tool.ts
168
+ @@
169
+ -const value = 1;
170
+ +const value = 2;
171
+ *** Update File: src/tool.ts
172
+ @@
173
+ -const value = 2;
174
+ +const value = 3;
175
+ *** End Patch`);
176
+ assert.equal(operations.length, 1);
177
+ assert.equal(operations[0].path, "src/tool.ts");
178
+ assert.equal(operations[0].chunkGroups.length, 2);
179
+ const after = applyUpdateGroups("const value = 1;\n", operations[0].chunkGroups, "src/tool.ts");
180
+ assert.equal(after, "const value = 3;\n");
181
+ });
182
+
183
+ test("still rejects conflicting repeated targets", () => {
184
+ assert.throws(() => parsePatch(`*** Begin Patch
185
+ *** Update File: src/tool.ts
186
+ @@
187
+ -old
188
+ +new
189
+ *** Delete File: src/tool.ts
190
+ *** End Patch`), /same path/);
191
+ assert.throws(() => parsePatch(`*** Begin Patch
192
+ *** Update File: src/old.ts
193
+ *** Move to: src/new.ts
194
+ @@
195
+ -old
196
+ +new
197
+ *** Add File: src/new.ts
198
+ +replacement
199
+ *** End Patch`), /same path/);
200
+ });
201
+
202
+ test("rejects empty chunks and stale expected lines instead of guessing", () => {
203
+ assert.throws(() => parsePatch(`*** Begin Patch
204
+ *** Update File: src/tool.ts
205
+ @@
206
+ *** End Patch`), /Empty update chunk/);
207
+ const [operation] = parsePatch(`*** Begin Patch
208
+ *** Update File: src/tool.ts
209
+ @@
210
+ -missing line
211
+ +replacement
212
+ *** End Patch`);
213
+ assert.throws(
214
+ () => applyUpdate("actual line\n", operation.chunks, operation.path),
215
+ /Failed to find expected lines in src\/tool\.ts/,
216
+ );
217
+ });
218
+
219
+ test("handles the reported project-scale repeated-file and Markdown shapes", () => {
220
+ const operations = parsePatch(`*** Begin Patch
221
+ *** Update File: skills/pi-worktree/SKILL.md
222
+ @@
223
+ -description: old
224
+ +description: updated
225
+ @@
226
+ ---
227
+ +Release notes follow.
228
+ *** Update File: packages/tiny-subagent/src/tool.ts
229
+ @@
230
+ -const state = "old";
231
+ +const state = "middle";
232
+ *** Update File: packages/tiny-subagent/src/tool.ts
233
+ @@
234
+ -const state = "middle";
235
+ +const state = "new";
236
+ *** Update File: scripts/release-package.sh
237
+ @@
238
+ -ROOT_LEVEL=patch
239
+ +ROOT_LEVEL=minor
240
+ *** Update File: scripts/release-package.sh
241
+ @@
242
+ -ROOT_LEVEL=minor
243
+ +ROOT_LEVEL=patch
244
+ *** End Patch`);
245
+ assert.deepEqual(operations.map((operation) => operation.path), [
246
+ "skills/pi-worktree/SKILL.md",
247
+ "packages/tiny-subagent/src/tool.ts",
248
+ "scripts/release-package.sh",
249
+ ]);
250
+ assert.equal(operations[1].chunkGroups.length, 2);
251
+ assert.equal(operations[2].chunkGroups.length, 2);
252
+
253
+ const skill = `---
254
+ name: pi-worktree
255
+ description: old
256
+ ---
257
+
258
+ # pi-worktree
259
+
260
+ ---
261
+ Footer
262
+ `;
263
+ assert.equal(
264
+ applyUpdateGroups(skill, operations[0].chunkGroups, operations[0].path),
265
+ `---
266
+ name: pi-worktree
267
+ description: updated
268
+ ---
269
+ Release notes follow.
270
+
271
+ # pi-worktree
272
+
273
+ ---
274
+ Footer
275
+ `,
276
+ );
277
+ assert.equal(
278
+ applyUpdateGroups('const state = "old";\n', operations[1].chunkGroups, operations[1].path),
279
+ 'const state = "new";\n',
280
+ );
281
+ assert.equal(
282
+ applyUpdateGroups("ROOT_LEVEL=patch\n", operations[2].chunkGroups, operations[2].path),
283
+ "ROOT_LEVEL=patch\n",
284
+ );
285
+ });
286
+
165
287
  test("rejects workspace escapes", () => {
166
288
  assert.equal(resolveWorkspacePath("/tmp/project", "/tmp/project/src/a.js"), "/tmp/project/src/a.js");
167
289
  assert.throws(() => resolveWorkspacePath("/tmp/project", "/tmp/other/a.js"), /escapes/);
@@ -0,0 +1,31 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { applyUpdate, parsePatch } from "../src/parser.ts";
4
+ import { comparisonCases } from "./comparison-cases.mjs";
5
+ import { runPatchCase } from "./comparison-harness.mjs";
6
+ import { applyLegacyUpdate, parseLegacyPatch } from "./legacy-parser-v0.1.7.mjs";
7
+
8
+ test("released v0.1.7 versus local parser over the growing comparison corpus", () => {
9
+ const results = [];
10
+ for (const testCase of comparisonCases) {
11
+ const released = runPatchCase(
12
+ testCase,
13
+ parseLegacyPatch,
14
+ applyLegacyUpdate,
15
+ testCase.outcomes.released,
16
+ );
17
+ const local = runPatchCase(testCase, parsePatch, applyUpdate, testCase.outcomes.local);
18
+ results.push({ id: testCase.id, released, local });
19
+ }
20
+
21
+ assert.equal(results.length, comparisonCases.length);
22
+ assert.deepEqual(
23
+ results.find(({ id }) => id === "repeated-update-same-path"),
24
+ { id: "repeated-update-same-path", released: "fail", local: "pass" },
25
+ );
26
+ assert.equal(results.filter(({ released }) => released === "pass").length, 6);
27
+ assert.equal(results.filter(({ released }) => released === "fail").length, 6);
28
+ assert.equal(results.filter(({ local }) => local === "pass").length, 7);
29
+ assert.equal(results.filter(({ local }) => local === "fail").length, 5);
30
+ for (const result of results) console.log(`${result.id}: released=${result.released}, local=${result.local}`);
31
+ });