Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/biome-plugin-patch-format.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/cli-edit-json-noop.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion decisions/004-templating-and-edit-json-dsl.md
Original file line number Diff line number Diff line change
@@ -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/`.

Expand Down Expand Up @@ -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

Expand Down
36 changes: 36 additions & 0 deletions decisions/017-plugin-owned-format-of-patched-config.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions run-run/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<tool>-plugin/`: copy the shape of any existing plugin (`biome-plugin` is the most complete reference).
Expand Down
2 changes: 1 addition & 1 deletion run-run/biome-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
180 changes: 180 additions & 0 deletions run-run/biome-plugin/src/__tests__/install.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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> = {}): 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> = {}): 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" }] },
]);
});
});
Loading
Loading