From f11984e260deabc820c5a40acfff278c8a215376 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Fri, 18 Sep 2026 08:20:20 -0700 Subject: [PATCH] feat: align extension with patchloom CLI 0.35.0 Recommend 0.35.0, accept JSONC and key-value docs in Quick Actions, and document directory rename, if-exists, and the 64-tool MCP inventory. Signed-off-by: Sebastien Tardif --- AGENTS.md | 8 ++-- README.md | 37 ++++++++++----- src/commands/batchApply.ts | 2 + src/commands/configureMcp.ts | 2 +- src/commands/quickActions.ts | 43 ++++++++++++----- test/unit/batchApply.test.ts | 28 +++++++---- test/unit/initializeProject.test.ts | 13 +++++ test/unit/patchloomCli.test.ts | 73 +++++++++++++++++++++++++++++ test/unit/quickActions.test.ts | 21 ++++++++- 9 files changed, 189 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0c59b34..0f9e4a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,19 +50,19 @@ 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 (20 tests) + batchApply.test.ts Batch template and operation count parsing (21 tests) binary.test.ts Binary discovery, managed install, compatibility, workspace env (76 tests) binaryDiscovery.test.ts Real executable discovery on PATH (13 tests) - initializeProject.test.ts Status display, agents file classification, formatError (70 tests) + initializeProject.test.ts Status display, agents file classification, formatError (71 tests) managedLifecycle.test.ts Managed install with real file I/O (26 tests) mcpConfig.test.ts MCP config with real temp directories (16 tests) managedInstall.test.ts Managed Update compares latest vs managed binary (10 tests) mcpRegister.test.ts Native MCP definition helper for binary path (6 tests) statusRefresh.test.ts Status and MCP refresh order after input change (1 test) outputChannel.test.ts Output channel logging wrapper (22 tests) - patchloomCli.test.ts Patchloom CLI integration with real binary + managed install e2e MCP (51 tests incl. e2e) + patchloomCli.test.ts Patchloom CLI integration with real binary + managed install e2e MCP (53 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 (94 tests) + quickActions.test.ts Quick action command building, path containment, patch merge (95 tests) verifyMcp.test.ts MCP server verify and JSON-RPC response parsing (15 tests) downloadIntegration.test.ts HTTP download, redirect, streaming SHA-256 (12 tests) suite/ diff --git a/README.md b/README.md index 045f22e..a0a7590 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Run `Patchloom: Setup Workspace` to walk through everything your project needs: When configuring, pick **Full tool inventory** (default) or **Core pack**. Core sets `PATCHLOOM_MCP_SURFACE=core` on the server entry. Existing servers in JSON or JSONC (`//` comments, trailing commas) stay in the file. A config that is not an object is left unchanged and the command reports an error. -CLI **0.34.0** (and 0.24+) exposes **58** MCP tools by default (including `list_files` and `apply_fragment`). The core pack is 11 tools: `read_file`, `search_files`, `list_files`, `replace_text`, `batch_replace`, `doc_get`, `doc_set`, `doc_query`, `md_replace_section`, `execute_plan`, `server_info`. `search_files` accepts `files_without_match` (CLI 0.29+). `apply_patch` accepts unified diffs, Codex `*** Begin Patch`, and Aider SEARCH/REPLACE (CLI 0.30+). `doc_query` can list object keys and count array or object length (CLI 0.32+). Search, replace, and tidy dests accept cwd-only globs such as `*.txt` (CLI 0.33+; use `**/*.txt` or `--glob` for nested files). Absolute paths that resolve inside the MCP workspace root are allowed; empty paths, `../`, and outside paths still reject with stable `error_kind` peels. +CLI **0.35.0** exposes **64** MCP tools by default (including `explain_plan`, `tidy_check`, `list_files`, and `apply_fragment`). The core pack is still 11 tools: `read_file`, `search_files`, `list_files`, `replace_text`, `batch_replace`, `doc_get`, `doc_set`, `doc_query`, `md_replace_section`, `execute_plan`, `server_info`. `doc` accepts `.jsonc` (comments stay on set), plus `.env`, `.ini`, and `.properties`. `doc_set` accepts `if_exists` to skip a missing file or selector. `apply_patch` accepts `apply=false` for a check-only preview. CLI `list-files` inventories ignore-aware paths the same way as MCP `list_files`. `search_files` accepts `files_without_match` (CLI 0.29+). `apply_patch` accepts unified diffs, Codex `*** Begin Patch`, and Aider SEARCH/REPLACE (CLI 0.30+). `doc_query` can list object keys and count array or object length (CLI 0.32+). Search, replace, and tidy dests accept cwd-only globs such as `*.txt` (CLI 0.33+; use `**/*.txt` or `--glob` for nested files). Absolute paths that resolve inside the MCP workspace root are allowed; empty paths, `../`, and outside paths still reject with stable `error_kind` peels. ### Status bar @@ -92,23 +92,23 @@ Click it to see full diagnostics, including per-editor MCP configuration status | **Insert text before match** | Line-oriented insert before each match (CLI 0.16+) | | **Apply fragment at anchor** | Morph-style freeform fragment at a unique anchor (`--after` / `--before` / `--old`, CLI 0.22+) | | **Tidy file** | Whitespace and newline cleanup with diff preview | -| **Set structured value** | Update a JSON, YAML, or TOML key with diff preview | -| **Update matching structured values** | Update all JSON, YAML, or TOML nodes matching a wildcard or predicate (`doc update`, CLI 0.27+) | +| **Set structured value** | Update a JSON, JSONC, YAML, TOML, .env, INI, or .properties key with diff preview | +| **Update matching structured values** | Update matching nodes in those formats (`doc update`, CLI 0.27+) | | **Search text** | Find pattern matches across workspace files (results in output channel) | | **Search files without match** | List files that do not contain the pattern (`search -L`, CLI 0.29+) | | **Create file** | Scaffold a new file with optional content and open it in the editor | | **Append to file** | Append content to an existing file | | **Prepend to file** | Prepend content to the start of an existing file (CLI 0.9+) | -| **Read structured value** | Read a JSON/YAML/TOML key and copy to clipboard | +| **Read structured value** | Read a JSON, JSONC, YAML, TOML, .env, INI, or .properties key and copy to clipboard | | **List structured keys** | List object keys at a selector (`doc keys`, CLI 0.32+; `.` is the document root) | | **Count structured length** | Count array items or object keys (`doc len`, CLI 0.32+) | -| **Delete structured value** | Remove a key from JSON, YAML, or TOML with diff preview | +| **Delete structured value** | Remove a key from JSON, JSONC, YAML, TOML, .env, INI, or .properties with diff preview | | **Delete matching array items** | Remove array items matching a predicate (`doc delete-where`, CLI 0.27+; array path plus `key=value` predicate) | | **Merge into structured file** | Merge a partial JSON object into a config file (optional multi-doc selector, CLI 0.16+) | -| **Append to array** | Append a value to a JSON, YAML, or TOML array | -| **Prepend to array** | Prepend a value to a JSON, YAML, or TOML array | +| **Append to array** | Append a value to a JSON, JSONC, YAML, TOML, .env, INI, or .properties array | +| **Prepend to array** | Prepend a value to a JSON, JSONC, YAML, TOML, .env, INI, or .properties array | | **Ensure structured value** | Idempotent set: write only if the key is missing | -| **Move/rename key** | Move or rename a selector path in JSON, YAML, or TOML | +| **Move/rename key** | Move or rename a selector path in those formats | | **Insert after heading** | Insert content immediately after a markdown heading line | | **Insert after section** | Insert a sibling markdown section after a full section body (CLI 0.14+) | | **Insert before heading** | Insert content immediately before a markdown heading line | @@ -123,7 +123,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, fuzzy replace, `doc.set`, multi-match `doc.update`, `doc.delete_where`, multi-doc `doc.merge`, file append, markdown section inserts, tidy). 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`, JSONC `doc.set`, multi-match `doc.update`, `doc.delete_where`, multi-doc `doc.merge`, file append, directory `file.rename`, markdown section inserts, tidy). The extension pipes the plan to `patchloom batch --apply` so all changes land atomically. ### Output channel @@ -181,7 +181,7 @@ The extension detects outdated CLI builds and warns with upgrade guidance. It re Set `patchloom.path` in settings, or add the CLI to your `PATH`. **CLI compatibility warning / upgrade path** -The extension requires Patchloom **0.3.0** or newer; **0.34.0** is recommended. Which fix to use depends on how the CLI was resolved (status shows Source): +The extension requires Patchloom **0.3.0** or newer; **0.35.0** is recommended. Which fix to use depends on how the CLI was resolved (status shows Source): 1. **Source: managed install** → **Patchloom: Update Patchloom** (checksum-verified GitHub release into extension storage) 2. **Source: PATH** → upgrade that install in place (**Scoop** `scoop update patchloom` on Windows; Homebrew / npm / cargo / the official installer elsewhere). Managed Install will not override a PATH binary. @@ -220,6 +220,21 @@ On CLI 0.34+, a bad dest glob or `--glob` is `error_kind: invalid_input` (not a **AST parse timed out** On CLI 0.34+, an AST parse that runs longer than five seconds reports `error_kind: parse_timeout` and exit 4. Narrow the file or pick an explicit language. Unknown explicit language is `invalid_input`. +**JSONC and key-value documents** +On CLI 0.35+, Quick Actions accept `.jsonc` (comments stay on `doc set`), plus `.env` / `.env.*`, `.ini`, and `.properties`. Use `doc --as` when the extension is not enough. `.properties` keys are flat (a name like `app.name` is one key, not nested `app` then `name`); run **List structured keys** to see the literal names. + +**Directory rename and undo** +On CLI 0.35+, `rename` moves a real directory and undo puts it back. Dry-run undo reports the rename-back without writing. Batch plans use `file.rename FROM TO`. + +**Doc set if the key already exists** +On CLI 0.35+, `doc set --if-exists` (MCP `doc_set.if_exists`) succeeds and writes nothing when the file or selector is missing. **Ensure structured value** is the inverse: write only when the key is absent. + +**Undo one path from a session** +On CLI 0.35+, `undo --path` restores only those session paths. An unknown path is `error_kind: no_matches` and does not restore the rest of the session. + +**HTTP MCP Host allowlist** +The extension's MCP server is stdio. If you bind Streamable HTTP yourself (`patchloom mcp-server --http`), non-loopback binds keep the Host allowlist. Add extra names with `--allowed-host`. Loopback still starts without `--allow-unauthenticated`. + **Patch apply formats** On CLI 0.30+, `patch apply` (and MCP `apply_patch`) accepts unified diffs, Codex `*** Begin Patch`, and Aider SEARCH/REPLACE. Update and SEARCH matches must be unique unless you pass `--replace-all` (SEARCH/REPLACE only). The Quick Action **Apply patch (unified / Begin Patch / SEARCH-REPLACE)** builds `patch apply`. **Merge patch (three-way)** is still `patch merge` for stale unified diffs. @@ -277,7 +292,7 @@ File bugs and feature requests at [patchloom/patchloom-vscode/issues](https://gi ## Requirements - VS Code 1.90 or newer (or compatible editors: Cursor, Windsurf, VSCodium) -- [Patchloom CLI](https://github.com/patchloom/patchloom) 0.3.0 or newer (**0.34.0+ recommended** for empty or whitespace-only edit payloads as `invalid_input`, AST `parse_timeout` (exit 4), dest/`--glob` mistakes as `invalid_input` (not parse errors), dest globs on search/replace/tidy (`*.txt` is cwd-only), `doc keys` / `doc len`, YAML merge-key edits that keep `<<:` and comments, Windows dest/`--cwd` rules, charset / EditorConfig `charset`, YAML alias-to-merge on `doc set`, create/rename `parent path is not a directory`, numeric selector compares (`servers[port>8000]`), `search -L` / `files_without_match`, Codex Begin Patch and Aider SEARCH/REPLACE on `patch apply`, `agent-rules --surface core` honoring `--mode`, empty-path fail-closed (`path must not be empty`), `suggested_op` on fail-closed doc navigation, `not_regular_file` soft peels, ambiguous markdown headings, `list_files` MCP inventory, `apply-fragment`, full `error_kind` peels (`binary` / `invalid_encoding` / `fuzzy_span_suspicious` / `already_exists` / `guard_rejected` / `ambiguous` / `parse_timeout`), optional `PATCHLOOM_MCP_SURFACE=core` 11-tool pack, multi-doc `doc merge --selector`, line-oriented inserts, batch `replace PATH OLD NEW` hints, 58 MCP tools, and agent-facing JSON envelopes) +- [Patchloom CLI](https://github.com/patchloom/patchloom) 0.3.0 or newer (**0.35.0+ recommended** for JSONC / `.env` / INI / `.properties` `doc` edits, directory `rename` with undo, `doc set --if-exists`, CLI `list-files`, MCP `explain_plan` / `tidy_check` / `apply_patch` `apply=false`, HTTP MCP Host allowlist, 64 MCP tools, empty or whitespace-only edit payloads as `invalid_input`, AST `parse_timeout` (exit 4), dest/`--glob` mistakes as `invalid_input`, dest globs on search/replace/tidy (`*.txt` is cwd-only), `doc keys` / `doc len`, YAML merge-key edits that keep `<<:` and comments, Windows dest/`--cwd` rules, charset / EditorConfig `charset`, YAML alias-to-merge on `doc set`, create/rename `parent path is not a directory`, numeric selector compares (`servers[port>8000]`), `search -L` / `files_without_match`, Codex Begin Patch and Aider SEARCH/REPLACE on `patch apply`, `agent-rules --surface core` honoring `--mode`, empty-path fail-closed (`path must not be empty`), `suggested_op` on fail-closed doc navigation, `not_regular_file` soft peels, ambiguous markdown headings, `list_files` MCP inventory, `apply-fragment`, full `error_kind` peels (`binary` / `invalid_encoding` / `fuzzy_span_suspicious` / `already_exists` / `guard_rejected` / `ambiguous` / `parse_timeout` / `no_matches`), optional `PATCHLOOM_MCP_SURFACE=core` 11-tool pack, multi-doc `doc merge --selector`, line-oriented inserts, batch `replace PATH OLD NEW` hints, and agent-facing JSON envelopes) ## Contributing diff --git a/src/commands/batchApply.ts b/src/commands/batchApply.ts index da87cde..80bbe9b 100644 --- a/src/commands/batchApply.ts +++ b/src/commands/batchApply.ts @@ -20,10 +20,12 @@ export const BATCH_TEMPLATE = [ "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.set tsconfig.jsonc compilerOptions.strict true", "doc.update data.json \"items[*].enabled\" true", "doc.delete_where data.json items name=stale", "doc.merge multi-doc.yaml 0 \"{\\\"debug\\\": true}\"", "file.append src/example.ts \"new appended line\"", + "file.rename src/old_pkg src/new_pkg", "md.insert_after_section README.md \"## Config\" \"## FAQ\"", "tidy.fix src/example.ts", "" diff --git a/src/commands/configureMcp.ts b/src/commands/configureMcp.ts index 043a0d1..597b060 100644 --- a/src/commands/configureMcp.ts +++ b/src/commands/configureMcp.ts @@ -48,7 +48,7 @@ export async function configureMcp(): Promise { [ { label: "Full tool inventory", - description: "Default (58 tools on CLI 0.24+; verified on 0.34)", + description: "Default (64 tools on CLI 0.35+; verified on 0.35)", surface: "full" as const }, { diff --git a/src/commands/quickActions.ts b/src/commands/quickActions.ts index 0e93f4f..78c9333 100644 --- a/src/commands/quickActions.ts +++ b/src/commands/quickActions.ts @@ -18,8 +18,19 @@ import { formatCliOutput, formatError, formatQuickActionCliOutput, mergePatchloo import { activeWorkspaceFolder, describeWorkspaceEnvironment } from "../workspace/readiness.js"; const execFileAsync = promisify(execFile); -const STRUCTURED_FILE_EXTENSIONS = new Set([".json", ".yaml", ".yml", ".toml"]); +const STRUCTURED_FILE_EXTENSIONS = new Set([ + ".json", + ".jsonc", + ".yaml", + ".yml", + ".toml", + ".ini", + ".properties" +]); const MARKDOWN_FILE_EXTENSIONS = new Set([".md", ".markdown", ".mdx"]); +/** Human label for picker copy. `.env` / `.env.*` are basename matches, not extensions. */ +export const STRUCTURED_DOCUMENT_LABEL = + "JSON, JSONC, YAML, TOML, .env, INI, or .properties"; export type TidyFix = "ensure-final-newline" | "trim-trailing-whitespace" | "normalize-eol-lf"; @@ -340,7 +351,7 @@ export async function runQuickAction(): Promise { }, { label: "Set structured value", - description: "Update JSON, YAML, or TOML with diff preview", + description: `Update ${STRUCTURED_DOCUMENT_LABEL} with diff preview`, detail: "Builds `patchloom doc set `", run: async () => { const target = await pickStructuredDocumentTarget("doc set"); @@ -372,7 +383,7 @@ export async function runQuickAction(): Promise { }, { label: "Update matching structured values", - description: "Update all JSON, YAML, or TOML nodes matching a wildcard or predicate", + description: `Update all ${STRUCTURED_DOCUMENT_LABEL} nodes matching a wildcard or predicate`, detail: "Builds `patchloom doc update `", run: async () => { const target = await pickStructuredDocumentTarget("doc update"); @@ -600,7 +611,7 @@ export async function runQuickAction(): Promise { }, { label: "Read structured value", - description: "Read a value from JSON, YAML, or TOML", + description: `Read a value from ${STRUCTURED_DOCUMENT_LABEL}`, detail: "Builds `patchloom doc get `", run: async () => { const target = await pickStructuredDocumentTarget("doc get"); @@ -632,7 +643,7 @@ export async function runQuickAction(): Promise { }, { label: "List structured keys", - description: "List object keys at a JSON, YAML, or TOML selector", + description: `List object keys at a ${STRUCTURED_DOCUMENT_LABEL} selector`, detail: "Builds `patchloom doc keys ` (CLI 0.32+). Use `.` for the document root.", run: async () => { const target = await pickStructuredDocumentTarget("doc keys"); @@ -699,7 +710,7 @@ export async function runQuickAction(): Promise { }, { label: "Delete structured value", - description: "Remove a key from JSON, YAML, or TOML with diff preview", + description: `Remove a key from ${STRUCTURED_DOCUMENT_LABEL} with diff preview`, detail: "Builds `patchloom doc delete `", run: async () => { const target = await pickStructuredDocumentTarget("doc delete"); @@ -790,7 +801,7 @@ export async function runQuickAction(): Promise { }, { label: "Append to array", - description: "Append a value to a JSON, YAML, or TOML array", + description: `Append a value to a ${STRUCTURED_DOCUMENT_LABEL} array`, detail: "Builds `patchloom doc append `", run: async () => { const target = await pickStructuredDocumentTarget("doc append"); @@ -821,7 +832,7 @@ export async function runQuickAction(): Promise { }, { label: "Prepend to array", - description: "Prepend a value to a JSON, YAML, or TOML array", + description: `Prepend a value to a ${STRUCTURED_DOCUMENT_LABEL} array`, detail: "Builds `patchloom doc prepend `", run: async () => { const target = await pickStructuredDocumentTarget("doc prepend"); @@ -883,7 +894,7 @@ export async function runQuickAction(): Promise { }, { label: "Move/rename key", - description: "Move or rename a selector path in JSON, YAML, or TOML", + description: `Move or rename a selector path in ${STRUCTURED_DOCUMENT_LABEL}`, detail: "Builds `patchloom doc move `", run: async () => { const target = await pickStructuredDocumentTarget("doc move"); @@ -1676,8 +1687,16 @@ export function isMarkdownPath(filePath: string): boolean { return MARKDOWN_FILE_EXTENSIONS.has(path.extname(filePath).toLowerCase()); } +export function isEnvDocumentPath(filePath: string): boolean { + const base = path.basename(filePath); + return base === ".env" || base.startsWith(".env."); +} + export function isStructuredDocumentPath(filePath: string): boolean { - return STRUCTURED_FILE_EXTENSIONS.has(path.extname(filePath).toLowerCase()); + if (STRUCTURED_FILE_EXTENSIONS.has(path.extname(filePath).toLowerCase())) { + return true; + } + return isEnvDocumentPath(filePath); } export function isAllowedPreviewMiss(action: PlannedQuickAction, exitCode: number): boolean { @@ -1808,7 +1827,7 @@ async function buildPreviewDocument( async function pickStructuredDocumentTarget(opLabel: string): Promise { const target = await pickWorkspaceFileTarget( - `Select a JSON, YAML, or TOML file for Patchloom ${opLabel}` + `Select a ${STRUCTURED_DOCUMENT_LABEL} file for Patchloom ${opLabel}` ); if (!target) { return undefined; @@ -1816,7 +1835,7 @@ async function pickStructuredDocumentTarget(opLabel: string): Promise { +test("buildBatchTemplate returns line-oriented format with twelve operations", () => { const template = buildBatchTemplate(); const lines = template.split("\n").filter((line) => line.trim().length > 0); - assert.equal(lines.length, 10); + assert.equal(lines.length, 12); 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("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.update "), "fifth line should be multi-match doc.update"); - assert.ok(lines[5].startsWith("doc.delete_where "), "sixth line should be multi-match doc.delete_where"); - assert.ok(lines[6].startsWith("doc.merge "), "seventh line should be multi-doc doc.merge"); - assert.ok(lines[7].startsWith("file.append "), "eighth line should be a file.append operation"); - assert.ok(lines[8].startsWith("md.insert_after_section "), "ninth line should be md.insert_after_section"); - assert.ok(lines[9].startsWith("tidy.fix "), "tenth line should be a tidy.fix operation"); + assert.ok(lines[4].startsWith("doc.set ") && lines[4].includes("tsconfig.jsonc"), "fifth line should be JSONC doc.set"); + assert.ok(lines[5].startsWith("doc.update "), "sixth line should be multi-match doc.update"); + assert.ok(lines[6].startsWith("doc.delete_where "), "seventh line should be multi-match doc.delete_where"); + assert.ok(lines[7].startsWith("doc.merge "), "eighth line should be multi-doc doc.merge"); + assert.ok(lines[8].startsWith("file.append "), "ninth line should be a file.append operation"); + assert.ok(lines[9].startsWith("file.rename "), "tenth line should be a directory file.rename"); + assert.ok(lines[10].startsWith("md.insert_after_section "), "eleventh line should be md.insert_after_section"); + assert.ok(lines[11].startsWith("tidy.fix "), "twelfth line should be a tidy.fix operation"); }); test("buildBatchTemplate ends with a newline", () => { @@ -133,6 +135,16 @@ test("buildBatchTemplate includes replace --insert-after example (CLI 0.16)", () ); }); +test("buildBatchTemplate includes JSONC doc.set and directory file.rename (CLI 0.35+)", () => { + const lines = buildBatchTemplate().split("\n"); + const jsoncLine = lines.find((l) => l.includes("tsconfig.jsonc")); + const renameLine = lines.find((l) => l.startsWith("file.rename ")); + assert.ok(jsoncLine, "template should contain a JSONC doc.set example"); + assert.match(jsoncLine, /doc\.set tsconfig\.jsonc compilerOptions\.strict true/); + assert.ok(renameLine, "template should contain a file.rename example"); + assert.match(renameLine, /file\.rename \S+ \S+/); +}); + test("buildBatchTemplate includes doc.update multi-match example (CLI 0.27+ suggested_op sibling)", () => { const lines = buildBatchTemplate().split("\n"); const updateLine = lines.find((l) => l.startsWith("doc.update ")); diff --git a/test/unit/initializeProject.test.ts b/test/unit/initializeProject.test.ts index d8a29e9..ab6287a 100644 --- a/test/unit/initializeProject.test.ts +++ b/test/unit/initializeProject.test.ts @@ -207,6 +207,19 @@ test("formatCliOutput surfaces parse_timeout kind (CLI 0.34+)", () => { ); }); +test("formatCliOutput surfaces undo path no_matches kind (CLI 0.35+)", () => { + const stdout = JSON.stringify({ + ok: false, + error: "no backup entry for path nope.txt in session 1", + error_kind: "no_matches", + applied: false + }); + assert.equal( + formatCliOutput({ exitCode: 3, stdout, stderr: "" }), + "no_matches: no backup entry for path nope.txt in session 1" + ); +}); + test("formatCliOutput surfaces invalid_encoding kind (CLI 0.20+)", () => { const stdout = JSON.stringify({ ok: false, diff --git a/test/unit/patchloomCli.test.ts b/test/unit/patchloomCli.test.ts index 98cdfbf..66b15df 100644 --- a/test/unit/patchloomCli.test.ts +++ b/test/unit/patchloomCli.test.ts @@ -1035,6 +1035,66 @@ describe("patchloom CLI integration", async () => { assert.equal(vscodeTarget.configured, true, "target should be detected as configured"); }); }); + + test("doc set JSONC keeps comments (CLI 0.35+)", async (t) => { + const { stdout, stderr } = await execFileAsync(binaryPath, ["--version"], { timeout: 5000 }); + const version = parsePatchloomVersion(`${stdout}${stderr}`); + if (!version || comparePatchloomVersions(version, "0.35.0") < 0) { + t.skip(`requires patchloom >= 0.35.0 (found ${version ?? "unknown"})`); + return; + } + + await withTempDir(async (dir) => { + const file = path.join(dir, "tsconfig.jsonc"); + await fs.writeFile(file, "// keep\n{\n \"compilerOptions\": { \"strict\": false }\n}\n", "utf8"); + const action = buildDocSetQuickAction(file, "compilerOptions.strict", "true"); + await execFileAsync( + binaryPath, + serializePatchloomArgs({ args: action.args, apply: true }), + { timeout: 5000 } + ); + const content = await fs.readFile(file, "utf8"); + assert.match(content, /\/\/ keep/); + assert.match(content, /"strict": true/); + }); + }); + + test("doc set .env and directory rename (CLI 0.35+)", async (t) => { + const { stdout, stderr } = await execFileAsync(binaryPath, ["--version"], { timeout: 5000 }); + const version = parsePatchloomVersion(`${stdout}${stderr}`); + if (!version || comparePatchloomVersions(version, "0.35.0") < 0) { + t.skip(`requires patchloom >= 0.35.0 (found ${version ?? "unknown"})`); + return; + } + + await withTempDir(async (dir) => { + const envFile = path.join(dir, ".env"); + await fs.writeFile(envFile, "FOO=1\n# comment\nBAR=2\n", "utf8"); + const action = buildDocSetQuickAction(envFile, "FOO", "9"); + await execFileAsync( + binaryPath, + serializePatchloomArgs({ args: action.args, apply: true }), + { cwd: dir, timeout: 5000 } + ); + const envContent = await fs.readFile(envFile, "utf8"); + assert.match(envContent, /FOO=9/); + assert.match(envContent, /# comment/); + + await fs.mkdir(path.join(dir, "old_pkg")); + await fs.writeFile(path.join(dir, "old_pkg", "mod.rs"), "fn x() {}\n", "utf8"); + await execFileAsync(binaryPath, ["rename", "old_pkg", "new_pkg", "--apply"], { + cwd: dir, + timeout: 5000 + }); + assert.equal(await fs.readFile(path.join(dir, "new_pkg", "mod.rs"), "utf8"), "fn x() {}\n"); + await assert.rejects(() => fs.access(path.join(dir, "old_pkg"))); + + const preview = await execFileAsync(binaryPath, ["--json", "undo"], { cwd: dir, timeout: 5000 }) + .then((result) => result.stdout) + .catch((error: { stdout?: string }) => error.stdout ?? ""); + assert.match(preview, /rename back to old_pkg/); + }); + }); }); // --- End-to-end: managed install + MCP server --- @@ -1171,6 +1231,19 @@ describe("managed install end-to-end MCP", { timeout: 120_000 }, async () => { assert.ok(tool.inputSchema !== undefined, `tool ${tool.name} should have an inputSchema`); } + + const { stdout: versionOut, stderr: versionErr } = await execFileAsync( + binaryPath, + ["--version"], + { timeout: 60000 } + ); + const installed = parsePatchloomVersion(`${versionOut}${versionErr}`); + if (installed && comparePatchloomVersions(installed, "0.35.0") >= 0) { + const names = new Set(tools.map((tool) => tool.name)); + assert.ok(names.has("explain_plan"), "CLI 0.35+ full inventory includes explain_plan"); + assert.ok(names.has("tidy_check"), "CLI 0.35+ full inventory includes tidy_check"); + assert.ok(tools.length >= 64, `CLI 0.35+ full inventory is 64+ tools (got ${tools.length})`); + } }); test("MCP tools/call modifies a file on disk", async () => { diff --git a/test/unit/quickActions.test.ts b/test/unit/quickActions.test.ts index ed257ea..4ca70fa 100644 --- a/test/unit/quickActions.test.ts +++ b/test/unit/quickActions.test.ts @@ -37,6 +37,7 @@ import { isAllowedPreviewMiss, previewMissMessage, isMarkdownPath, + isEnvDocumentPath, isStructuredDocumentPath, isPathInsideWorkspace, isRealPathInsideWorkspace, @@ -316,17 +317,23 @@ test("buildPrependQuickAction builds a file prepend command", () => { test("isStructuredDocumentPath recognizes supported structured formats", () => { assert.equal(isStructuredDocumentPath("package.json"), true); + assert.equal(isStructuredDocumentPath("tsconfig.jsonc"), true); assert.equal(isStructuredDocumentPath("config.yaml"), true); assert.equal(isStructuredDocumentPath("config.yml"), true); assert.equal(isStructuredDocumentPath("Cargo.toml"), true); + assert.equal(isStructuredDocumentPath("app.ini"), true); + assert.equal(isStructuredDocumentPath("app.properties"), true); assert.equal(isStructuredDocumentPath("README.md"), false); }); test("isStructuredDocumentPath handles uppercase extensions", () => { assert.equal(isStructuredDocumentPath("data.JSON"), true); + assert.equal(isStructuredDocumentPath("tsconfig.JSONC"), true); assert.equal(isStructuredDocumentPath("config.YAML"), true); assert.equal(isStructuredDocumentPath("config.YML"), true); assert.equal(isStructuredDocumentPath("settings.TOML"), true); + assert.equal(isStructuredDocumentPath("APP.INI"), true); + assert.equal(isStructuredDocumentPath("app.PROPERTIES"), true); assert.equal(isStructuredDocumentPath("README.MD"), false); }); @@ -336,10 +343,20 @@ test("isStructuredDocumentPath rejects files without extensions", () => { assert.equal(isStructuredDocumentPath("file."), false); }); -test("isStructuredDocumentPath rejects dotfiles without basenames", () => { +test("isStructuredDocumentPath rejects extension-only dotfiles", () => { assert.equal(isStructuredDocumentPath(".json"), false); assert.equal(isStructuredDocumentPath(".yaml"), false); - assert.equal(isStructuredDocumentPath(".env"), false); + assert.equal(isStructuredDocumentPath(".jsonc"), false); +}); + +test("isStructuredDocumentPath accepts .env and .env.* (CLI 0.35+)", () => { + assert.equal(isStructuredDocumentPath(".env"), true); + assert.equal(isStructuredDocumentPath(".env.local"), true); + assert.equal(isStructuredDocumentPath(".env.production"), true); + assert.equal(isStructuredDocumentPath(path.join("config", ".env")), true); + assert.equal(isEnvDocumentPath(".envrc"), false); + assert.equal(isStructuredDocumentPath(".envrc"), false); + assert.equal(isStructuredDocumentPath("foo.env"), false); }); test("buildTidyQuickAction with a single fix omits unselected flags", () => {