From 439db1f2fdf960be3922619d73413adea61f9a4b Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 22 Jul 2026 17:10:45 -0400 Subject: [PATCH] feat: line-oriented insert Quick Actions and npm audit hardening Add replace --insert-before / --insert-after Quick Actions and batch template example for CLI 0.16 line-oriented inserts. Clear four high severity transitive audit findings (brace-expansion, fast-uri, js-yaml, linkify-it). Refresh README Quick Action / batch docs and AGENTS test counts. Signed-off-by: Sebastien Tardif --- AGENTS.md | 6 +-- README.md | 4 +- package-lock.json | 30 +++++------ src/commands/batchApply.ts | 1 + src/commands/quickActions.ts | 94 ++++++++++++++++++++++++++++++++++ test/unit/batchApply.test.ts | 26 +++++++--- test/unit/patchloomCli.test.ts | 21 ++++++++ test/unit/quickActions.test.ts | 32 ++++++++++++ 8 files changed, 188 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 529cb55..450e04e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,16 +47,16 @@ src/ workspace/readiness.ts Workspace readiness: environment detection, folder selection test/ unit/ Unit tests (node:test, dependency-injected, no VS Code API) - batchApply.test.ts Batch template and operation count parsing (14 tests) + batchApply.test.ts Batch template and operation count parsing (15 tests) binary.test.ts Binary discovery, managed install, compatibility, workspace env (59 tests) binaryDiscovery.test.ts Real executable discovery on PATH (13 tests) initializeProject.test.ts Status display, agents file classification, formatError (26 tests) managedLifecycle.test.ts Managed install with real file I/O (22 tests) mcpConfig.test.ts MCP config with real temp directories (9 tests) outputChannel.test.ts Output channel logging wrapper (10 tests) - patchloomCli.test.ts Patchloom CLI integration with real binary + managed install e2e MCP (36 tests incl. e2e) + patchloomCli.test.ts Patchloom CLI integration with real binary + managed install e2e MCP (37 tests incl. e2e) propertyBased.test.ts Property-based tests with fast-check (13 tests) - quickActions.test.ts Quick action command building, path containment, patch merge (54 tests) + quickActions.test.ts Quick action command building, path containment, patch merge (57 tests) verifyMcp.test.ts MCP server verify and JSON-RPC response parsing (15 tests) downloadIntegration.test.ts HTTP download, redirect, streaming SHA-256 (9 tests) suite/ diff --git a/README.md b/README.md index f5b70e5..2feada7 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ Click it to see full diagnostics, including per-editor MCP configuration status | Action | What it does | |--------|-------------| | **Replace text** | Literal text replacement with diff preview before applying | +| **Insert text after match** | Line-oriented insert after each match (CLI 0.16+) | +| **Insert text before match** | Line-oriented insert before each match (CLI 0.16+) | | **Tidy file** | Whitespace and newline cleanup with diff preview | | **Set structured value** | Update a JSON, YAML, or TOML key with diff preview | | **Search text** | Find pattern matches across workspace files (results in output channel) | @@ -110,7 +112,7 @@ Workspace Quick Actions and Batch Apply pass `--contain` so CLI paths stay insid ### Batch operations -`Patchloom: Batch Apply` opens a line-oriented plan template where you can compose multiple operations (replace, tidy, doc set). The extension pipes the plan to `patchloom batch --apply` so all changes land atomically. +`Patchloom: Batch Apply` opens a line-oriented plan template where you can compose multiple operations (replace, fuzzy replace, doc set, multi-doc `doc.merge`, file append, markdown section inserts, tidy). The extension pipes the plan to `patchloom batch --apply` so all changes land atomically. ### Output channel diff --git a/package-lock.json b/package-lock.json index cd3efd0..705c415 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1833,9 +1833,9 @@ "license": "BSD-2-Clause" }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -3027,9 +3027,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { @@ -4076,9 +4076,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -4284,9 +4284,9 @@ } }, "node_modules/linkify-it": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", - "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "dev": true, "funding": [ { @@ -4761,9 +4761,9 @@ } }, "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "peer": true, diff --git a/src/commands/batchApply.ts b/src/commands/batchApply.ts index fd5f9bf..3bc8e1f 100644 --- a/src/commands/batchApply.ts +++ b/src/commands/batchApply.ts @@ -8,6 +8,7 @@ import { activeWorkspaceFolder } from "../workspace/readiness.js"; export const BATCH_TEMPLATE = [ "replace src/example.ts \"old text\" \"new text\"", "replace src/example.ts \"typo_here\" \"fixed\" --fuzzy --min-fuzzy-score 0.80", + "replace src/example.ts \"anchor_line\" --insert-after=\"new sibling line\"", "doc.set package.json version \"2.0.0\"", "doc.merge multi-doc.yaml 0 \"{\\\"debug\\\": true}\"", "file.append src/example.ts \"new appended line\"", diff --git a/src/commands/quickActions.ts b/src/commands/quickActions.ts index 0d674bb..851092a 100644 --- a/src/commands/quickActions.ts +++ b/src/commands/quickActions.ts @@ -73,6 +73,74 @@ export async function runQuickAction(): Promise { await previewAndMaybeApply(binaryPath, target, buildReplaceQuickAction(target.absolutePath, from, to)); } }, + { + label: "Insert text after match", + description: "Line-oriented insert after each match (CLI 0.16+)", + detail: "Builds `patchloom replace --insert-after `", + run: async () => { + const target = await pickWorkspaceFileTarget("Select a file for Patchloom insert-after"); + if (!target) { + return; + } + + const pattern = await vscode.window.showInputBox({ + prompt: "Text to match (anchor for the insert)", + placeHolder: "existing_line_or_token", + validateInput: (value) => value.length > 0 ? undefined : "Match text is required." + }); + if (pattern === undefined) { + return; + } + + const content = await vscode.window.showInputBox({ + prompt: "Text to insert after each match", + placeHolder: "new_line_or_token" + }); + if (content === undefined) { + return; + } + + await previewAndMaybeApply( + binaryPath, + target, + buildInsertAfterMatchQuickAction(target.absolutePath, pattern, content) + ); + } + }, + { + label: "Insert text before match", + description: "Line-oriented insert before each match (CLI 0.16+)", + detail: "Builds `patchloom replace --insert-before `", + run: async () => { + const target = await pickWorkspaceFileTarget("Select a file for Patchloom insert-before"); + if (!target) { + return; + } + + const pattern = await vscode.window.showInputBox({ + prompt: "Text to match (anchor for the insert)", + placeHolder: "existing_line_or_token", + validateInput: (value) => value.length > 0 ? undefined : "Match text is required." + }); + if (pattern === undefined) { + return; + } + + const content = await vscode.window.showInputBox({ + prompt: "Text to insert before each match", + placeHolder: "new_line_or_token" + }); + if (content === undefined) { + return; + } + + await previewAndMaybeApply( + binaryPath, + target, + buildInsertBeforeMatchQuickAction(target.absolutePath, pattern, content) + ); + } + }, { label: "Tidy file", description: "Whitespace and newline cleanup with diff preview", @@ -895,6 +963,32 @@ export function buildReplaceQuickAction(targetPath: string, from: string, to: st }; } +export function buildInsertAfterMatchQuickAction( + targetPath: string, + pattern: string, + content: string +): PlannedQuickAction { + return { + title: `Insert after match in ${path.basename(targetPath)}`, + targetPath, + targetArgIndices: [4], + args: ["replace", pattern, "--insert-after", content, targetPath] + }; +} + +export function buildInsertBeforeMatchQuickAction( + targetPath: string, + pattern: string, + content: string +): PlannedQuickAction { + return { + title: `Insert before match in ${path.basename(targetPath)}`, + targetPath, + targetArgIndices: [4], + args: ["replace", pattern, "--insert-before", content, targetPath] + }; +} + export function buildTidyQuickAction(targetPath: string, fixes: readonly TidyFix[]): PlannedQuickAction { const args = ["tidy", "fix", targetPath]; if (fixes.includes("ensure-final-newline")) { diff --git a/test/unit/batchApply.test.ts b/test/unit/batchApply.test.ts index f3c2744..ad68cb0 100644 --- a/test/unit/batchApply.test.ts +++ b/test/unit/batchApply.test.ts @@ -6,18 +6,19 @@ import { parseBatchOperationCount } from "../../src/commands/batchApply.js"; -test("buildBatchTemplate returns line-oriented format with seven operations", () => { +test("buildBatchTemplate returns line-oriented format with eight operations", () => { const template = buildBatchTemplate(); const lines = template.split("\n").filter((line) => line.trim().length > 0); - assert.equal(lines.length, 7); + assert.equal(lines.length, 8); assert.ok(lines[0].startsWith("replace "), "first line should be a replace operation"); assert.ok(lines[1].startsWith("replace ") && lines[1].includes("--fuzzy"), "second line should be fuzzy replace"); - assert.ok(lines[2].startsWith("doc.set "), "third line should be a doc.set operation"); - assert.ok(lines[3].startsWith("doc.merge "), "fourth line should be multi-doc doc.merge"); - assert.ok(lines[4].startsWith("file.append "), "fifth line should be a file.append operation"); - assert.ok(lines[5].startsWith("md.insert_after_section "), "sixth line should be md.insert_after_section"); - assert.ok(lines[6].startsWith("tidy.fix "), "seventh line should be a tidy.fix operation"); + assert.ok(lines[2].startsWith("replace ") && lines[2].includes("--insert-after"), "third line should be insert-after"); + assert.ok(lines[3].startsWith("doc.set "), "fourth line should be a doc.set operation"); + assert.ok(lines[4].startsWith("doc.merge "), "fifth line should be multi-doc doc.merge"); + assert.ok(lines[5].startsWith("file.append "), "sixth line should be a file.append operation"); + assert.ok(lines[6].startsWith("md.insert_after_section "), "seventh line should be md.insert_after_section"); + assert.ok(lines[7].startsWith("tidy.fix "), "eighth line should be a tidy.fix operation"); }); test("buildBatchTemplate ends with a newline", () => { @@ -107,6 +108,17 @@ test("buildBatchTemplate doc.merge line uses path selector value (CLI 0.16 multi assert.match(mergeLine, /\s0\s/, "example should merge into document 0"); }); +test("buildBatchTemplate includes replace --insert-after example (CLI 0.16)", () => { + const lines = buildBatchTemplate().split("\n"); + const insertLine = lines.find((l) => l.includes("--insert-after")); + assert.ok(insertLine, "template should contain an insert-after example"); + assert.match( + insertLine, + /replace \S+ ".+" --insert-after=/, + "insert-after should use batch flag form path pattern --insert-after=payload" + ); +}); + test("buildBatchApplyArgs prefixes global --contain before batch --apply", () => { assert.deepEqual(buildBatchApplyArgs(), ["--contain", "batch", "--apply"]); }); diff --git a/test/unit/patchloomCli.test.ts b/test/unit/patchloomCli.test.ts index e93ca2f..fd06030 100644 --- a/test/unit/patchloomCli.test.ts +++ b/test/unit/patchloomCli.test.ts @@ -26,6 +26,7 @@ import { classifyAgentsFile } from "../../src/commands/initializeProject.js"; import { buildStatusDetails, preferredStatusAction } from "../../src/commands/showStatus.js"; import { buildDocMergeQuickAction, + buildInsertAfterMatchQuickAction, buildReplaceQuickAction, retargetQuickAction, withApplyFlag @@ -539,6 +540,26 @@ describe("patchloom CLI integration", async () => { }); }); + test("replace --insert-after via Quick Action args is line-oriented (CLI 0.16+)", async (t) => { + const { stdout, stderr } = await execFileAsync(binaryPath, ["--version"], { timeout: 5000 }); + const version = parsePatchloomVersion(`${stdout}${stderr}`); + if (!version || comparePatchloomVersions(version, "0.16.0") < 0) { + t.skip(`requires patchloom >= 0.16.0 (found ${version ?? "unknown"})`); + return; + } + + await withTempDir(async (dir) => { + const file = path.join(dir, "notes.txt"); + await fs.writeFile(file, "alpha\nbeta\n", "utf8"); + + const action = buildInsertAfterMatchQuickAction(file, "beta", "gamma"); + await execFileAsync(binaryPath, withApplyFlag(action.args), { timeout: 5000 }); + + const content = await fs.readFile(file, "utf8"); + assert.equal(content, "alpha\nbeta\ngamma\n"); + }); + }); + test("doc append adds an item to a JSON array", async () => { await withTempDir(async (dir) => { const file = path.join(dir, "config.json"); diff --git a/test/unit/quickActions.test.ts b/test/unit/quickActions.test.ts index dbbb4fb..91d51a5 100644 --- a/test/unit/quickActions.test.ts +++ b/test/unit/quickActions.test.ts @@ -3,6 +3,8 @@ import test from "node:test"; import { buildAppendQuickAction, buildCreateQuickAction, + buildInsertAfterMatchQuickAction, + buildInsertBeforeMatchQuickAction, buildPrependQuickAction, buildDocAppendQuickAction, buildDocDeleteQuickAction, @@ -39,6 +41,36 @@ test("buildReplaceQuickAction builds a replace command for one file", () => { assert.deepEqual(action.args, ["replace", "old", "--new", "new", "/workspace/demo/README.md"]); }); +test("buildInsertAfterMatchQuickAction builds replace --insert-after (CLI 0.16+)", () => { + const action = buildInsertAfterMatchQuickAction("/workspace/demo/app.ts", "const x = 1;", "const y = 2;"); + + assert.equal(action.title, "Insert after match in app.ts"); + assert.deepEqual(action.targetArgIndices, [4]); + assert.deepEqual(action.args, [ + "replace", "const x = 1;", "--insert-after", "const y = 2;", "/workspace/demo/app.ts" + ]); +}); + +test("buildInsertBeforeMatchQuickAction builds replace --insert-before (CLI 0.16+)", () => { + const action = buildInsertBeforeMatchQuickAction("/workspace/demo/app.ts", "return true;", "// checked"); + + assert.equal(action.title, "Insert before match in app.ts"); + assert.deepEqual(action.targetArgIndices, [4]); + assert.deepEqual(action.args, [ + "replace", "return true;", "--insert-before", "// checked", "/workspace/demo/app.ts" + ]); +}); + +test("retargetQuickAction preserves insert-after args", () => { + const action = buildInsertAfterMatchQuickAction("/workspace/demo/a.ts", "foo", "bar"); + const retargeted = retargetQuickAction(action, "/workspace/demo/b.ts"); + + assert.equal(retargeted.targetPath, "/workspace/demo/b.ts"); + assert.deepEqual(retargeted.args, [ + "replace", "foo", "--insert-after", "bar", "/workspace/demo/b.ts" + ]); +}); + test("buildTidyQuickAction includes selected tidy flags", () => { const action = buildTidyQuickAction("/workspace/demo/file.txt", [ "ensure-final-newline",