Skip to content

feat(rrlab): declarative plugin shape#229

Merged
rqbazan merged 5 commits into
mainfrom
feat/declarative-plugin-shape
May 21, 2026
Merged

feat(rrlab): declarative plugin shape#229
rqbazan merged 5 commits into
mainfrom
feat/declarative-plugin-shape

Conversation

@rqbazan
Copy link
Copy Markdown
Member

@rqbazan rqbazan commented May 21, 2026

Summary

Three logically-grouped changes that the squash unified:

  1. Declarative plugin shape (decision 009).
  2. Dogfooding oxc({ only: ['tsc'] }) at the repo root.
  3. tsgolint cleanup the dogfood swap surfaced (19 warnings → 0).

1. Declarative plugin shape — decision 009

Plugins now declare capabilities (a { kind: service } map) instead of implementing an imperative setup().

Kernel (@rrlab/cli/plugin):

  • definePlugin centralizes only narrowing, typed against keyof ReturnType<capabilities>.
  • Bin probes are deduplicated across services that share a pkg.
  • A single canonical "requires X to be installed" error is emitted when a peer-installed tool is missing.
  • New helpers decideScaffold and pickPreset absorb the install-time prompts that were duplicated across plugins.

Discipline:

  • test/integration/plugin-discipline.test.ts enforces (via AST/regex scan over each plugin's sources) that plugin code does not touch ctx.prompts directly.

UX:

  • rr check's help summary lists the actual underlying tools instead of the generic (run-run) label — e.g. (biome, oxlint) when biome composes jsc and oxc provides tsc.
  • Composed providers flatten: biome + biomebiome.
  • @rrlab/oxc-plugin drops the oxlint:tsc UI qualifier — it was inconsistent with how biome (3 capabilities) and ts (1 capability) label themselves; the command name already disambiguates the mode.

Decisions log:

  • 007 (per-plugin only) → Superseded by 009.
  • 008 (@rrlab/plugin package split) → Rejected (record preserved for the reasoning).
  • decisions/README.md adds Rejected as a fourth status.

2. Dogfooded oxc({ only: ['tsc'] })

  • Root run-run.config.mts swaps ts() for oxc({ only: ['tsc'] }).
  • rr tsc now runs oxlint --type-aware --type-check via oxlint-tsgolint instead of TypeScript's tsc.
  • Adds oxlint, oxfmt, and oxlint-tsgolint as workspace-root devDependencies.

This is the exact configuration the centralized only narrowing was designed for, so the swap also validates the kernel work in (1).


3. tsgolint cleanup — 19 warnings → 0 across 8 packages

run-run/cli:

  • Doctor, Formatter, Linter, StaticChecker, TypeChecker, Packer switch from method syntax to function-typed property syntax in types/tool.ts and plugin/types.ts. Reflects reality — none of the impls bind this — and fixes 7 unbound-method warnings in composed-jsc.test.ts where vi.mocked(linter.lint) / expect(formatter.format) were flagged.
  • PluginDefinition.install / uninstall annotated with this: void in define-plugin.ts to satisfy unbound-method when forwarded as property references.
  • plugins.ts drops ?? {} fallbacks in spreads (3 unicorn(no-useless-fallback-in-spread) warnings).
  • plugins.ts:215 stringifies the unknown branch in a template literal with String(err) (restrict-template-expressions).
  • ctx.ts:57 captures plugin.apiVersion to a got: number local before the guard so the template literal isn't typed never.

shared/loggy:

  • LogFnOptions | unknownLogFnOptions | string in info, trace, warn, start, success. The | unknown collapsed to unknown, losing design intent; | string matches the actual call sites.
  • New LogFn type in types.ts codifies the signature. AnyLogger's log methods upgrade from AnyLogFn (permissive) to LogFn so consumers get useful parameter typing instead of unknown[]. debug and error stay as AnyLogFn — they don't take LogFnOptions.

IDE:

  • .vscode/settings.json enables oxc.typeAware: true so the IDE matches the CLI's tsgolint behaviour.

Test plan

  • pnpm rr check (lint + format + tsc) green across all 8 packages — 0 warnings, 0 errors under oxc({ only: ['tsc'] }).
  • pnpm test green — new tests: define-plugin, bin-probe, decide-scaffold, pick-preset, only, plugin-discipline, plus per-plugin setup.test.ts.
  • Lefthook pre-commit (jscheck + tscheck) passed on the squashed commit.
  • Manual smoke: rr check --help shows real tool names (e.g. (biome, oxlint)) instead of (run-run).
  • Manual smoke: install a plugin with a missing peer tool, confirm the canonical "requires X to be installed" error appears once.

🤖 Generated with Claude Code

…d bin-probe

Plugins now declare `capabilities` (a `{ kind: service }` map) rather than
implementing an imperative `setup()`. `@rrlab/cli/plugin`'s `definePlugin`
applies `only` narrowing (typed against `keyof ReturnType<capabilities>`),
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.

Two new helpers exported from `@rrlab/cli/plugin` capture the remaining
install-time pattern duplication across plugins: `decideScaffold` for the
"file exists? → ask user → create/patch/overwrite/skip" dialog, and
`pickPreset` for preset selection. Plugin sources are now forbidden from
touching `ctx.prompts` directly — `test/integration/plugin-discipline.test.ts`
enforces this with an AST/regex scan over each plugin's sources.

`rr check`'s help summary now lists the actual underlying tools instead of
the generic `(run-run)` label — e.g. `(biome, oxlint)` when biome composes
jsc and oxc provides tsc. Mirrors the same provider resolution as `rr jsc`
and `rr tsc` and flattens composed providers so `biome + biome` collapses
to `biome`.

`@rrlab/oxc-plugin` drops the `oxlint:tsc` UI qualifier; the command name
already disambiguates the mode and the qualifier was inconsistent with how
biome (3 capabilities) and ts (1 capability) label themselves.

Decisions: 007 (per-plugin `only`) marked Superseded by 009; 008 (package
split into `@rrlab/plugin`) kept as a Rejected record because the reasoning
for rejection is worth preserving. `decisions/README.md` adds Rejected as
a fourth status with explicit semantics. 009 is the load-bearing record.

Dogfooding: the repo-root `run-run.config.mts` swaps `ts()` for
`oxc({ only: ['tsc'] })` so `rr tsc` runs `oxlint --type-aware --type-check`
via `oxlint-tsgolint` instead of TypeScript's `tsc`. Adds `oxlint`, `oxfmt`,
and `oxlint-tsgolint` as workspace-root devDependencies. The biome +
`oxc({ only: ['tsc'] })` combination is the exact configuration the
centralized `only` narrowing was designed for.

tsgolint cleanup (19 warnings → 0 across 8 packages):

`run-run/cli`:
- `Doctor`, `Formatter`, `Linter`, `StaticChecker`, `TypeChecker`, `Packer`
  switch from method syntax to function-typed property syntax in `types/tool.ts`
  and `plugin/types.ts`. Reflects reality — none of the impls bind `this`
  inside these methods — and fixes 7 `unbound-method` warnings in
  `composed-jsc.test.ts` where `vi.mocked(linter.lint)` / `expect(formatter.format)`
  were flagged.
- `PluginDefinition.install`/`uninstall` annotated with `this: void` in
  `define-plugin.ts` to satisfy `unbound-method` when forwarded as property
  references.
- `plugins.ts` drops the `?? {}` fallbacks in spreads (3 warnings from
  `unicorn(no-useless-fallback-in-spread)`).
- `plugins.ts:215` stringifies the `unknown` branch in a template literal
  with `String(err)` (`restrict-template-expressions`).
- `ctx.ts:57` captures `plugin.apiVersion` to a `got: number` local before
  the guard so the template literal isn't typed `never`.

`shared/loggy`:
- `LogFnOptions | unknown` → `LogFnOptions | string` in `info`, `trace`,
  `warn`, `start`, `success`. The `| unknown` collapsed to `unknown`,
  losing the design intent; `| string` captures the actual call sites.
- New `LogFn` type in `types.ts` codifies the signature; `AnyLogger`'s
  log methods upgrade from `AnyLogFn` (permissive) to `LogFn` so
  consumers get useful parameter typing instead of `unknown[]`. `debug`
  and `error` stay as `AnyLogFn` — they don't take `LogFnOptions`.

`.vscode/settings.json`: enable `oxc.typeAware: true` so the IDE matches
the CLI's tsgolint behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 21, 2026

🦋 Changeset detected

Latest commit: 9887d65

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@rrlab/biome-plugin Major
@rrlab/oxc-plugin Major
@rrlab/ts-plugin Major
@rrlab/tsdown-plugin Major
@rrlab/cli Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

CI's `rr tsc dispatches to oxlint with --type-aware --type-check` in
`only.test.ts` hit vitest's 5000ms default — the run took 6628ms because
oxlint with `--type-aware --type-check` spawns `oxlint-tsgolint` and loads
the fixture's TypeScript program. Locally it fits under 5s on faster
hardware; CI runners don't.

Pin the timeout for this specific test to 15000ms (vitest's third-arg
form). The sibling `rr lint` / `rr format` / `rr jsc` tests stay on the
default since biome is sub-second.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vland-bot
Copy link
Copy Markdown
Contributor

vland-bot Bot commented May 21, 2026

Preview release

Latest commit: 9887d65

Some packages have been released:

Package Version Install
@rrlab/biome-plugin 0.1.2-git-9887d65.0 @rrlab/biome-plugin@0.1.2-git-9887d65.0
@rrlab/cli 0.0.4-git-9887d65.0 @rrlab/cli@0.0.4-git-9887d65.0
@rrlab/oxc-plugin 0.1.2-git-9887d65.0 @rrlab/oxc-plugin@0.1.2-git-9887d65.0
@rrlab/ts-plugin 0.1.2-git-9887d65.0 @rrlab/ts-plugin@0.1.2-git-9887d65.0
@rrlab/tsdown-plugin 0.1.2-git-9887d65.0 @rrlab/tsdown-plugin@0.1.2-git-9887d65.0
@vlandoss/loggy 0.2.2-git-9887d65.0 @vlandoss/loggy@0.2.2-git-9887d65.0
@vlandoss/vland 0.3.1-git-9887d65.0 @vlandoss/vland@0.3.1-git-9887d65.0

Note

Use the PR number as tag to install any package. For instance:

pnpm add @rrlab/biome-plugin@pr-229

rqbazan and others added 3 commits May 21, 2026 17:04
…r` field

`TOOL_VERSIONS` in each `@rrlab/*-plugin/src/tool-versions.ts` carried
both `install` and `peer` ranges, kept in sync with `package.json#peerDependencies`
by a per-plugin parity test. But `peer` was never read at runtime — the
kernel's bin-probe error names the missing package without quoting a
range, and the parity test was its only consumer. Two sources of truth
for the same value, with one of them dead code.

Collapse to one: `tool-versions.ts` keeps only `install` (the
prescriptive pin used by `rr plugins add`'s nypm call). The peer
contract lives in `package.json#peerDependencies` where npm already
enforces it. The per-plugin `tool-versions.test.ts` switches from
string-equality with `peer` to `semver.subset(install, peerDependencies[name])` —
a stronger invariant that actually checks the install range falls inside
the supported peer range.

Mechanics:
- Drop `peer` from `tool-versions.ts` in biome/oxc/ts/tsdown plugins.
- Rewrite each `__tests__/tool-versions.test.ts` to assert subset via
  semver. Falls back to `satisfies(minVersion(install), peerRange)` when
  `subset()` can't decide (semver-subset is more conservative than
  satisfies for hyphen/x-ranges; the min-version check is the practical
  invariant).
- Add `semver` + `@types/semver` as devDeps in each plugin (host-local
  to the plugin per "each plugin owns its tool" — no kernel coupling).
- Entries without a corresponding `peerDependencies` entry (e.g.
  `@types/node` in ts-plugin) are skipped — they're install-time
  conveniences, not contracts.

This commit is purely structural — install ranges are unchanged.
Follow-up commit refreshes the stale 0.x install ranges that the dead
`peer` field was masking.

Decision: decisions/010-tool-versions-install-only.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Deps

`^0.X.Y` semver on 0.x packages only allows patch bumps, so the oxc-plugin
install ranges stranded users on old minors:

- `oxfmt`: install `^0.30.0` → `^0.51.0`; devDep `0.35.0` → `0.51.0`.
  npm latest was 0.51.0; `^0.30.0` only allowed 0.30.x.
- `oxlint-tsgolint`: install `^0.15.0` → `^0.23.0`; devDep `0.15.0` → `0.23.0`.
  npm latest was 0.23.0; `^0.15.0` only allowed 0.15.x.
- `oxlint`: install stays `^1.0.0` (caret on 1.x already covers 1.66.x);
  devDep `1.50.0` → `1.66.0` so the plugin tests against current binary.
- `@biomejs/biome`: install stays `^2.0.0` (caret covers latest); devDep
  `2.4.4` → `2.4.15` so the dogfooded lint matches what users get.

Workspace-root devDeps mirror the prescriptive ranges:
- `oxfmt`: `^0.30.0` → `^0.51.0`.
- `oxlint-tsgolint`: `^0.15.0` → `^0.23.0`.

Two follow-on changes biome 2.4.15 surfaced:
- `biome.json` `$schema` URL bumped to 2.4.15 (otherwise biome warns
  about config-schema-version mismatch).
- `vland/cli/src/actions/init.ts:26` — `!name || !name.trim()` rewritten
  as `!name?.trim()` for the new `useOptionalChain` rule that biome
  2.4.15 enables by default.

No code behaviour changes; this is pure version refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plugin peers (`@rrlab/cli`) move from `workspace:*` to `workspace:^`, and
Changesets gets `onlyUpdatePeerDependentsWhenOutOfRange: true`. Once on
1.x, CLI patches/minors stay in `^1.x.y` and stop propagating to plugin
majors; CLI majors still escape `^1.0.0` and cascade as intended.

The pending changeset escalates `@rrlab/cli` and the 4 plugins to major,
landing the whole ecosystem at 1.0.0 in this PR (declarative plugin
shape is itself a contract break, so 1.0 is honest about it).

Rationale: decisions/011-cli-peer-bump-strategy.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rqbazan rqbazan merged commit 696de73 into main May 21, 2026
3 checks passed
@rqbazan rqbazan deleted the feat/declarative-plugin-shape branch May 21, 2026 22:29
@vland-bot vland-bot Bot mentioned this pull request May 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant