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: 4 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,8 @@
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
"ignore": [],
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
"onlyUpdatePeerDependentsWhenOutOfRange": true
}
}
42 changes: 42 additions & 0 deletions .changeset/plugin-only-narrowing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
"@rrlab/biome-plugin": major
"@rrlab/oxc-plugin": major
"@rrlab/ts-plugin": major
"@rrlab/tsdown-plugin": major
"@rrlab/cli": major
---

### Declarative plugin shape + first-class `only` narrowing

Every official plugin factory now accepts an `only?: readonly Kind[]` option that narrows which capabilities the plugin contributes to the kernel's registry. The `only` array is typed against the kinds *that plugin* provides — `biome({ only: ["lint", "format"] })` and `oxc({ only: ["tsc"] })` are both valid; `oxc({ only: ["pack"] })` is a compile error.

This unblocks host configurations that mix plugins with overlapping capabilities — for example, biome for lint+format alongside oxc for type-aware checks:

```ts
import biome from "@rrlab/biome-plugin";
import oxc from "@rrlab/oxc-plugin";
import { defineConfig } from "@rrlab/cli/config";

export default defineConfig({
plugins: [
biome({ only: ["lint", "format"] }),
oxc({ only: ["tsc"] }),
],
});
```

### `@rrlab/oxc-plugin` — new `tsc` capability

`@rrlab/oxc-plugin` now provides a `tsc` capability backed by the `oxlint-tsgolint` peer (already installed by `rr plugins add oxc`). `rr tsc` configured with the oxc plugin runs `oxlint --type-aware --type-check`.

### `@rrlab/cli` — better multi-provider error

The error thrown when two plugins claim the same capability now references the `only` syntax explicitly, e.g.:

> Multiple plugins provide capability 'lint': biome, oxc. Narrow each plugin's capabilities in run-run.config.ts using the 'only' option — e.g. biome({ only: ['lint'] }) or oxc({ only: ['lint'] }).

### Plugin authoring — declarative shape (internal-only)

Plugins now declare `capabilities` (a `{ kind: service }` map) rather than implementing an imperative `setup()`. The kernel-internal SDK at `@rrlab/cli/plugin` applies `only` narrowing, deduplicates bin probes across services that share a `pkg`, and surfaces a single canonical "requires X to be installed" error when a peer-installed tool is missing. New plugin-authoring helpers `decideScaffold` and `pickPreset` are exported from `@rrlab/cli/plugin` and are the canonical path for any user interaction during `rr plugins add`.

The plugin API remains internal to `@rrlab/*` (no third-party authoring contract). Architectural rationale recorded in `decisions/007-per-plugin-only-option.md` (superseded by 009) and `decisions/009-declarative-plugin-shape.md`.
11 changes: 11 additions & 0 deletions .changeset/refresh-stale-tool-versions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@rrlab/biome-plugin": patch
"@rrlab/oxc-plugin": patch
---

Refresh stale install ranges in `oxc-plugin`'s `TOOL_VERSIONS` so `rr plugins add oxc` no longer pins users to old 0.x minors. `^0.X.Y` semver on 0.x packages only allows patch bumps — `oxfmt ^0.30.0` was strands at 0.30.x while upstream shipped through 0.51.0, and `oxlint-tsgolint ^0.15.0` strands at 0.15.x while upstream shipped 0.23.0.

- `oxfmt`: install `^0.30.0` → `^0.51.0`; devDep `0.35.0` → `0.51.0`.
- `oxlint-tsgolint`: install `^0.15.0` → `^0.23.0`; devDep `0.15.0` → `0.23.0`.
- `oxlint`: install stays `^1.0.0` (caret on 1.x already covers 1.66.x); devDep `1.50.0` → `1.66.0`.
- `@biomejs/biome`: install stays `^2.0.0` (caret on 2.x covers latest); devDep `2.4.4` → `2.4.15`.
10 changes: 10 additions & 0 deletions .changeset/tool-versions-install-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@rrlab/biome-plugin": patch
"@rrlab/oxc-plugin": patch
"@rrlab/ts-plugin": patch
"@rrlab/tsdown-plugin": patch
---

`TOOL_VERSIONS` now carries only `install` (the prescriptive pin used by `rr plugins add`). The `peer` field is gone — `package.json#peerDependencies` is the single source of truth for the peer contract. The per-plugin `tool-versions.test.ts` asserts `semver.subset(install, peerDependencies[name])` instead of string-equality with a duplicated `peer` field. No runtime behaviour change — `peer` was never read outside its parity test.

