diff --git a/.changeset/biome-plugin-patch-format.md b/.changeset/biome-plugin-patch-format.md new file mode 100644 index 0000000..d4150ec --- /dev/null +++ b/.changeset/biome-plugin-patch-format.md @@ -0,0 +1,5 @@ +--- +"@rrlab/biome-plugin": patch +--- + +`rr plugins add biome` on a project that already has a `biome.json` formats the patched file with the installed Biome, so `rr jsc`, `rr format` and `rr check` pass on it. It used to leave the file with a 2-space indent and every array item on its own line, including arrays it didn't touch, which Biome's format check rejects. If Biome can't format the file, it's left as patched and `rr plugins add` still succeeds. diff --git a/.changeset/cli-edit-json-noop.md b/.changeset/cli-edit-json-noop.md new file mode 100644 index 0000000..9d3c353 --- /dev/null +++ b/.changeset/cli-edit-json-noop.md @@ -0,0 +1,5 @@ +--- +"@rrlab/cli": patch +--- + +`rr plugins add` and `rr plugins remove` leave a JSON config file as it is when the plugin's edits change nothing in it, as with `rr plugins add biome --force` or `rr plugins add ts --force` on a file that already has them: the file stays byte-identical, and the output says "No changes for …". It used to be rewritten with a 2-space indent and each array item on its own line, so `rr check` failed on it. diff --git a/decisions/004-templating-and-edit-json-dsl.md b/decisions/004-templating-and-edit-json-dsl.md index c093c62..d3fc6fc 100644 --- a/decisions/004-templating-and-edit-json-dsl.md +++ b/decisions/004-templating-and-edit-json-dsl.md @@ -1,6 +1,7 @@ # 004: Templating strategy + JSON edit DSL for install/uninstall hooks - **Date**: 2026-05-19 +- **Amended**: 2026-10-18 — an edit that changes nothing leaves the file byte-identical, and biome-plugin formats the `biome.json` it patches with Biome, because the user's formatter no longer re-applies their style before `rr check` runs (see 017) - **Status**: Applied - **Files affected**: `run-run/cli/src/plugin/types.ts`, `run-run/cli/src/services/json-edit.ts`, `run-run/cli/src/program/commands/plugins.ts`, each plugin's `install()` / `uninstall()` hook, `run-run/ts-config/`, `run-run/biome-config/`. @@ -36,7 +37,7 @@ The second question matters specifically for migration agents — running `rr pl - Lighter than `jsonc-parser` for the same outcome we actually care about (comments survive). - Familiar mental model: `parse` → mutate → `stringify`, same as `JSON`. The only difference is that comments come along for the ride. -- Quote style and indent get normalised on re-write — acceptable cosmetic loss; the user's formatter (biome, prettier) re-applies their style on next run. +- Quote style and indent get normalised on re-write — acceptable cosmetic loss; the user's formatter (biome, prettier) re-applies their style on next run. **No longer holds** (amended by 017): `rr check` reports formatting without fixing it, so a reflowed file fails it right after `rr plugins add`. An edit that changes nothing now leaves the file byte-identical, and biome-plugin formats the `biome.json` it patches. Any other real change is still normalised. ## Edit DSL diff --git a/decisions/017-plugin-owned-format-of-patched-config.md b/decisions/017-plugin-owned-format-of-patched-config.md new file mode 100644 index 0000000..d92b0ce --- /dev/null +++ b/decisions/017-plugin-owned-format-of-patched-config.md @@ -0,0 +1,36 @@ +# 017: Who makes a config file that `rr plugins add` patches pass the project's formatter: the edit engine, the owning plugin, or the kernel? + +- **Date**: 2026-10-18 +- **Status**: Applied +- **Files affected**: `run-run/biome-plugin/src/index.ts`, `run-run/biome-plugin/src/__tests__/install.test.ts` (new), `run-run/biome-plugin/README.md`, `run-run/cli/src/services/json-edit.ts`, `run-run/cli/src/services/__tests__/json-edit.test.ts`, `run-run/cli/src/services/__tests__/file-ops.test.ts`, `run-run/cli/test/integration/plugins.test.ts`, `run-run/cli/CLAUDE.md`, `run-run/CLAUDE.md`, `decisions/004-templating-and-edit-json-dsl.md` (amended) + +## Context + +`applyJsonEdits` (`run-run/cli/src/services/json-edit.ts`) ends every edit with `cjson.stringify(root, null, 2)`. The output always has a 2-space indent and every array item on its own line, whatever the source looked like. So on a project that already has a `biome.json`, biome-plugin's patch (`include /extends`) leaves a file that Biome's own formatter rejects: `formatter.expand: "auto"` keeps short arrays on one line, and the indent comes from the merged config. `rr jsc`, `rr format` and `rr check` then fail right after `rr plugins add biome`. A patch that changes nothing (`rr plugins add biome --force`) also rewrote the file and reported "Edited". ADR 004 accepted this because "the user's formatter re-applies their style on next run". Since dx#276 the bar is that an add without a TTY leaves `rr check` green, and `rr check` reports problems without fixing them. + +## Options considered + +- **A**: The owning plugin formats. biome-plugin's patch branch returns the existing `edit-json`, then an `edit-text` that runs the patched `biome.json` through the installed Biome synchronously (`biome format --stdin-file-path=biome.json`) and falls back to the unformatted text on any error. Separately, `applyJsonEdits` returns the source verbatim when no edit changes the document. +- **B**: A surgical engine. Amend 004 so that edit-json edits the source text in place (jsonc-parser or hand-written), keeping untouched text and indent and inserting new values compactly. +- **C**: The kernel formats. After `rr plugins add/remove`, the kernel runs the configured `Formatter` on exactly the files it wrote. That needs a file list on `Formatter.format` and a registry rebuilt from the config it just saved. + +## Decision: Option A + +Biome's layout depends on the merged config, and the patch changes that config. For a `biome.json` without `indentStyle`, Biome formats with tabs before the patch and spaces after it, because `@rrlab/biome-config` sets `indentStyle: space`. Only Biome gets this right, and only when it runs after the patched file is on disk and `@rrlab/biome-config` is installed. In `rr plugins add`, the file-ops step is the one point where both are true: `add.ts` installs `devDependencies` before it applies the file ops, in order. Formatting a tool's own config file with that tool falls under the plugin's ownership of "the tool's config-file shape" (`run-run/CLAUDE.md`). It uses the existing `edit-text` op (tsdown-plugin already patches through it) and changes no contract, so the new plugin works on the published `@rrlab/cli` and the two packages release independently. The no-op guard is tool-agnostic and makes ADR 004's idempotency hold byte for byte: an edit that changes nothing leaves the file as it was. + +`edit-text` stays `(source) => string`, so the format step spawns Biome synchronously (`execFileSync`). That makes biome-plugin the first plugin to spawn a process outside `ToolService`/`ShellService`. Widening `edit-text` to return a Promise would let it use `ShellService.runCaptured` with `stdin`. But `rr plugins add` installs the latest plugin whatever the host's `@rrlab/cli` version, and an older `applyFileOp` does not await `op.edit`: it would pass a Promise to `fs.writeFile` partway through the add. + +## Alternatives rejected + +- Option B: it doesn't fix the bug. Keeping the source indent is wrong whenever the preset changes it. jsonc-parser was tested and still spreads the inserted array over lines, so the engine would need its own compact-insertion rule, which is a formatting policy inside a tool-agnostic kernel. It also reopens 004's rejection of jsonc-parser. +- Option C: the right shape for the whole bug class, but it costs too much for the one file in scope. `Formatter.format` takes only `{ fix }` and formats the whole project, so it would need a file list, implemented in both biome and oxc. The add/remove flows would need a new step that rebuilds the registry after `ast.save` and handles zero or several formatters. And it would need an ADR 011 release. A does not rule it out; see the notes. +- Formatting inside `install()`: `install()` runs before the `devDependencies` are installed and before the patch is written, so Biome would format against the old config or fail to resolve `@rrlab/biome-config`. +- One `edit-text` that patches and formats in a single step: it formats against the pre-patch config on disk. + +## Notes for human review + +- The scope is the patch path for `biome.json`. Still unformatted after this: ts-plugin's `tsconfig.json` patch and uninstall (arrays spread over lines, `rr check` red), and `run-run.config.mts`, which is written with recast's 2-space indent even in tab or 4-space projects. ts-plugin owns no formatter, and the project's formatter may be oxfmt, so this plugin-side pattern doesn't carry over to those files. Fixing them is Option C's job, in its own decision. +- A does not foreclose C, because it adds nothing to the contract. When C lands, delete biome-plugin's format step (`formatWithBiome` and its `edit-text` op) in the same PR; until then, formatting the same file twice is idempotent. C needs the no-op guard too, so that a re-add doesn't churn files. +- The format step falls back to the unformatted text when Biome can't be resolved, exits non-zero (invalid JSON, an `extends` it can't resolve), times out (10 s) or prints nothing. The add never fails because of this step; the worst case is the old behaviour. When the user's `files.includes` leaves `biome.json` out, Biome echoes the input unchanged, so the step reports "No changes", which matches `rr jsc` not checking that file either. +- Known wart: `add.ts` logs one line per op, so a patch prints "Edited biome.json" twice (or "Edited" then "No changes"). Collapsing the outcomes per path is a separate change. +- Edge case: an empty existing file with no-op edits now stays empty instead of becoming `{}\n`. No existing test asserted the old behaviour; `json-edit.test.ts` locks the new one. diff --git a/run-run/CLAUDE.md b/run-run/CLAUDE.md index fd313e6..6a38c1a 100644 --- a/run-run/CLAUDE.md +++ b/run-run/CLAUDE.md @@ -130,6 +130,8 @@ Paths are JSON Pointer (RFC 6901). Ops are NOT strict RFC 6902 — see `decision For TS modules, such as the `tsdown.config.ts` that tsdown-plugin patches, the escape hatch is `{ kind: "edit-text", path, edit: (src) => string }`: the plugin parses the module with `magicast`, from its own `dependencies`, and returns the new text. +An `edit-json` patch re-stringifies the file with a 2-space indent and one array item per line, which the project's formatter may reject. biome-plugin follows its `biome.json` patch with an `edit-text` that runs the installed Biome over the patched file; ops apply in order, after the `devDependencies` are installed. That fixes only the file a plugin's own tool formats: see `decisions/017-plugin-owned-format-of-patched-config.md` before copying the pattern. + ### Adding a new plugin (checklist) 1. `run-run/-plugin/`: copy the shape of any existing plugin (`biome-plugin` is the most complete reference). diff --git a/run-run/biome-plugin/README.md b/run-run/biome-plugin/README.md index eb4d071..a161a7a 100644 --- a/run-run/biome-plugin/README.md +++ b/run-run/biome-plugin/README.md @@ -8,7 +8,7 @@ rr plugins add biome ``` -`rr plugins add` installs `@rrlab/biome-plugin` and adds `@biomejs/biome` as a dev dependency. Once you confirm, it writes a `biome.json` that extends [`@rrlab/biome-config`](https://www.npmjs.com/package/@rrlab/biome-config). If `biome.json` already exists, you choose to patch it, keep it or overwrite it. +`rr plugins add` installs `@rrlab/biome-plugin` and adds `@biomejs/biome` as a dev dependency. Once you confirm, it writes a `biome.json` that extends [`@rrlab/biome-config`](https://www.npmjs.com/package/@rrlab/biome-config). If `biome.json` already exists, you choose to patch it, keep it or overwrite it. A patch adds the preset to `extends`, keeps your other settings, and formats the file with the installed Biome, so `rr jsc` passes on it. It needs `@rrlab/cli` and Biome 2 or later. diff --git a/run-run/biome-plugin/src/__tests__/install.test.ts b/run-run/biome-plugin/src/__tests__/install.test.ts new file mode 100644 index 0000000..b16e363 --- /dev/null +++ b/run-run/biome-plugin/src/__tests__/install.test.ts @@ -0,0 +1,180 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { type ClackPrompts, type InstallContext, ReleaseService, type UninstallContext } from "@rrlab/cli/plugin"; +import type { Pkg, ShellService } from "@variablelab/clibuddy"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { install, uninstall } from "../index.ts"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rr-biome-plugin-")); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +const writeBiomeJson = (content: string) => fs.writeFile(path.join(tmpDir, "biome.json"), content, "utf8"); + +function appPkg(): Pkg { + return { dirPath: tmpDir, packageJson: {} } as Pkg; +} + +function installCtx(overrides: Partial = {}): InstallContext { + return { + shell: {} as ShellService, + // biome-ignore lint/suspicious/noExplicitAny: minimal stub for the test boundary + logger: {} as any, + appPkg: appPkg(), + prompts: stubPrompts(), + flags: { force: false, yes: true, nonInteractive: true }, + release: new ReleaseService(undefined), + ...overrides, + }; +} + +function uninstallCtx(overrides: Partial = {}): UninstallContext { + return { + shell: {} as ShellService, + // biome-ignore lint/suspicious/noExplicitAny: minimal stub + logger: {} as any, + appPkg: appPkg(), + prompts: stubPrompts(), + flags: { yes: true, nonInteractive: true }, + ...overrides, + }; +} + +function stubPrompts(overrides: Partial = {}): ClackPrompts { + const cancelSymbol = Symbol("clack:cancel"); + return { + select: vi.fn(async () => "patch" as unknown) as unknown as ClackPrompts["select"], + confirm: vi.fn(async () => true), + isCancel: (value): value is symbol => value === cancelSymbol, + ...overrides, + }; +} + +/** The `edit-text` step that the patch branch returns after its `edit-json`. */ +async function formatStep(): Promise<(source: string) => string> { + const op = (await install(installCtx())).files?.[1]; + if (op?.kind !== "edit-text") throw new Error("expected an edit-text op"); + return op.edit; +} + +describe("@rrlab/biome-plugin install()", () => { + it("creates a biome.json that extends the preset, in Biome's format, when none exists", async () => { + const result = await install(installCtx()); + expect(result.devDependencies).toEqual({ + "@biomejs/biome": expect.stringMatching(/\^?\d/), + "@rrlab/biome-config": "latest", + }); + expect(result.files).toEqual([ + { + kind: "create", + path: "biome.json", + content: `{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "extends": ["@rrlab/biome-config"] +} +`, + overwrite: false, + }, + ]); + }); + + describe("patching an existing biome.json", () => { + it("adds the preset with an edit-json, then formats the file with an edit-text", async () => { + await writeBiomeJson("{}\n"); + const result = await install(installCtx()); + expect(result.files?.map((op) => `${op.kind} ${op.path}`)).toEqual(["edit-json biome.json", "edit-text biome.json"]); + const patch = result.files?.[0]; + if (patch?.kind !== "edit-json") throw new Error("expected an edit-json op"); + expect(patch.edits).toEqual([ + { op: "set", path: "/$schema", value: "./node_modules/@biomejs/biome/configuration_schema.json", mode: "if-missing" }, + { op: "include", path: "/extends", value: "@rrlab/biome-config", position: "start" }, + ]); + }); + + it("formats with the config on disk, as Biome's format check wants it", async () => { + // What the edit-json leaves on disk: 2-space indent, one array item per line. + const patched = `{ + "formatter": { + "indentStyle": "tab" + }, + "files": { + "includes": [ + "**", + "!dist" + ] + } +} +`; + await writeBiomeJson(patched); + const format = await formatStep(); + expect(format(patched)).toMatchInlineSnapshot(` + "{ + "formatter": { + "indentStyle": "tab" + }, + "files": { + "includes": ["**", "!dist"] + } + } + " + `); + }); + + it("returns the source as it is when it isn't valid JSON", async () => { + await writeBiomeJson("{}\n"); + const format = await formatStep(); + expect(format(`{ "extends": [`)).toBe(`{ "extends": [`); + }); + + it("returns the source as it is when Biome can't load the config on disk", async () => { + // The preset isn't installed in the temp dir, so Biome can't resolve the `extends`. + const patched = `{\n "extends": [\n "@rrlab/biome-config"\n ]\n}\n`; + await writeBiomeJson(patched); + const format = await formatStep(); + expect(format(patched)).toBe(patched); + }); + }); +}); + +describe("@rrlab/biome-plugin uninstall()", () => { + it("returns removeDependencies and no file ops when biome.json doesn't exist", async () => { + const result = await uninstall(uninstallCtx()); + expect(result).toEqual({ removeDependencies: ["@biomejs/biome", "@rrlab/biome-config"] }); + }); + + it("deletes biome.json when it only extends the preset", async () => { + await writeBiomeJson(`{ "$schema": "./schema.json", "extends": ["@rrlab/biome-config"] }`); + const result = await uninstall(uninstallCtx()); + expect(result.files).toEqual([{ kind: "delete", path: "biome.json" }]); + }); + + it("drops the preset and an emptied extends when biome.json has other settings", async () => { + await writeBiomeJson(`{ "extends": ["@rrlab/biome-config"], "linter": { "enabled": false } }`); + const result = await uninstall(uninstallCtx()); + expect(result.files).toEqual([ + { + kind: "edit-json", + path: "biome.json", + edits: [ + { op: "exclude", path: "/extends", value: "@rrlab/biome-config" }, + { op: "unset", path: "/extends" }, + ], + }, + ]); + }); + + it("keeps the other extends", async () => { + await writeBiomeJson(`{ "extends": ["@rrlab/biome-config", "./base.json"] }`); + const result = await uninstall(uninstallCtx()); + expect(result.files).toEqual([ + { kind: "edit-json", path: "biome.json", edits: [{ op: "exclude", path: "/extends", value: "@rrlab/biome-config" }] }, + ]); + }); +}); diff --git a/run-run/biome-plugin/src/index.ts b/run-run/biome-plugin/src/index.ts index 50b7631..b5f6463 100644 --- a/run-run/biome-plugin/src/index.ts +++ b/run-run/biome-plugin/src/index.ts @@ -1,4 +1,6 @@ +import { execFileSync } from "node:child_process"; import fs from "node:fs/promises"; +import { createRequire } from "node:module"; import path from "node:path"; import { decideScaffold, @@ -27,6 +29,7 @@ const BIOME_JSON = "biome.json"; const BIOME_CONFIG_PKG = "@rrlab/biome-config"; // The installed Biome's own schema: a versioned URL drifts from `^2.0.0` and Biome flags the mismatch. const BIOME_SCHEMA = "./node_modules/@biomejs/biome/configuration_schema.json"; +const FORMAT_TIMEOUT_MS = 10_000; export { TOOL_VERSIONS } from "./tool-versions.ts"; @@ -77,25 +80,59 @@ export async function install(ctx: InstallContext): Promise { [BIOME_CONFIG_PKG]: await ctx.release.resolve(BIOME_CONFIG_PKG), }; - const file: FileOp = + const files: FileOp[] = scaffoldDecision === "create" || scaffoldDecision === "overwrite" - ? { - kind: "create", - path: BIOME_JSON, - // In Biome's own format, which keeps a short array on one line, so `rr jsc` passes on it. - content: `{\n "$schema": "${BIOME_SCHEMA}",\n "extends": ["${BIOME_CONFIG_PKG}"]\n}\n`, - overwrite: scaffoldDecision === "overwrite" || ctx.flags.force, - } - : { - kind: "edit-json", - path: BIOME_JSON, - edits: [ - { op: "set", path: "/$schema", value: BIOME_SCHEMA, mode: "if-missing" }, - { op: "include", path: "/extends", value: BIOME_CONFIG_PKG, position: "start" }, - ], - }; - - return { devDependencies, files: [file] }; + ? [ + { + kind: "create", + path: BIOME_JSON, + // In Biome's own format, which keeps a short array on one line, so `rr jsc` passes on it. + content: `{\n "$schema": "${BIOME_SCHEMA}",\n "extends": ["${BIOME_CONFIG_PKG}"]\n}\n`, + overwrite: scaffoldDecision === "overwrite" || ctx.flags.force, + }, + ] + : [ + { + kind: "edit-json", + path: BIOME_JSON, + edits: [ + { op: "set", path: "/$schema", value: BIOME_SCHEMA, mode: "if-missing" }, + { op: "include", path: "/extends", value: BIOME_CONFIG_PKG, position: "start" }, + ], + }, + // The edit-json writes one array item per line, which Biome's format check rejects, so Biome formats the + // result. It's a step of its own because Biome reads the effective config from disk, and that has to be + // the patched biome.json, which extends @rrlab/biome-config and so can change the indent Biome wants. + // It relies on `rr plugins add` installing the devDependencies (Biome, the preset) before it applies + // the file ops, in order. + { kind: "edit-text", path: BIOME_JSON, edit: (source) => formatWithBiome(source, ctx.appPkg.dirPath) }, + ]; + + return { devDependencies, files }; +} + +/** + * Formats a `biome.json` source with the installed Biome, run in `cwd` so it reads that project's config. + * Synchronous because `edit-text` is `(source) => string`. Returns `source` as it is when Biome can't be + * resolved, fails (invalid JSON, a config it can't load), times out or prints nothing, so it never fails + * the add: the worst case is an unformatted file that `rr format --fix` fixes. + */ +function formatWithBiome(source: string, cwd: string): string { + try { + const requireFromPlugin = createRequire(FROM); + const pkgJsonPath = requireFromPlugin.resolve("@biomejs/biome/package.json"); + const { bin } = requireFromPlugin(pkgJsonPath) as { bin?: string | Record }; + const rel = typeof bin === "string" ? bin : bin?.biome; + if (!rel) return source; + const formatted = execFileSync( + process.execPath, + [path.join(path.dirname(pkgJsonPath), rel), "format", `--stdin-file-path=${BIOME_JSON}`], + { cwd, input: source, encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], timeout: FORMAT_TIMEOUT_MS }, + ); + return formatted.trim() === "" ? source : formatted; + } catch { + return source; + } } export async function uninstall(ctx: UninstallContext): Promise { diff --git a/run-run/cli/CLAUDE.md b/run-run/cli/CLAUDE.md index eb57ff6..d09d0dc 100644 --- a/run-run/cli/CLAUDE.md +++ b/run-run/cli/CLAUDE.md @@ -123,7 +123,7 @@ It renders via the `Lines` builder and returns `""` (nothing appended) when both `src/services/file-ops.ts`. `applyFileOp` performs the filesystem work and returns a `FileOpOutcome` — it never writes to the terminal; the `plugins add/remove` actions map the outcome to clack log lines (`reportFileOp`), and `describeFileOp` renders a side-effect-free one-liner for the remove plan. It handles every `FileOp` kind in `src/lib/plugin/types.ts`: - `create` — writes content; respects `overwrite` flag. -- `edit-json` — reads, applies `JsonEdit[]` via `applyJsonEdits` (in `src/services/json-edit.ts`), writes back. Uses `comment-json` so user comments + key positions survive. +- `edit-json` — reads, applies `JsonEdit[]` via `applyJsonEdits` (in `src/services/json-edit.ts`), writes back. Uses `comment-json` so user comments + key positions survive. A real change re-stringifies the whole file (2-space indent, one array item per line); an edit that changes nothing leaves the file byte-identical and reports `unchanged`. - `edit-text` — reads, calls `op.edit(source)`, writes back. Escape hatch for TS modules etc. - `delete` — `fs.unlink`. Idempotent (skips if file doesn't exist). diff --git a/run-run/cli/src/services/__tests__/file-ops.test.ts b/run-run/cli/src/services/__tests__/file-ops.test.ts index b4d4d58..e42251b 100644 --- a/run-run/cli/src/services/__tests__/file-ops.test.ts +++ b/run-run/cli/src/services/__tests__/file-ops.test.ts @@ -58,15 +58,27 @@ describe("applyFileOp — edit-json", () => { expect(JSON.parse(await read("b.json"))).toEqual({ extends: "new" }); }); - it("reports unchanged when re-applying the same edit (already canonical)", async () => { + it("reports unchanged when re-applying the same edit", async () => { await write("b.json", `{ "extends": "old" }`); const edit = { kind: "edit-json", path: "b.json", edits: [{ op: "set", path: "/extends", value: "new" }] } satisfies FileOp; - // First application normalises the file to the engine's canonical output; - // the second is a genuine no-op (same value, already-canonical text). + // The second application changes nothing in the document, so the engine + // returns the file's text as it is. expect((await applyFileOp(dir, edit, false)).status).toBe("edited"); expect((await applyFileOp(dir, edit, false)).status).toBe("unchanged"); }); + it("leaves a file byte-identical when no edit changes it", async () => { + const source = `{\n\t"extends": ["a", "b"],\n\t"include": ["src"]\n}\n`; + await write("b.json", source); + const outcome = await applyFileOp( + dir, + { kind: "edit-json", path: "b.json", edits: [{ op: "include", path: "/extends", value: "a", position: "start" }] }, + false, + ); + expect(outcome.status).toBe("unchanged"); + expect(await read("b.json")).toBe(source); + }); + it("reports missing when the target file is absent", async () => { const outcome = await applyFileOp( dir, diff --git a/run-run/cli/src/services/__tests__/json-edit.test.ts b/run-run/cli/src/services/__tests__/json-edit.test.ts index 74ef652..d77a10b 100644 --- a/run-run/cli/src/services/__tests__/json-edit.test.ts +++ b/run-run/cli/src/services/__tests__/json-edit.test.ts @@ -91,6 +91,24 @@ describe("applyJsonEdits", () => { }); }); + describe("no-op edits", () => { + it("return the source unchanged when no edit changes the document", () => { + const source = `{\n\t// keep me\n\t"$schema": "./schema.json",\n\t"extends": ["@rrlab/biome-config", "./base.json"],\n\t"files": { "includes": ["**", "!dist"] }\n}\n`; + const out = applyJsonEdits(source, [ + { op: "include", path: "/extends", value: "@rrlab/biome-config", position: "start" }, + { op: "set", path: "/$schema", value: "./other.json", mode: "if-missing" }, + { op: "set", path: "/files/includes", value: ["**", "!dist"], mode: "replace" }, + { op: "exclude", path: "/extends", value: "./absent.json" }, + { op: "unset", path: "/linter" }, + ]); + expect(out).toBe(source); + }); + + it("leave an empty source empty", () => { + expect(applyJsonEdits("", [{ op: "unset", path: "/extends" }])).toBe(""); + }); + }); + describe("composition", () => { it("applies a biome-config-style patch (set $schema if missing + include extends at start)", () => { const source = `{ "extends": ["existing"], "files": { "includes": ["**"] } }`; diff --git a/run-run/cli/src/services/json-edit.ts b/run-run/cli/src/services/json-edit.ts index 4f21169..ff9167a 100644 --- a/run-run/cli/src/services/json-edit.ts +++ b/run-run/cli/src/services/json-edit.ts @@ -9,6 +9,10 @@ import type { JsonEdit } from "#src/lib/plugin/types.ts"; * Paths follow JSON Pointer (RFC 6901): `"/extends"`, * `"/compilerOptions/strict"`. Indices are valid path segments for arrays * (`"/extends/0"`). + * + * When the edits leave the document as it was, returns `source` verbatim: + * re-stringifying would still reflow the file (2-space indent, one array + * item per line), so a no-op edit leaves it byte-identical. */ export function applyJsonEdits(source: string, edits: JsonEdit[]): string { // biome-ignore lint/suspicious/noExplicitAny: cjson exposes the parsed tree as a structurally-typed JS object/array @@ -16,9 +20,13 @@ export function applyJsonEdits(source: string, edits: JsonEdit[]): string { if (root == null || typeof root !== "object") { throw new Error(`applyJsonEdits: expected a JSON object/array at the top level, got ${typeof root}.`); } + // A snapshot, not a reference: the edits mutate `root` in place. Comments live in + // symbol keys, which `JSON.stringify` skips, and no edit touches them. + const before = JSON.stringify(root); for (const edit of edits) { root = applyOne(root, edit); } + if (JSON.stringify(root) === before) return source; return `${cjson.stringify(root, null, 2)}\n`; } diff --git a/run-run/cli/test/integration/plugins.test.ts b/run-run/cli/test/integration/plugins.test.ts index b90097e..72cb068 100644 --- a/run-run/cli/test/integration/plugins.test.ts +++ b/run-run/cli/test/integration/plugins.test.ts @@ -171,6 +171,32 @@ describe("rr plugins", () => { expect(jsc.status, jsc.stdout + jsc.stderr).toBe(0); }); + test("patches an existing biome.json into one that passes `rr jsc`", () => { + fixture = makeFixture("plugins-add-no-tty-patch", { + ".gitignore": "node_modules\n.stub-bin\n", + "package.json": pnpmPkg(), + "biome.json": `{\n "formatter": { "indentStyle": "space" },\n "files": { "includes": ["**", "!dist"] }\n}\n`, + }); + + const r = cli("plugins add biome", { cwd: fixture.dir, env: stubPackageManager(fixture.dir) }); + expect(r.stdout + r.stderr).toContain("Edited biome.json"); + expect(r.status).toBe(0); + + expect(readFileSync(path.join(fixture.dir, "biome.json"), "utf8")).toBe(`{ + "formatter": { + "indentStyle": "space" + }, + "files": { + "includes": ["**", "!dist"] + }, + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "extends": ["@rrlab/biome-config"] +} +`); + const jsc = cli("jsc", { cwd: fixture.dir }); + expect(jsc.status, jsc.stdout + jsc.stderr).toBe(0); + }); + test("ts picks the React preset for a package that depends on react", () => { fixture = makeFixture("plugins-add-no-tty-react", { "package.json": pnpmPkg({ dependencies: { react: "^19.0.0" } }), @@ -182,6 +208,43 @@ describe("rr plugins", () => { const tsconfig = JSON.parse(readFileSync(path.join(fixture.dir, "tsconfig.json"), "utf8")); expect(tsconfig.extends).toBe("@rrlab/ts-config/react"); }); + + test("biome again with --force leaves biome.json byte-identical", () => { + fixture = makeFixture("plugins-add-no-tty-force", { + ".gitignore": "node_modules\n.stub-bin\n", + "package.json": pnpmPkg(), + }); + const env = stubPackageManager(fixture.dir); + expect(cli("plugins add biome", { cwd: fixture.dir, env }).status).toBe(0); + const biomeJson = readFileSync(path.join(fixture.dir, "biome.json"), "utf8"); + + const r = cli("plugins add biome --force", { cwd: fixture.dir, env }); + expect(r.stdout + r.stderr).toContain("No changes for biome.json"); + expect(r.status).toBe(0); + + expect(readFileSync(path.join(fixture.dir, "biome.json"), "utf8")).toBe(biomeJson); + const jsc = cli("jsc", { cwd: fixture.dir }); + expect(jsc.status, jsc.stdout + jsc.stderr).toBe(0); + }); + + test("ts with --force leaves a tsconfig.json that already extends the preset byte-identical", () => { + const tsconfig = `{\n "extends": "@rrlab/ts-config/no-dom/app",\n "compilerOptions": { "lib": ["ES2022"] },\n "include": ["src"]\n}\n`; + fixture = makeFixture("plugins-add-no-tty-ts-force", { + ".gitignore": "node_modules\n.stub-bin\n", + "package.json": pnpmPkg(), + "tsconfig.json": tsconfig, + }); + const env = stubPackageManager(fixture.dir); + expect(cli("plugins add biome", { cwd: fixture.dir, env }).status).toBe(0); + + const r = cli("plugins add ts --force", { cwd: fixture.dir, env }); + expect(r.stdout + r.stderr).toContain("No changes for tsconfig.json"); + expect(r.status).toBe(0); + + expect(readFileSync(path.join(fixture.dir, "tsconfig.json"), "utf8")).toBe(tsconfig); + const jsc = cli("jsc", { cwd: fixture.dir }); + expect(jsc.status, jsc.stdout + jsc.stderr).toBe(0); + }); }); describe("remove without a TTY", () => {