Architectural rationale: `decisions/010-tool-versions-install-only.md`.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"vitest.configSearchPatternExclude": "{**/node_modules/**,**/.*/**,**/*.timestamp-*,**/templates/**}"
"vitest.configSearchPatternExclude": "{**/node_modules/**,**/.*/**,**/*.timestamp-*,**/templates/**}",
"oxc.typeAware": true
}
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"extends": ["@rrlab/biome-config"],
"files": {
"includes": ["**", "!vland/templates"]
Expand Down
69 changes: 69 additions & 0 deletions decisions/007-per-plugin-only-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 007: How should plugins narrow which capabilities they contribute to the registry?

> **Superseded by [009](./009-declarative-plugin-shape.md).** The per-plugin narrowing *principle* is preserved (typing is per-plugin via `keyof ReturnType<capabilities>`), but the `only`-filter implementation moves from copy-paste-per-plugin to a single implementation inside `definePlugin`. 007's rejection of the kernel-helper Option B was conditional on no SDK-shaped centralisation existing inside `@rrlab/cli/plugin`; 009 introduces that centralisation while keeping the kernel-internal contract posture intact.

- **Date**: 2026-05-21
- **Status**: Superseded by 009
- **Files affected**: `run-run/cli/src/plugin/registry.ts`, `run-run/cli/src/plugin/__tests__/registry.test.ts`, `run-run/biome-plugin/src/index.ts`, `run-run/oxc-plugin/src/index.ts`, `run-run/ts-plugin/src/index.ts`, `run-run/tsdown-plugin/src/index.ts`, plus each plugin's `__tests__/` and a new integration test under `run-run/cli/test/integration/`.

## Context

`PluginRegistry.get<K>(kind)` throws when N>1 plugins provide the same capability (`run-run/cli/src/plugin/registry.ts:19-25`). Today that makes biome + oxc mutually exclusive: both register `lint` and `format`. A real use case has emerged — running biome for `lint`/`format` while oxc provides a forthcoming `tsc` capability backed by `oxlint --type-aware --type-check` (`oxlint-tsgolint` is already a peer of `@rrlab/oxc-plugin`). The registry's multi-provider error message already anticipates this: *"Disambiguate by narrowing each plugin's capabilities in run-run.config.ts."* — but no mechanism exists.

`definePlugin<T>` (`run-run/cli/src/plugin/define-plugin.ts:3`) already supports a typed options generic, so per-plugin options are free at the type level.

## Options considered

- **A**: Per-plugin `only?: readonly K[]` option, plugin self-filters its `setup()` return. `K` is typed against the kinds *that plugin* can provide.
- **B**: Kernel helper `narrow(plugin: Plugin, only: PluginKind[]): Plugin` exported from `@rrlab/cli/plugin`.
- **C**: Registry-level config shape change — `plugins: [{ plugin: oxc(), kinds: ['tsc'] }]`.

## Decision: Option A

Each official plugin parameterises `definePlugin<TOptions>` with an options shape that includes `only?: readonly K[]`, where `K` is the union of kinds *that plugin* provides. The plugin self-filters inside `setup()`. Example:

```ts
type OxcKind = "lint" | "format" | "tsc";
type OxcOptions = { only?: readonly OxcKind[] };

const oxc = definePlugin<OxcOptions>((opts = {}) => ({
name: "oxc", apiVersion: 1, install, uninstall,
async setup({ shell }) {
const all = { lint: lintSvc, format: fmtSvc, tsc: tscSvc };
if (!opts.only) return all;
return Object.fromEntries(opts.only.map((k) => [k, all[k]])) as PluginCapabilities;
},
}));
```

Reasons:

- Matches the kernel-agnostic rule (`run-run/CLAUDE.md` → "The kernel is tool-agnostic"): narrowing logic lives in the plugin, the kernel surface stays unchanged.
- Per-plugin typing is sharper than a kernel helper — `biome({ only: ['tsc'] })` is a compile error because biome doesn't provide `tsc`. A `narrow(plugin, kinds[])` helper typed against the global `PluginKind` union loses that.
- Reads better at the call site (`oxc({ only: ['tsc'] })` is configuration on the plugin, matching every other plugin-config knob).
- Zero new kernel exports. `definePlugin<T>` already supports the generic.
- ~3 lines × 4 plugins of duplication is exactly the trade `run-run/CLAUDE.md` endorses: *"Per-plugin duplication of small constants … is the right shape — duplication across plugins is cheap; coupling the kernel to tool details is not."*

### Sub-decisions

1. **Apply `only` to all 4 plugins for uniformity.** Even single-capability plugins (`ts`, `tsdown`) accept the option. The TS union constrains it to that plugin's kinds, so the knob is type-safe and self-documenting. Future-proofs the moment any single-capability plugin grows a second capability.

2. **Throw at `setup()` time when `only` lists a kind the plugin does not provide.** Mostly redundant with TS typing (config is typically `.ts`), but covers the `.mts`-without-strict-TS / `as any` cases. Matches the registry's existing "loud actionable errors" UX.

3. **`install()` stays coarse.** Narrowing is runtime-only. `rr plugins add oxc` continues to install `oxlint`+`oxfmt`+`oxlint-tsgolint` regardless of which capabilities the user later enables. If install footprint becomes a complaint, add per-tool install flags (`--no-tsgolint`) — don't couple install to runtime narrowing.

4. **`rr doctor` honours narrowing automatically.** `collectDistinctDoctors` iterates registered capabilities; narrowed-out capabilities are never registered, so they never appear in doctor output. Intentional, not accidental.

5. **Registry multi-provider error message gets tightened** to reference the new syntax: *"… use `<plugin>({ only: [...] })` to narrow which capabilities each plugin contributes."* The existing test only asserts the plugin names appear, so the wording change is safe.

6. **`rr plugins remove`** does not need to learn about `only`. `ConfigAstService` removes the whole call node regardless of arguments, but an integration test should be added to lock down that magicast doesn't choke on an entry with an object argument.

## Alternatives rejected

- **Option B** (kernel helper `narrow(plugin, only)`): typing is weaker (uses the global `PluginKind` union, accepts kinds the wrapped plugin doesn't provide); call-site reads as a wrapper-around-wrapper; introduces a new kernel concept the kernel-agnostic rule discourages. The ~3-line × 4-plugin duplication of Option A is cheaper than centralisation here.
- **Option C** (registry-level config shape): would change the public `defineConfig` array shape from `Plugin[]` to a union, breaking the `[biome(), ts()]` ergonomic to serve the 5% case. Also re-introduces pre-registry branching in the kernel, which decision 003 deliberately collapsed.

## Notes for human review

- This decision deliberately does NOT couple to the oxc `tsc` capability landing — that's a separate add. Shipping `only` first or together both work; the narrowing decision stands either way.
- Reviewed by `arch-critic` 2026-05-21; the recommendation above mirrors its conclusions.
76 changes: 76 additions & 0 deletions decisions/008-plugin-sdk-split.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# 008: Should the plugin contract become its own package (`@rrlab/plugin`) instead of a subpath of `@rrlab/cli`?

> **Rejected — see [009](./009-declarative-plugin-shape.md).** This proposal was reviewed by `arch-critic` on 2026-05-21 and rejected before being applied. The substantive part of 008 (declarative `capabilities` shape inside `definePlugin`) was absorbed into 009, but the topology change (separate `@rrlab/plugin` package) was not adopted. Preserved as historical record of *why* the package split was considered and rejected — that reasoning is load-bearing for any future revisit of the same idea.

- **Date**: 2026-05-21
- **Status**: Rejected
- **Files affected** (had it been applied): new package `run-run/plugin/`, deletions from `run-run/cli/src/plugin/`, swaps across the 4 plugins' `package.json` (peer + dev from `@rrlab/cli` to `@rrlab/plugin`), all plugin `src/index.ts` imports rewritten. Nothing actually changed in the repo from this proposal.

## Context

`@rrlab/cli/plugin` is a subpath export of the kernel package that ships the entire plugin contract (`Plugin`, `PluginCapabilities`, `definePlugin`, `ToolService`, capability interfaces, `FileOp`/`JsonEdit` types). The 4 official plugins peer-depend on `@rrlab/cli` solely to import that subpath — they never use the host runtime. Decision 007 just landed adding an `only` block duplicated across all 4 plugins (~8 LOC × 4) that the registry's multi-provider error message tells users to write. The duplication is a symptom of two concerns colliding in one package: the *host runtime* (commander, registry impl, file-op engine, bin) and the *plugin SDK* (contract + base classes + factory).

## Options considered

- **A**: Split. New `@rrlab/plugin` package owns the contract + base classes + a redesigned declarative `definePlugin`. Kernel retires the `/plugin` subpath and depends on the new package. `only` and bin-probing become first-class SDK features.
- **B**: Keep `@rrlab/cli/plugin` subpath; add `narrow()` + `probeBins()` helpers to it. Cheap refactor, no topology change.
- **C**: Status quo + accept the per-plugin duplication that 007 endorsed.

## Proposed: Option A (NOT ADOPTED)

The split was conceptually *appealing* — `@rrlab/cli/plugin` as a name does hide the host-vs-SDK distinction. With a dedicated SDK boundary, `only`-filter and bin-probe could stop being "helpers each plugin opts into" and become responsibilities of the framework: the plugin author declares *what* it provides; the SDK handles registration, filtering, and validation.

The kernel-agnostic rule (`run-run/CLAUDE.md` → "The kernel is tool-agnostic") would be preserved verbatim — `@rrlab/plugin` is equally tool-agnostic. The split would be on a different axis (SDK vs. host runtime), not on the tool-knowledge axis the rule guards.

### Sub-decisions that *would have* applied

1. **What lives in `@rrlab/plugin`:** all contract types, capability interfaces, `ToolService` base class + `ToolServiceOptions`, `definePlugin` factory (redesigned — see #2), `PLUGIN_KINDS` constant, structural interface for `ReleaseService` (implementation stays in kernel).

2. **`definePlugin` becomes declarative.** New shape — `setup()` replaced by `capabilities(ctx)`. The SDK applies `only` filter (typed against `keyof ReturnType<capabilities>`), throws on unknown kinds, dedup-probes the services' `pkg` field, and surfaces a canonical "requires X" error.

3. **No imperative `setup()` escape hatch initially.** All 4 plugins fit the declarative shape. Add it only if a future plugin can't be expressed that way.

4. **`@rrlab/cli` no longer exports the `/plugin` subpath.**

5. **`defineConfig` stays in `@rrlab/cli`** (CLI config is a CLI concern).

6. **Peer dependency posture** (impacts decision 001): each plugin would have dropped `@rrlab/cli` from peer + dev and added `@rrlab/plugin`. `@rrlab/cli` would have added `@rrlab/plugin` as a regular `dependencies` — a deliberate carve-out from 001's "all peers."

7. **`apiVersion` stays at 1** — topology change, no contract version bump.

## Why this was rejected (by arch-critic, 2026-05-21)

Arch-critic's verdict in summary:

1. **"Naming dressed as packaging."** Nothing observable breaks in the single-package world. The proposal's own concessions admit the split is invisible to the install/uninstall flow and to `apiVersion`. The only thing it "fixes" is the import string — that's a rename, not a packaging justification.

2. **Publishing a separately versioned `@rrlab/plugin` package implicitly signals "stable API for external authors"** — exactly the opposite of the kernel-internal contract posture the repo-root `CLAUDE.md` makes explicit. The split would invert that posture by accident.

3. **The `requires?` override smell on day 1.** Oxc was 1/4 plugins and would have needed the override immediately for the bin-probe to produce a sensible error message — a 25% miss rate on the abstraction. The SDK telling you "this case doesn't fit, opt out" on its first real consumer is the abstraction telling you it's wrong.

4. **Misread of decision 001.** My draft paraphrased 001's load-bearing reason as "tarball size"; the actual primary reason is "consistency from the user's perspective." The proposed posture (`@rrlab/cli` deps on plugin, plugins peer on plugin) recreates exactly the inconsistency 001 set out to kill: same `@rrlab/*` concept declared two different ways across adjacent packages.

5. **The `apiVersion` claim was sophistry.** 008 argued 007's reasoning "still stands" by redefining "kernel" to exclude `@rrlab/plugin`. That's a definitional move, not a substantive one. Moving the `only`-filter into the SDK *is* substantively what 007 rejected as Option B (kernel helper) — the SDK didn't exist as a concept then. If centralisation was right, 007 should be marked Superseded explicitly (which 009 does), not pretended-preserved.

6. **Test theater.** The proposed `sdk-boundary.test.ts` (parsing plugin source for `@rrlab/cli` imports) and `definePlugin-capabilities-shape.test.ts` were tautologies — asserting the kernel uses the API it has to use to function.

7. **Changeset bump policy was wrong** — peer dep swap is a major bump on plugins, not minor. The draft handwaved this.

## What was kept

The **substance** of 008's redesign (declarative `capabilities`, central `only`, central bin-probe) was correct and worth shipping. It moved into 009 *inside* the existing `@rrlab/cli/plugin` subpath — same enforcement, none of the topology costs. 009 also added the helpers `decideScaffold` and `pickPreset` to capture install-time pattern duplication, plus a `plugin-discipline.test.ts` enforcement that 008 did not anticipate.

## When to revisit this

A package split would become justified if a concrete pressure appears that the single-package model can't absorb. Candidates worth re-opening this decision for:

- A real external plugin author wanting to publish to npm (the kernel-internal posture changes).
- `@rrlab/cli` growing native dependencies that plugin authors don't want to install transitively.
- The kernel's release cadence diverging significantly from the plugins' (today they ship together; if they didn't, decoupled versioning would matter).

None of those exist today. Until they do, the import string `from "@rrlab/cli/plugin"` is a cheap cognitive cost worth paying for the topology simplicity.

## Notes for human review

- This file is preserved as a "Rejected" record rather than deleted because (a) 009 references it explicitly in its alternatives section, and (b) the negative knowledge — *why* the package split was rejected — is the load-bearing part for anyone considering the same idea in the future. Deleting it removes that and leaves only the gap in the directory listing.
- `decisions/README.md` currently lists three statuses (Applied, Pending human review, Overridden). "Rejected" is a fourth that the README doesn't formally name; if more proposals get drafted-then-rejected, the README should be amended to acknowledge it as a first-class status.
Loading
Loading