diff --git a/.changeset/config.json b/.changeset/config.json index c821a2ec..ed69194f 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,8 @@ "access": "restricted", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": [], + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true + } } diff --git a/.changeset/plugin-only-narrowing.md b/.changeset/plugin-only-narrowing.md new file mode 100644 index 00000000..0ce371da --- /dev/null +++ b/.changeset/plugin-only-narrowing.md @@ -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`. diff --git a/.changeset/refresh-stale-tool-versions.md b/.changeset/refresh-stale-tool-versions.md new file mode 100644 index 00000000..c1262616 --- /dev/null +++ b/.changeset/refresh-stale-tool-versions.md @@ -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`. diff --git a/.changeset/tool-versions-install-only.md b/.changeset/tool-versions-install-only.md new file mode 100644 index 00000000..ff6662fd --- /dev/null +++ b/.changeset/tool-versions-install-only.md @@ -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`. diff --git a/.vscode/settings.json b/.vscode/settings.json index 63b4d15a..1ec04a30 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "vitest.configSearchPatternExclude": "{**/node_modules/**,**/.*/**,**/*.timestamp-*,**/templates/**}" + "vitest.configSearchPatternExclude": "{**/node_modules/**,**/.*/**,**/*.timestamp-*,**/templates/**}", + "oxc.typeAware": true } diff --git a/biome.json b/biome.json index 4356bff1..f97df0ea 100644 --- a/biome.json +++ b/biome.json @@ -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"] diff --git a/decisions/007-per-plugin-only-option.md b/decisions/007-per-plugin-only-option.md new file mode 100644 index 00000000..a2ad08d5 --- /dev/null +++ b/decisions/007-per-plugin-only-option.md @@ -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`), 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(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` (`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` 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((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` 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 `({ 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. diff --git a/decisions/008-plugin-sdk-split.md b/decisions/008-plugin-sdk-split.md new file mode 100644 index 00000000..acf3c490 --- /dev/null +++ b/decisions/008-plugin-sdk-split.md @@ -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`), 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. diff --git a/decisions/009-declarative-plugin-shape.md b/decisions/009-declarative-plugin-shape.md new file mode 100644 index 00000000..71c4fcd2 --- /dev/null +++ b/decisions/009-declarative-plugin-shape.md @@ -0,0 +1,158 @@ +# 009: How do we make the plugin pattern hard to deviate from — one canonical shape across all 4 plugins? + +- **Date**: 2026-05-21 +- **Status**: Pending human review +- **Files affected**: `run-run/cli/src/plugin/define-plugin.ts` (redesigned), `run-run/cli/src/plugin/types.ts` (Plugin.setup → Plugin.capabilities; new helper types), `run-run/cli/src/plugin/decide-scaffold.ts` (new), `run-run/cli/src/plugin/pick-preset.ts` (new), `run-run/cli/src/plugin/bin-probe.ts` (new), `run-run/cli/src/lib/plugin.ts` (re-exports new helpers), `run-run/cli/src/services/ctx.ts` (call `capabilities` instead of `setup`), each of `run-run/{biome,oxc,ts,tsdown}-plugin/src/index.ts` (rewrite to declarative shape; drop local `only`/bin-probe/scaffold-decision/preset-pick code; consume helpers from `@rrlab/cli/plugin`), each plugin's `src/__tests__/setup.test.ts` (rewrite against new shape), `run-run/cli/src/plugin/__tests__/define-plugin.test.ts` (new), `run-run/cli/src/plugin/__tests__/decide-scaffold.test.ts` (new), `run-run/cli/src/plugin/__tests__/pick-preset.test.ts` (new), `run-run/cli/src/plugin/__tests__/bin-probe.test.ts` (new), `run-run/cli/test/integration/plugin-discipline.test.ts` (new — AST scan rejecting direct `ctx.prompts` in plugins), `decisions/007-per-plugin-only-option.md` (status changed to Superseded with pointer to 009). + +## Context + +The 4 official plugins reimplement the same housekeeping in subtly different ways: an `only`-filter block, a bin-probe try/catch, an "exists → ask → patch/skip/overwrite" scaffold-decision dialog, and a preset-pick prompt. The duplication is not just LOC — it's *prompt strings*, *error message formats*, and *decision branches* that drift independently. Decision 007 endorsed the `only`-block duplication on the principle that "duplication across plugins is cheap"; user feedback after 007 reframes the goal as "the plugin pattern should be hard to break" — under that framing, duplication-as-copy-paste is the failure mode, not the solution. + +A prior draft (`decisions/008-plugin-sdk-split.md`, rejected by arch-critic on 2026-05-21) proposed splitting the contract into a new `@rrlab/plugin` package. The split was rejected because the enforcement gain comes from `definePlugin`'s shape, not from the package boundary — and the split incurred a deviation from decision 001, a version-skew vector, and an "external API" signal that contradicts the kernel-internal contract posture. This decision keeps the contract inside `@rrlab/cli/plugin` and captures the enforcement directly there. + +## Options considered + +- **A**: Status quo + small helpers (`narrow`, `probeBins`) opt-in. Each plugin still chooses whether to use them. The 007 "duplication is cheap" trade stands. +- **B**: Redesign `definePlugin` to be declarative (`capabilities` map instead of imperative `setup`). Centralise `only`, bin-probe, scaffold-decision, and preset-pick as first-class features the SDK applies — plugins declare data, not procedure. +- **C**: Split contract into `@rrlab/plugin` package + redesign (rejected via 008's arch-critic review). + +## Decision: Option B + +The user-stated goal is "the plugin pattern should be hard to break." That outcome comes from making the canonical path the *only* path: the SDK accepts data and runs the procedure, instead of accepting a procedure and trusting the plugin to follow convention. Centralisation lives inside `@rrlab/cli/plugin` — same subpath, no new package, no decision 001 churn. The cost is a one-time rewrite of the 4 plugins; the payoff is that the next plugin (5th, 6th, ...) cannot diverge on `only`, bin-probe, scaffold prompts, or preset selection even if the author wanted to — those shapes are typed into `definePlugin`'s signature. + +### Sub-decisions + +1. **`Plugin.setup` → `Plugin.capabilities`.** The plugin returns a record `{ kind: service }` rather than imperatively calling registry methods. `only` is typed against `keyof ReturnType` so `biome({ only: ["tsc"] })` is a compile error. The SDK applies the `only` filter and the unknown-kind runtime guard before handing the result to the registry. The plugin author writes zero filtering code. + +2. **Bin-probe lives inside `definePlugin`.** The SDK introspects the `pkg` field of each `ToolService` returned by `capabilities()`, dedups, probes the distinct set in parallel via `ToolService.getBinDir`, and on failure throws a single canonical error: + + ``` + @rrlab/-plugin requires [, ]... to be installed in the host project. + Run: rr plugins add (or: pnpm add -D ...) + ``` + + `` comes from the plugin definition. No per-plugin override needed on day 1 — the oxc case (3 services backed by 2 distinct bins) resolves cleanly via deduplication on `pkg`. If a future plugin demonstrates that introspection-from-services is genuinely insufficient, *then* add a `requires?: string[]` override; do not add it preemptively. + +3. **`decideScaffold(ctx, opts)` helper.** Centralises the "file exists? ask user → patch/skip/overwrite vs. create" dialog used by biome, ts, and tsdown today. Signature: + + ```ts + type ScaffoldDecision = "create" | "patch" | "overwrite" | "skip"; + + export async function decideScaffold( + ctx: InstallContext, + opts: { + label: string; // e.g. "biome.json", "tsconfig.json" + fileExists: boolean; + patchHint: string; // e.g. "add @rrlab/biome-config to extends, keep my other settings" + } + ): Promise; + ``` + + Honours `ctx.flags.yes` / `ctx.flags.nonInteractive` (returns `"create"` if !exists, `"patch"` if exists — except tsdown's exception below). The four option labels become canonical; the prompt messages become canonical. Plugins that need "skip on existing under `--yes`" semantics (tsdown today) pass a flag `unattendedExistingAction: "skip" | "patch"` (default `"patch"`). Captures the only legitimate divergence. + +4. **`pickPreset(ctx, opts)` helper.** Centralises the preset-selection prompt used by ts and tsdown. Signature: + + ```ts + export async function pickPreset( + ctx: InstallContext, + opts: { + message: string; + presets: Record; + defaultPreset: K; + } + ): Promise; + ``` + + Honours `--yes` / non-interactive (returns `defaultPreset`). + +5. **Plugin authoring rule: plugins do NOT use `ctx.prompts` directly.** All user interaction in `install()` / `uninstall()` flows through `decideScaffold` and `pickPreset`. New helpers added here as new patterns emerge. This is the rule that makes new plugins boring to write *and* impossible to drift. + +6. **Enforce rule #5 mechanically.** A new test `run-run/cli/test/integration/plugin-discipline.test.ts` scans each `run-run/*-plugin/src/**/*.ts` and asserts no AST node matches `ctx.prompts.` outside of allowlisted helper internals. Cheap, fast, and locks the discipline in for the lifetime of the repo. This is *not* tautological — it asserts about plugin code, which we explicitly want to constrain. + +7. **`pathExists` is dropped as a shared helper.** It's `try { await fs.access(p); return true } catch { return false }` — 4 LOC, fine inline. Or move to `@vlandoss/clibuddy` if it accumulates a third user. Don't add it to `@rrlab/cli/plugin`; the SDK should host things that enforce shape, not random fs utilities. + +8. **`definePlugin`'s generic still carries the plugin's options shape**. `definePlugin(...)` is unchanged at the call signature; only the returned `Plugin` object's interior shape (`capabilities` instead of `setup`) is new. Plugin-specific options (e.g. nothing today, but a hypothetical `biome({ schema: '2.5.0' })`) keep working. + +9. **`apiVersion` stays at 1.** Internal contract evolution; no version bump from the registry's perspective. + +10. **Decision 007 is marked Superseded by 009.** The honest reading is that centralising the `only`-filter inside `definePlugin` *is* substantively what 007 rejected as Option B (kernel helper) — the SDK didn't exist as a concept then. 007's typed-against-plugin-kinds requirement is preserved (via `keyof` in this design), but the "where the code lives" answer changes. A pointer header is added to `decisions/007-per-plugin-only-option.md`. + +11. **Decision 008 is deleted.** Per `decisions/README.md`'s "delete is preferred when the override fully replaces it" — 008's substance lives in 009 (declarative shape) without 008's topology change. + +12. **Decision 001 is untouched.** No package split, no peer-vs-deps carve-out, no consistency loss. + +### Test plan + +**Layer 1 — SDK unit tests in `run-run/cli/src/plugin/__tests__/`** (kernel package, no new package): + +- `define-plugin.test.ts`: + - Plugin returned exposes `Plugin` shape the registry expects. + - `only` filter applied to the returned capabilities map. + - `only` with unknown kind throws with the canonical error message (the plugin-author UX 007 #2 codified). + - Type-level: `expectTypeOf[0]["only"]>().toEqualTypeOf()` — `only` is constrained to the plugin's own kinds via `keyof ReturnType`. + +- `bin-probe.test.ts`: + - Distinct-`pkg` deduplication: services sharing a `pkg` produce one probe. + - Failure path lists distinct pkg names in the error message. + - Parallel execution (assert via a counter + a delayed mock). + +- `decide-scaffold.test.ts`: + - `--yes` + `!exists` → `"create"`. + - `--yes` + exists + default `unattendedExistingAction` → `"patch"`. + - `--yes` + exists + `unattendedExistingAction: "skip"` → `"skip"` (the tsdown case). + - Interactive paths drive the prompt with the canonical labels (snapshot the prompt calls). + +- `pick-preset.test.ts`: + - `--yes` returns `defaultPreset`. + - Interactive returns the user's choice. + - Cancellation throws "Cancelled by user." + +**Layer 2 — kernel tests** (existing, must stay green without behaviour change): + +- `run-run/cli/src/plugin/__tests__/registry.test.ts` — registry consumes the post-redesign `Plugin` shape. Multi-provider error message wording from 007 is preserved. +- `run-run/cli/src/services/__tests__/json-edit.test.ts` — unchanged. +- `run-run/cli/src/services/__tests__/config-ast.test.ts` — unchanged. + +**Layer 3 — plugin tests, rewritten** (`run-run/*-plugin/src/__tests__/setup.test.ts`): + +- Each plugin's existing `setup.test.ts` (added in this branch) is rewritten to exercise the declarative path. Assertions about `only` filtering and bin-absent errors stay — they now come from the SDK's behaviour, hit via the plugin under test. + +**Layer 4 — integration tests** (`run-run/cli/test/integration/`): + +- `only.test.ts` (existing): kept; fixture continues to work because the user-facing `biome({ only: [...] })` call shape is unchanged. +- `plugins.test.ts` (existing): `rr plugins add` / `remove` across all 4 plugins. Must stay green without modification — install/uninstall behaviour is unchanged. +- **NEW `plugin-discipline.test.ts`**: AST-scans `run-run/*-plugin/src/**/*.ts` and asserts: + - No direct `ctx.prompts.` call expression in any plugin source file. + - No plugin source file imports `@clack/prompts` directly. + + Fails the suite if a new plugin tries to roll its own prompt. This is the enforcement test — the mechanical reason new plugins stay boring. + +### Migration order (single PR / changeset wave) + +1. Add new helpers (`decide-scaffold.ts`, `pick-preset.ts`, `bin-probe.ts`) under `run-run/cli/src/plugin/`. +2. Redesign `define-plugin.ts` (declarative `capabilities` + integrated `only` + integrated bin-probe). +3. Update `run-run/cli/src/lib/plugin.ts` to re-export the new helpers from `@rrlab/cli/plugin`. +4. Update `run-run/cli/src/plugin/types.ts`: `Plugin.setup` → `Plugin.capabilities`. +5. Update `run-run/cli/src/services/ctx.ts` to call `capabilities()` instead of `setup()`. +6. Add SDK unit tests (Layer 1). +7. Rewrite each plugin's `src/index.ts` against the new shape; delete local `only`/bin-probe/`decideScaffoldAction`/`pickPreset`/`pathExists` definitions. +8. Rewrite each plugin's `setup.test.ts` against the new shape. +9. Add `plugin-discipline.test.ts`. +10. Mark `decisions/007-per-plugin-only-option.md` as Superseded with a pointer header. +11. Delete `decisions/008-plugin-sdk-split.md`. +12. Changeset: minor on `@rrlab/cli` (contract change, new public helpers on the subpath), minor on each plugin (consumes new helpers; user-facing surface unchanged). No major because the user's `run-run.config.mts` does not change. +13. `pnpm build && pnpm test && pnpm rr check` green. + +## Alternatives rejected + +- **Option A** (status quo + opt-in helpers): Doesn't address the goal. As long as the helpers are opt-in, "each plugin does whatever it wants" survives — a plugin can skip them. The whole value of this decision is that the canonical path is the *only* path. +- **Option C** (`@rrlab/plugin` package + redesign): Rejected via 008's arch-critic review. The enforcement gain comes from `definePlugin`'s shape, not from a package boundary. The split's costs (decision 001 deviation, version-skew vector, "external API" signalling against kernel-internal posture) are not bought by any concrete enforcement improvement. 009 captures the substance without the topology change. +- **Day-1 `requires?` override on bin-probe**: Rejected as preemptive. The oxc case resolves via `pkg`-dedup. Add the override only when a future plugin proves it necessary. +- **`pathExists` as a shared helper in `@rrlab/cli/plugin`**: Rejected. The SDK should host things that enforce shape; `pathExists` is a generic fs util. Drop it (use `fs.access` inline) or move it to `@vlandoss/clibuddy` if a third user emerges. + +## Notes for human review + +- 007 is being honestly Superseded, not "reinterpreted to still apply." The Supersede header on 007 should read something like: "Superseded by 009. The per-plugin narrowing principle is preserved (typing is per-plugin via `keyof`), 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; 009 introduces that centralisation while keeping the kernel-internal contract posture intact." +- 008 is deleted, not Overridden — its core proposal (package split) is not adopted in any form. 009 absorbs only 008's secondary idea (declarative `capabilities`) and re-anchors it inside the existing kernel package. +- The `plugin-discipline.test.ts` AST scan is the part most worth a human gut-check. It's the difference between "a convention" and "an enforced rule." If you'd rather start with a Biome rule (via `@rrlab/biome-config`) instead of a test, that's also fine and probably more idiomatic in this stack — but a test is the smallest possible enforcement and lands inside this PR. +- The `decideScaffold` helper's `unattendedExistingAction` flag exists because tsdown today skips on existing+yes (since the existing file may be user-written code) while biome/ts patch on existing+yes (since the existing file is JSON we can edit safely). The flag captures that single legitimate divergence; everything else collapses. diff --git a/decisions/010-tool-versions-install-only.md b/decisions/010-tool-versions-install-only.md new file mode 100644 index 00000000..4715e2a1 --- /dev/null +++ b/decisions/010-tool-versions-install-only.md @@ -0,0 +1,29 @@ +# 010: Should `TOOL_VERSIONS` carry a separate `peer` field, or derive peer ranges from each plugin's `package.json#peerDependencies`? + +- **Date**: 2026-05-21 +- **Status**: Applied +- **Files affected**: `run-run/{biome,oxc,ts,tsdown}-plugin/src/tool-versions.ts`, the matching `src/__tests__/tool-versions.test.ts` in each plugin + +## Context + +Each official plugin (`@rrlab/{biome,oxc,ts,tsdown}-plugin`) used to declare per-tool versions in two places — `src/tool-versions.ts` with `{ install, peer }` fields, and `package.json#peerDependencies`. The per-plugin `tool-versions.test.ts` asserted exact-string equality between `peer` and `peerDependencies[name]`, so the two stayed in sync; but `peer` was never consumed anywhere at runtime (the kernel's bin-probe message names the missing package without quoting a range). The result was two sources of truth where only one was load-bearing, and version drift had set in: `oxfmt` and `oxlint-tsgolint` carried stale `install` ranges (`^0.30.0` and `^0.15.0` respectively) while npm latest was many minors ahead, hidden by the fact that 0.x caret semver doesn't allow minor bumps. + +## Options considered + +- **A**: Status quo — leave both fields, bump them in lock-step manually. Cheap today, but the drift recurs. +- **B**: Add a Renovate `customManagers` rule that pattern-matches `install`/`peer` strings in `tool-versions.ts` and bumps both files in one PR. Solves drift but adds repo-level automation config that has to be maintained alongside the code. +- **C**: Drop `peer` from `TOOL_VERSIONS`, leave only `install`. Treat `package.json#peerDependencies` as the single source of truth for the peer contract (npm enforces it anyway). Rewrite the parity test to `semver.subset(install, peerDependencies[name])` so an `install` range that escapes the peer contract is caught in CI. + +## Decision: Option C + +`peer` was dead code — the parity test was the only consumer, and it was just guarding internal consistency between two redundant fields. Eliminating it collapses the per-tool data model to one prescriptive field (`install`, used by `rr plugins add`'s `nypm.addDependency` call) and lets npm's existing `peerDependencies` mechanism own the contract. `semver.subset(install, peer)` is a stronger invariant than string-equality with `peer` was: it actually checks that the prescribed install range falls inside what the plugin claims to support, which is the semantic property that mattered all along. Renovate (option B) remains compatible — its native `package.json` manager handles `peerDependencies` and `devDependencies` directly, leaving `install` as the only spot that needs ad-hoc bumps or a small `customManagers` rule later. + +## Alternatives rejected + +- Option A: doesn't solve the recurring drift — the 0.x semver trap (`^0.X.Y` ≡ `>=0.X.Y <0.(X+1).0`) silently strands the install range any time upstream cuts a minor. +- Option B: solves drift but pre-commits to a Renovate-specific automation. Option C is a code-side fix that composes with B later if/when Renovate is enabled; doing B without C still leaves the dead `peer` field and the weaker string-equality test. + +## Notes for human review + +- The `install` range is now the only prescriptive value per tool. For 0.x packages the maintainer should usually pin with `^0.X.Y` matching the latest minor on npm; for ≥1.x packages the caret behaves intuitively. +- `@types/node` lives in `TOOL_VERSIONS` (used by `ts-plugin`'s `install()` when `presetInfo.needsNode`) but has no corresponding `peerDependencies` entry. The new test skips entries without a peer-side counterpart — `@types/node` is an install-time convenience, not a contract. diff --git a/decisions/011-cli-peer-bump-strategy.md b/decisions/011-cli-peer-bump-strategy.md new file mode 100644 index 00000000..55d5d2f4 --- /dev/null +++ b/decisions/011-cli-peer-bump-strategy.md @@ -0,0 +1,42 @@ +# 011: How should bumps of `@rrlab/cli` propagate to the 4 official plugins via Changesets? + +- **Date**: 2026-05-21 +- **Status**: Applied +- **Files affected**: `.changeset/config.json`, `run-run/{biome,oxc,ts,tsdown}-plugin/package.json` (peer range), `.changeset/plugin-only-narrowing.md` (CLI bump escalated to major) + +## Context + +The 4 official plugins declare `@rrlab/cli` as a `peerDependency`. With Changesets' defaults (`onlyUpdatePeerDependentsWhenOutOfRange: false`), any non-`patch` bump of a peer automatically promotes its dependents to `major` — that lives in `@changesets/assemble-release-plan` `determine-dependents.ts#shouldBumpMajor`. The result was that even a routine `minor` to `@rrlab/cli` cascaded into 4 plugin majors, which doesn't reflect the actual contract: a minor or patch of the kernel never breaks the plugin SDK. + +The default is also entangled with the `workspace:*` range, which Changesets resolves to the *exact* old version when reading the peer range, so the "is the new version still in range?" check would always fail anyway. + +## Options considered + +- **A**: Move `@rrlab/cli` out of `peerDependencies` into `dependencies` in each plugin. With `updateInternalDependencies: "patch"`, any CLI bump becomes a plugin patch automatically. Loses the peer contract (each plugin would carry its own copy in installs that don't deduplicate). +- **B**: Bump `@rrlab/cli` to `1.0.0`, switch the plugin peer range to `workspace:^` (publishes as `^`), and turn on the experimental `onlyUpdatePeerDependentsWhenOutOfRange: true`. CLI minors land in-range and stop propagating to plugin majors; CLI majors still escape `^1.x.y` and propagate as intended. +- **C**: Replace `workspace:*` with a literal manual range (`>=0.0.0 <2.0.0`) in each plugin's peer, then enable `onlyUpdatePeerDependentsWhenOutOfRange: true`. Works pre-1.0, but the range is maintained by hand. + +## Decision: Option B + +- `1.0.0` is honest about where the contract sits. The declarative-plugin-shape change in this PR is itself a breaking edit to the kernel↔plugin SDK, so the CLI exiting `0.x` here is congruent with the change, not a marketing decision. +- `workspace:^` + `onlyUpdatePeerDependentsWhenOutOfRange: true` is the smallest delta that gives the desired semantics: patches/minors of the CLI keep the plugins as-is, majors propagate to plugin majors. Both knobs are already supported by Changesets — no fork, no custom tooling. +- The plugin contract being kernel-internal (per `CLAUDE.md`) means the only consumers of the peer relationship are the 4 official plugins; we can keep the peer-vs-dependency distinction because pnpm + workspaces makes both resolve to the same workspace package. + +Resulting matrix once on 1.x: + +| CLI bump | Effect on plugins | +|---|---| +| `patch` `1.0.0 → 1.0.1` | no bump — `nextRelease.type === "patch"` short-circuits and `^1.0.0` still satisfies | +| `minor` `1.0.0 → 1.1.0` | no bump — `^1.0.0` still satisfies, so out-of-range check fails | +| `major` `1.0.0 → 2.0.0` | plugins go `major` — out of `^1.0.0`, contract is actually breaking | + +## Alternatives rejected + +- Option A: drops the peer semantics. The plugins genuinely couple to the kernel SDK; expressing that as `dependencies` would model the relationship incorrectly and lose `peerDependencies`' duplicate-detection guarantees for hosts. +- Option C: works, but introduces a manually-maintained range string per plugin. Option B uses `workspace:^` which Changesets already resolves at publish-time, so no per-release upkeep. + +## Notes for human review + +- `___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH` is the actual config key — its name advertises that the API may change in a future patch release. If Changesets renames it, the migration is mechanical. +- `updateInternalDependents` (the sibling experimental key) is left at the default `"out-of-range"`. With `workspace:^`, in-range CLI bumps publish no new plugin tarballs at all, which is the intended low-churn behaviour. Flipping it to `"always"` would force a plugin patch on every CLI release. +- For *this* PR specifically the plugins still publish as `1.0.0` because the changeset declares them `major` explicitly (the declarative-plugin-shape change is breaking for plugin authors), not because the cascade promoted them. diff --git a/decisions/README.md b/decisions/README.md index c114899c..f2d9654c 100644 --- a/decisions/README.md +++ b/decisions/README.md @@ -12,7 +12,7 @@ Each file follows this template: # NNN: [one-sentence question] - **Date**: YYYY-MM-DD -- **Status**: Applied | Pending human review | Overridden +- **Status**: Applied | Pending human review | Overridden | Rejected - **Files affected**: [list] ## Context @@ -44,4 +44,5 @@ Add an entry every time you invoke `arch-critic` and apply its recommendation. D - **Applied**: code is in main, tests green, decision is in effect. - **Pending human review**: applied but the implementer flagged it as uncertain. -- **Overridden**: a human later disagreed and a new decision supersedes this one. Either delete the file (preferred when the override fully replaces it) or leave the file and add a "→ See NNN" pointer at the top. +- **Overridden**: a previously *Applied* decision that a human later disagreed with; superseded by a newer decision. Either delete the file (preferred when the override fully replaces it) or leave the file and add a "→ See NNN" pointer at the top. +- **Rejected**: a proposal that was reviewed (typically by `arch-critic`) and never applied. Keep the file when the *reasoning behind the rejection* is the part worth preserving — i.e. when "we considered X and chose not to do it because Y" is load-bearing for future readers tempted by the same idea. Add a "→ Rejected — see NNN" header pointing to whatever decision (if any) absorbed the substantive parts. diff --git a/package.json b/package.json index 39bd5603..174de42f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@rrlab/biome-config": "workspace:*", "@rrlab/biome-plugin": "workspace:*", "@rrlab/cli": "workspace:*", + "@rrlab/oxc-plugin": "workspace:*", "@rrlab/ts-config": "workspace:*", "@rrlab/ts-plugin": "workspace:*", "@rrlab/tsdown-config": "workspace:*", @@ -23,6 +24,9 @@ "@types/node": "latest", "@vlandoss/vland": "workspace:*", "lefthook": "2.1.1", + "oxfmt": "^0.51.0", + "oxlint": "^1.0.0", + "oxlint-tsgolint": "^0.23.0", "tsdown": "0.22.0", "turbo": "2.8.12", "typescript": "^6.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f756970..86a4355e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@rrlab/cli': specifier: workspace:* version: link:run-run/cli + '@rrlab/oxc-plugin': + specifier: workspace:* + version: link:run-run/oxc-plugin '@rrlab/ts-config': specifier: workspace:* version: link:run-run/ts-config @@ -47,6 +50,15 @@ importers: lefthook: specifier: 2.1.1 version: 2.1.1 + oxfmt: + specifier: ^0.51.0 + version: 0.51.0 + oxlint: + specifier: ^1.0.0 + version: 1.50.0(oxlint-tsgolint@0.23.0) + oxlint-tsgolint: + specifier: ^0.23.0 + version: 0.23.0 tsdown: specifier: 0.22.0 version: 0.22.0(typescript@6.0.3)(unrun@0.2.39) @@ -76,14 +88,20 @@ importers: version: 4.2.5 devDependencies: '@biomejs/biome': - specifier: 2.4.4 - version: 2.4.4 + specifier: 2.4.15 + version: 2.4.15 '@rrlab/cli': specifier: workspace:* version: link:../cli '@rrlab/tsdown-config': specifier: workspace:^ version: link:../tsdown-config + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 + semver: + specifier: ^7.8.1 + version: 7.8.1 run-run/cli: dependencies: @@ -143,15 +161,21 @@ importers: '@rrlab/tsdown-config': specifier: workspace:^ version: link:../tsdown-config + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 oxfmt: - specifier: 0.35.0 - version: 0.35.0 + specifier: 0.51.0 + version: 0.51.0 oxlint: - specifier: 1.50.0 - version: 1.50.0(oxlint-tsgolint@0.15.0) + specifier: 1.66.0 + version: 1.66.0(oxlint-tsgolint@0.23.0) oxlint-tsgolint: - specifier: 0.15.0 - version: 0.15.0 + specifier: 0.23.0 + version: 0.23.0 + semver: + specifier: ^7.8.1 + version: 7.8.1 run-run/ts-config: dependencies: @@ -180,6 +204,12 @@ importers: '@rrlab/tsdown-config': specifier: workspace:^ version: link:../tsdown-config + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 + semver: + specifier: ^7.8.1 + version: 7.8.1 typescript: specifier: 6.0.3 version: 6.0.3 @@ -205,6 +235,12 @@ importers: '@rrlab/tsdown-config': specifier: workspace:^ version: link:../tsdown-config + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 + semver: + specifier: ^7.8.1 + version: 7.8.1 tsdown: specifier: 0.22.0 version: 0.22.0(typescript@6.0.3)(unrun@0.2.39) @@ -340,53 +376,106 @@ packages: '@bgotink/kdl@0.4.0': resolution: {integrity: sha512-F0uJCjo5FQvFdcGF5QbYVNfcGiRWlocuzyIdQxottZF2+gu6L2xjMGEu9PIpse2hifAca/19vIospgaETCKxIg==} + '@biomejs/biome@2.4.15': + resolution: {integrity: sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==} + engines: {node: '>=14.21.3'} + hasBin: true + '@biomejs/biome@2.4.4': resolution: {integrity: sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q==} engines: {node: '>=14.21.3'} hasBin: true + '@biomejs/cli-darwin-arm64@2.4.15': + resolution: {integrity: sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + '@biomejs/cli-darwin-arm64@2.4.4': resolution: {integrity: sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] + '@biomejs/cli-darwin-x64@2.4.15': + resolution: {integrity: sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + '@biomejs/cli-darwin-x64@2.4.4': resolution: {integrity: sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] + '@biomejs/cli-linux-arm64-musl@2.4.15': + resolution: {integrity: sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + '@biomejs/cli-linux-arm64-musl@2.4.4': resolution: {integrity: sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + '@biomejs/cli-linux-arm64@2.4.15': + resolution: {integrity: sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + '@biomejs/cli-linux-arm64@2.4.4': resolution: {integrity: sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + '@biomejs/cli-linux-x64-musl@2.4.15': + resolution: {integrity: sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + '@biomejs/cli-linux-x64-musl@2.4.4': resolution: {integrity: sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + '@biomejs/cli-linux-x64@2.4.15': + resolution: {integrity: sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + '@biomejs/cli-linux-x64@2.4.4': resolution: {integrity: sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + '@biomejs/cli-win32-arm64@2.4.15': + resolution: {integrity: sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + '@biomejs/cli-win32-arm64@2.4.4': resolution: {integrity: sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] + '@biomejs/cli-win32-x64@2.4.15': + resolution: {integrity: sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@biomejs/cli-win32-x64@2.4.4': resolution: {integrity: sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A==} engines: {node: '>=14.21.3'} @@ -525,147 +614,147 @@ packages: '@oxc-project/types@0.130.0': resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} - '@oxfmt/binding-android-arm-eabi@0.35.0': - resolution: {integrity: sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA==} + '@oxfmt/binding-android-arm-eabi@0.51.0': + resolution: {integrity: sha512-Ni0sCqg5CIHaLIYFGj+ncbcumylvNC6FE4rfD0KfdmnWHbPJ+zev0qZCXKxy2hFVa0fYRK0yPzf5nzPbkZou7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.35.0': - resolution: {integrity: sha512-/O+EbuAJYs6nde/anv+aID6uHsGQApyE9JtYBo/79KyU8e6RBN3DMbT0ix97y1SOnCglurmL2iZ+hlohjP2PnQ==} + '@oxfmt/binding-android-arm64@0.51.0': + resolution: {integrity: sha512-eu5lAZjuo0KAkp+M24EhDqfOwA8owQ8d7wyBlOUUGRbDLHpU3IRlDHp8Dif+YqGlxs6jra7yS6WQu/NkPhAxeg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.35.0': - resolution: {integrity: sha512-pGqRtqlNdn9d4VrmGUWVyQjkw79ryhI6je9y2jfqNUIZCfqceob+R97YYAoG7C5TFyt8ILdLVoN+L2vw/hSFyA==} + '@oxfmt/binding-darwin-arm64@0.51.0': + resolution: {integrity: sha512-6LsUNIdURhhcIfIn8+xsOb61mSTa9msAHTeSGx9Jf4rsP/gN8PGCF+SKWPAQZbND2w/WBkqQ6303jqEEIXzMdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.35.0': - resolution: {integrity: sha512-8GmsDcSozTPjrCJeGpp+sCmS9+9V5yRrdEZ1p/sTWxPG5nYeAfSLuS0nuEYjXSO+CtdSbStIW6dxa+4NM58yRw==} + '@oxfmt/binding-darwin-x64@0.51.0': + resolution: {integrity: sha512-9aUMGmVxdHjYMsEAW1tNRoieTJXlVNDFkRvIR1J7LttJXWjVYCu2ekclLij2KJtxBxSQOYSHd12ME/adVGVbZg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.35.0': - resolution: {integrity: sha512-QyfKfTe0ytHpFKHAcHCGQEzN45QSqq1AHJOYYxQMgLM3KY4xu8OsXHpCnINjDsV4XGnQzczJDU9e04Zmd8XqIQ==} + '@oxfmt/binding-freebsd-x64@0.51.0': + resolution: {integrity: sha512-mkY1nhZTqYb+NHaAWxOCKISN6FwdrwMNsu17vTUA3wzUV2VJ+Paq15ZokRcsMU/2PUdHO73prxyeJpjXQ3MPpQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.35.0': - resolution: {integrity: sha512-u+kv3JD6P3J38oOyUaiCqgY5TNESzBRZJ5lyZQ6c2czUW2v5SIN9E/KWWa9vxoc+P8AFXQFUVrdzGy1tK+nbPQ==} + '@oxfmt/binding-linux-arm-gnueabihf@0.51.0': + resolution: {integrity: sha512-wtFwNwE4+YCNuPaWoGDZeGsKvD6D1YSUNBJNn/rJBh7CrDBThFE+TBI5kY7vRW9rIOQRsbW2IpyyL3Du4Zqwiw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.35.0': - resolution: {integrity: sha512-1NiZroCiV57I7Pf8kOH4XGR366kW5zir3VfSMBU2D0V14GpYjiYmPYFAoJboZvp8ACnZKUReWyMkNKSa5ad58A==} + '@oxfmt/binding-linux-arm-musleabihf@0.51.0': + resolution: {integrity: sha512-rnOaNx86G7iRKM6lsCIQMux0SMGNC/TEbFR+r7lpruJ12bnrIWgxd5w1PLqOvgR9r8ZJbpK/zfRKctJnh8/Jfg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.35.0': - resolution: {integrity: sha512-7Q0Xeg7ZnW2nxnZ4R7aF6DEbCFls4skgHZg+I63XitpNvJCbVIU8MFOTZlvZGRsY9+rPgWPQGeUpLHlyx0wvMA==} + '@oxfmt/binding-linux-arm64-gnu@0.51.0': + resolution: {integrity: sha512-jOgDzSqWcICGRjsp4mc08FxKMN8vzP2Kgs4E0d2HUP99F+nJDQKklRV4Zuj+0gcBgjrzx2CbpqaIdUVPepCojA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxfmt/binding-linux-arm64-musl@0.35.0': - resolution: {integrity: sha512-5Okqi+uhYFxwKz8hcnUftNNwdm8BCkf6GSCbcz9xJxYMm87k1E4p7PEmAAbhLTk7cjSdDre6TDL0pDzNX+Y22Q==} + '@oxfmt/binding-linux-arm64-musl@0.51.0': + resolution: {integrity: sha512-KBUCdrH5bwVrAvI9gU/1S55oH6fzXjr++J/oVocdu7bYTks1l7DNNT+rLd/1TDdAEjObGwmfWamn7LC1m8A0DQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxfmt/binding-linux-ppc64-gnu@0.35.0': - resolution: {integrity: sha512-9k66pbZQXM/lBJWys3Xbc5yhl4JexyfqkEf/tvtq8976VIJnLAAL3M127xHA3ifYSqxdVHfVGTg84eiBHCGcNw==} + '@oxfmt/binding-linux-ppc64-gnu@0.51.0': + resolution: {integrity: sha512-NapfjYsABFqTJ1Dn9Efq6sN5esaHconVKwVLbDGNQLrwpOx/g17mkwErHzU72PutL67nf3wNAkbq122H+zLxag==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@oxfmt/binding-linux-riscv64-gnu@0.35.0': - resolution: {integrity: sha512-aUcY9ofKPtjO52idT6t0SAQvEF6ctjzUQa1lLp7GDsRpSBvuTrBQGeq0rYKz3gN8dMIQ7mtMdGD9tT4LhR8jAQ==} + '@oxfmt/binding-linux-riscv64-gnu@0.51.0': + resolution: {integrity: sha512-5dlDt1dUZCVi6elIhiK1PWg9wpTzTcIuj0IZnSurvIoMrhOWqqTcc1dSTxcSkNaBZhfsNqRZdINI1zAgbKkJNQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxfmt/binding-linux-riscv64-musl@0.35.0': - resolution: {integrity: sha512-C6yhY5Hvc2sGM+mCPek9ZLe5xRUOC/BvhAt2qIWFAeXMn4il04EYIjl3DsWiJr0xDMTJhvMOmD55xTRPlNp39w==} + '@oxfmt/binding-linux-riscv64-musl@0.51.0': + resolution: {integrity: sha512-pgdWUJn0S5nulyiVdlFV8DzCUnGXkU99W5PSkkmbaZW+LrZBPxpezun4G0DDHbQaVYuJeCuKsXsGKGo77CkUTQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxfmt/binding-linux-s390x-gnu@0.35.0': - resolution: {integrity: sha512-RG2hlvOMK4OMZpO3mt8MpxLQ0AAezlFqhn5mI/g5YrVbPFyoCv9a34AAvbSJS501ocOxlFIRcKEuw5hFvddf9g==} + '@oxfmt/binding-linux-s390x-gnu@0.51.0': + resolution: {integrity: sha512-2XTFUe97CbDGAI8vjwDfZ1HdakO0XIADyJ24idEg64SC4/K4in/OisXVnrW4NMK7I6TgC7EqRhC0Ln/nKhAemA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@oxfmt/binding-linux-x64-gnu@0.35.0': - resolution: {integrity: sha512-wzmh90Pwvqj9xOKHJjkQYBpydRkaXG77ZvDz+iFDRRQpnqIEqGm5gmim2s6vnZIkDGsvKCuTdtxm0GFmBjM1+w==} + '@oxfmt/binding-linux-x64-gnu@0.51.0': + resolution: {integrity: sha512-kQ1OuCqqt/yyf0ZN9VFxW1/JnlgJgii3Dr7pWf9vNBvrX1hv6g39/+mc5oGRHRGJFZtl3zsGDWR9c5N2B/gwBw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxfmt/binding-linux-x64-musl@0.35.0': - resolution: {integrity: sha512-+HCqYCJPCUy5I+b2cf+gUVaApfgtoQT3HdnSg/l7NIcLHOhKstlYaGyrFZLmUpQt4WkFbpGKZZayG6zjRU0KFA==} + '@oxfmt/binding-linux-x64-musl@0.51.0': + resolution: {integrity: sha512-ARTYqxHF475o96Gbn41hvSWSSRygPlRDXZZgZ9I2scU1y0qiWpCQyZCoefaQa0mwv+wwtZ+luS4YOzsRzM/izg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxfmt/binding-openharmony-arm64@0.35.0': - resolution: {integrity: sha512-kFYmWfR9YL78XyO5ws+1dsxNvZoD973qfVMNFOS4e9bcHXGF7DvGC2tY5UDFwyMCeB33t3sDIuGONKggnVNSJA==} + '@oxfmt/binding-openharmony-arm64@0.51.0': + resolution: {integrity: sha512-QiC1XrCl6a6BmqMzduO8hdIRMf1m44hCkt2Q68KWkTvUB/E7fd2iomyNh6KnnRca5w6eBrRAAtLFqTh+xjsjJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.35.0': - resolution: {integrity: sha512-uD/NGdM65eKNCDGyTGdO8e9n3IHX+wwuorBvEYrPJXhDXL9qz6gzddmXH8EN04ejUXUujlq4FsoSeCfbg0Y+Jg==} + '@oxfmt/binding-win32-arm64-msvc@0.51.0': + resolution: {integrity: sha512-NC/hJb9dtU23Zf8L7IVK95xnFjiQ7AfcLO2l5pb69TDEr958qxrtnB2CveeeNSCBFNIkgaTCfd/vHNSoG78l9g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.35.0': - resolution: {integrity: sha512-oSRD2k8J2uxYDEKR2nAE/YTY9PobOEnhZgCmspHu0+yBQ665yH8lFErQVSTE7fcGJmJp/cC6322/gc8VFuQf7g==} + '@oxfmt/binding-win32-ia32-msvc@0.51.0': + resolution: {integrity: sha512-2C45za4Rj36n8YIbhRL1PQbxmXJYf81WEcAgvj5I4ptRROG+A+81hREEN5bmCHADE1UfYaN312U6tkILoZZy6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.35.0': - resolution: {integrity: sha512-WCDJjlS95NboR0ugI2BEwzt1tYvRDorDRM9Lvctls1SLyKYuNRCyrPwp1urUPFBnwgBNn9p2/gnmo7gFMySRoQ==} + '@oxfmt/binding-win32-x64-msvc@0.51.0': + resolution: {integrity: sha512-73RqdAuVKQTkjZIDw08JaDHUM4lav5Qu+CaPwg4QbbA7k8o7LEW0p3UsfZ/F8dsO/pwVYh3RzFcanwLRTTahbQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint-tsgolint/darwin-arm64@0.15.0': - resolution: {integrity: sha512-d7Ch+A6hic+RYrm32+Gh1o4lOrQqnFsHi721ORdHUDBiQPea+dssKUEMwIbA6MKmCy6TVJ02sQyi24OEfCiGzw==} + '@oxlint-tsgolint/darwin-arm64@0.23.0': + resolution: {integrity: sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.15.0': - resolution: {integrity: sha512-Aoai2wAkaUJqp/uEs1gml6TbaPW4YmyO5Ai/vOSkiizgHqVctjhjKqmRiWTX2xuPY94VkwOLqp+Qr3y/0qSpWQ==} + '@oxlint-tsgolint/darwin-x64@0.23.0': + resolution: {integrity: sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.15.0': - resolution: {integrity: sha512-4og13a7ec4Vku5t2Y7s3zx6YJP6IKadb1uA9fOoRH6lm/wHWoCnxjcfJmKHXRZJII81WmbdJMSPxaBfwN/S68Q==} + '@oxlint-tsgolint/linux-arm64@0.23.0': + resolution: {integrity: sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.15.0': - resolution: {integrity: sha512-9b9xzh/1Harn3a+XiKTK/8LrWw3VcqLfYp/vhV5/zAVR2Mt0d63WSp4FL+wG7DKnI2T/CbMFUFHwc7kCQjDMzQ==} + '@oxlint-tsgolint/linux-x64@0.23.0': + resolution: {integrity: sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.15.0': - resolution: {integrity: sha512-nNac5hewHdkk5mowOwTqB1ZD76zB/FsUiyUvdCyupq5cG54XyKqSLEp9QGbx7wFJkWCkeWmuwRed4sfpAlKaeA==} + '@oxlint-tsgolint/win32-arm64@0.23.0': + resolution: {integrity: sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.15.0': - resolution: {integrity: sha512-ioAY2XLpy83E2EqOLH9p1cEgj0G2qB1lmAn0a3yFV1jHQB29LIPIKGNsu/tYCClpwmHN79pT5KZAHZOgWxxqNg==} + '@oxlint-tsgolint/win32-x64@0.23.0': + resolution: {integrity: sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ==} cpu: [x64] os: [win32] @@ -675,114 +764,228 @@ packages: cpu: [arm] os: [android] + '@oxlint/binding-android-arm-eabi@1.66.0': + resolution: {integrity: sha512-f7kq8N51T4phpzqfBpA2qaVTI/KrkCmNwaj3t/97I/WLTDI+UhlP5GL9eER+zVxBhtlx5rKXWByJU1/zDAvyaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@oxlint/binding-android-arm64@1.50.0': resolution: {integrity: sha512-GeSuMoJWCVpovJi/e3xDSNgjeR8WEZ6MCXL6EtPiCIM2NTzv7LbflARINTXTJy2oFBYyvdf/l2PwHzYo6EdXvg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@oxlint/binding-android-arm64@1.66.0': + resolution: {integrity: sha512-xu6QO71tdDS9mjmLZ3AqhtaVHBvdmsOKkYnReNNDgh+XiwnsipeQOIxbiYOOO0iAXycJ+GK0wdMSZP/2j/AmSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@oxlint/binding-darwin-arm64@1.50.0': resolution: {integrity: sha512-w3SY5YtxGnxCHPJ8Twl3KmS9oja1gERYk3AMoZ7Hv8P43ZtB6HVfs02TxvarxfL214Tm3uzvc2vn+DhtUNeKnw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@oxlint/binding-darwin-arm64@1.66.0': + resolution: {integrity: sha512-HZ24VimSOC7mxuEA99e0H2FS0C1yO3+iW13jPRAk+e2njsUs3QeAXsafCDyaIrV/MirdOVez+etQNQsJE43zNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@oxlint/binding-darwin-x64@1.50.0': resolution: {integrity: sha512-hNfogDqy7tvmllXKBSlHo6k5x7dhTUVOHbMSE15CCAcXzmqf5883aPvBYPOq9AE7DpDUQUZ1kVE22YbiGW+tuw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@oxlint/binding-darwin-x64@1.66.0': + resolution: {integrity: sha512-awhj8ZvJrrRSnXj7V++rpZvTmnl99L6mi0B7gg7Cp7BN6cKpzuI481bHNLvXGA9GB1/oEgA3ponuyoAc6Md12A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@oxlint/binding-freebsd-x64@1.50.0': resolution: {integrity: sha512-ykZevOWEyu0nsxolA911ucxpEv0ahw8jfEeGWOwwb/VPoE4xoexuTOAiPNlWZNJqANlJl7yp8OyzCtXTUAxotw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@oxlint/binding-freebsd-x64@1.66.0': + resolution: {integrity: sha512-KQF0oVV21/FjIqkRuL8Q1vh8ECsE5+ocdH5tcqTQ4ZnYuDVoYibQUNfqBjQaUsP6UIIda5Y75Wpm5p4RgQWiWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@oxlint/binding-linux-arm-gnueabihf@1.50.0': resolution: {integrity: sha512-hif3iDk7vo5GGJ4OLCCZAf2vjnU9FztGw4L0MbQL0M2iY9LKFtDMMiQAHmkF0PQGQMVbTYtPdXCLKVgdkiqWXQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxlint/binding-linux-arm-gnueabihf@1.66.0': + resolution: {integrity: sha512-9u1rgwZSEXWb30vbFZzQ78HVXBo0WCKNwJ3a2InRUTNMRng+PUDIoSFmA+m4HdUfBaIqftShq8J8qHc+eE/Vig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxlint/binding-linux-arm-musleabihf@1.50.0': resolution: {integrity: sha512-dVp9iSssiGAnTNey2Ruf6xUaQhdnvcFOJyRWd/mu5o2jVbFK15E5fbWGeFRfmuobu5QXuROtFga44+7DOS3PLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxlint/binding-linux-arm-musleabihf@1.66.0': + resolution: {integrity: sha512-Ynot2HR1bHxUaNWoC280MVTDfZuaWuP3XfSMRDhyuZrVjhzoaBCVFlw8h8qeZjWKVUBhPWFIxB7AQTlK8Z2WWg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxlint/binding-linux-arm64-gnu@1.50.0': resolution: {integrity: sha512-1cT7yz2HA910CKA9NkH1ZJo50vTtmND2fkoW1oyiSb0j6WvNtJ0Wx2zoySfXWc/c+7HFoqRK5AbEoL41LOn9oA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + '@oxlint/binding-linux-arm64-gnu@1.66.0': + resolution: {integrity: sha512-xCbgzciGgo+A4aQZEknsNrNiIwY7sU5SfRuMmRjPIvZAgdF34cIHiKvwOsS5XRLjlTVSFwitmq6YclTtHTfU+g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + '@oxlint/binding-linux-arm64-musl@1.50.0': resolution: {integrity: sha512-++B3k/HEPFVlj89cOz8kWfQccMZB/aWL9AhsW7jPIkG++63Mpwb2cE9XOEsd0PATbIan78k2Gky+09uWM1d/gQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + '@oxlint/binding-linux-arm64-musl@1.66.0': + resolution: {integrity: sha512-hmo+ZB/lHkR1HdDmnziNpzSLmulnUSu10VEqX2Yex7OwvoBAbjJQLvy4gIBRV3AAwWnCvAxKp5Nv1GE6LU1QMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + '@oxlint/binding-linux-ppc64-gnu@1.50.0': resolution: {integrity: sha512-Z9b/KpFMkx66w3gVBqjIC1AJBTZAGoI9+U+K5L4QM0CB/G0JSNC1es9b3Y0Vcrlvtdn8A+IQTkYjd/Q0uCSaZw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + '@oxlint/binding-linux-ppc64-gnu@1.66.0': + resolution: {integrity: sha512-2Invd4Uyy81mVooQC5FBtfxSNrvcX1OxbMlVQ6M2erRrNI2awFYF26YNW2yFxdVFZ4ffNOWKghtMjhnUPsXsVA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + '@oxlint/binding-linux-riscv64-gnu@1.50.0': resolution: {integrity: sha512-jvmuIw8wRSohsQlFNIST5uUwkEtEJmOQYr33bf/K2FrFPXHhM4KqGekI3ShYJemFS/gARVacQFgBzzJKCAyJjg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + '@oxlint/binding-linux-riscv64-gnu@1.66.0': + resolution: {integrity: sha512-s0iXPDQVdgayE3RGa/N2DZF7tjgg0TwEtD1sGoDxqPDGrIXgo45H0yHknT0f9A0yteASsweYZtDyTuVlM4aSag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + '@oxlint/binding-linux-riscv64-musl@1.50.0': resolution: {integrity: sha512-x+UrN47oYNh90nmAAyql8eQaaRpHbDPu5guasDg10+OpszUQ3/1+1J6zFMmV4xfIEgTcUXG/oI5fxJhF4eWCNA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + '@oxlint/binding-linux-riscv64-musl@1.66.0': + resolution: {integrity: sha512-OekL4XFiu7RPK0JIZi8VeHgtIXPREf42t8Cy/rKEsC+P3gcqDgNAAGiyuUOpdbG4wwbfue1q4CHcCO7spSve6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + '@oxlint/binding-linux-s390x-gnu@1.50.0': resolution: {integrity: sha512-i/JLi2ljLUIVfekMj4ISmdt+Hn11wzYUdRRrkVUYsCWw7zAy5xV7X9iA+KMyM156LTFympa7s3oKBjuCLoTAUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + '@oxlint/binding-linux-s390x-gnu@1.66.0': + resolution: {integrity: sha512-Ga1D0kj1SFslm34ThA/BdkUlyAYEnTsXyRC4pF0C5agZSwtGdHYWMTQWemUfBGp4RCG4QWXgdO+HmmmKqOtlBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + '@oxlint/binding-linux-x64-gnu@1.50.0': resolution: {integrity: sha512-/C7brhn6c6UUPccgSPCcpLQXcp+xKIW/3sji/5VZ8/OItL3tQ2U7KalHz887UxxSQeEOmd1kY6lrpuwFnmNqOA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + '@oxlint/binding-linux-x64-gnu@1.66.0': + resolution: {integrity: sha512-p5jfP1wUZe/IC3qpQO84n9DRnf9g3lKRtLBlQq23ykyrDglHcVx7sWmVTlPuU6SBw8mNnPzyOn022G3XZHnlww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + '@oxlint/binding-linux-x64-musl@1.50.0': resolution: {integrity: sha512-oDR1f+bGOYU8LfgtEW8XtotWGB63ghtcxk5Jm6IDTCk++rTA/IRMsjOid2iMd+1bW+nP9Mdsmcdc7VbPD3+iyQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + '@oxlint/binding-linux-x64-musl@1.66.0': + resolution: {integrity: sha512-vUB/sYlYZorDL1ZD+o9mRv7zbsykrrFRtmgS6R8musZqLtrPRQn1gc1eGpuX+sfdccz42STl/AqldY6XRb2upQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + '@oxlint/binding-openharmony-arm64@1.50.0': resolution: {integrity: sha512-4CmRGPp5UpvXyu4jjP9Tey/SrXDQLRvZXm4pb4vdZBxAzbFZkCyh0KyRy4txld/kZKTJlW4TO8N1JKrNEk+mWw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@oxlint/binding-openharmony-arm64@1.66.0': + resolution: {integrity: sha512-yde+6p/F59xRkGR9H1HfngWRif1QRJjynZK349l+UI0H6w9hL3G8/AVaTHFyTtLVQ56qtNbX2/5Dc77n1ovnOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@oxlint/binding-win32-arm64-msvc@1.50.0': resolution: {integrity: sha512-Fq0M6vsGcFsSfeuWAACDhd5KJrO85ckbEfe1EGuBj+KPyJz7KeWte2fSFrFGmNKNXyhEMyx4tbgxiWRujBM2KQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@oxlint/binding-win32-arm64-msvc@1.66.0': + resolution: {integrity: sha512-O9GLucgoTdmOrbBX+EjzNe7o/Ze5TFOvXcib6bzUOtBOmj6cV+zw18NgB+cGKAkDw1Pdqs8vGkfHbbsLuDtXWg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@oxlint/binding-win32-ia32-msvc@1.50.0': resolution: {integrity: sha512-qTdWR9KwY/vxJGhHVIZG2eBOhidOQvOwzDxnX+jhW/zIVacal1nAhR8GLkiywW8BIFDkQKXo/zOfT+/DY+ns/w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] + '@oxlint/binding-win32-ia32-msvc@1.66.0': + resolution: {integrity: sha512-m3Pjwc2MfTcom4E4gOv7DyuGyt7OfGNCbmqDHd+N7EzXmP+ppHuudm2NjcA3AjV5TSeGxaguVF4SbTKHe1USYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + '@oxlint/binding-win32-x64-msvc@1.50.0': resolution: {integrity: sha512-682t7npLC4G2Ca+iNlI9fhAKTcFPYYXJjwoa88H4q+u5HHHlsnL/gHULapX3iqp+A8FIJbgdylL5KMYo2LaluQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@oxlint/binding-win32-x64-msvc@1.66.0': + resolution: {integrity: sha512-/DbBvw8UFBhja6PqudUjV4UtfsJr0Oa7jUjWVKB0g86lj/VwnPrkngn0sFql3c9RDA0O16dh7ozsXb6GjNAzBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@pnpm/constants@1001.3.1': resolution: {integrity: sha512-2hf0s4pVrVEH8RvdJJ7YRKjQdiG8m0iAT26TTqXnCbK30kKwJW69VLmP5tED5zstmDRXcOeH5eRcrpkdwczQ9g==} engines: {node: '>=18.12'} @@ -1061,6 +1264,9 @@ packages: '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@usage-spec/commander@1.1.0': resolution: {integrity: sha512-hVv+ccKtcPaiaywLrm7Q/Nb4nGdRD319FBhfmTWQq3yUlS1SK/pmwyn0+BFQGAYN4uOMxvYDrqPH+qXpmINrkg==} @@ -1622,13 +1828,18 @@ packages: outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} - oxfmt@0.35.0: - resolution: {integrity: sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q==} + oxfmt@0.51.0: + resolution: {integrity: sha512-l/AoAnaEOV7Q5/Z9kHOMDehVJnCgYN7wRoooWCTUMBMi16BJhLZqd9cmCnwcVFfVlzkt53zK2KLPFNp8vSsoDg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + peerDependencies: + svelte: ^5.0.0 + peerDependenciesMeta: + svelte: + optional: true - oxlint-tsgolint@0.15.0: - resolution: {integrity: sha512-iwvFmhKQVZzVTFygUVI4t2S/VKEm+Mqkw3jQRJwfDuTcUYI5LCIYzdO5Dbuv4mFOkXZCcXaRRh0m+uydB5xdqw==} + oxlint-tsgolint@0.23.0: + resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==} hasBin: true oxlint@1.50.0: @@ -1641,6 +1852,16 @@ packages: oxlint-tsgolint: optional: true + oxlint@1.66.0: + resolution: {integrity: sha512-N4LLxYLd94KEBqXDMDM5f+2PUpItTjDLreXe2Gn5KhjhCK4Qp2YUXaBi8Yu325ryOgKwt22m45fpD7nPOn69Yw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -1794,6 +2015,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2151,6 +2377,17 @@ snapshots: '@bgotink/kdl@0.4.0': {} + '@biomejs/biome@2.4.15': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.15 + '@biomejs/cli-darwin-x64': 2.4.15 + '@biomejs/cli-linux-arm64': 2.4.15 + '@biomejs/cli-linux-arm64-musl': 2.4.15 + '@biomejs/cli-linux-x64': 2.4.15 + '@biomejs/cli-linux-x64-musl': 2.4.15 + '@biomejs/cli-win32-arm64': 2.4.15 + '@biomejs/cli-win32-x64': 2.4.15 + '@biomejs/biome@2.4.4': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.4.4 @@ -2162,27 +2399,51 @@ snapshots: '@biomejs/cli-win32-arm64': 2.4.4 '@biomejs/cli-win32-x64': 2.4.4 + '@biomejs/cli-darwin-arm64@2.4.15': + optional: true + '@biomejs/cli-darwin-arm64@2.4.4': optional: true + '@biomejs/cli-darwin-x64@2.4.15': + optional: true + '@biomejs/cli-darwin-x64@2.4.4': optional: true + '@biomejs/cli-linux-arm64-musl@2.4.15': + optional: true + '@biomejs/cli-linux-arm64-musl@2.4.4': optional: true + '@biomejs/cli-linux-arm64@2.4.15': + optional: true + '@biomejs/cli-linux-arm64@2.4.4': optional: true + '@biomejs/cli-linux-x64-musl@2.4.15': + optional: true + '@biomejs/cli-linux-x64-musl@2.4.4': optional: true + '@biomejs/cli-linux-x64@2.4.15': + optional: true + '@biomejs/cli-linux-x64@2.4.4': optional: true + '@biomejs/cli-win32-arm64@2.4.15': + optional: true + '@biomejs/cli-win32-arm64@2.4.4': optional: true + '@biomejs/cli-win32-x64@2.4.15': + optional: true + '@biomejs/cli-win32-x64@2.4.4': optional: true @@ -2436,138 +2697,195 @@ snapshots: '@oxc-project/types@0.130.0': {} - '@oxfmt/binding-android-arm-eabi@0.35.0': + '@oxfmt/binding-android-arm-eabi@0.51.0': optional: true - '@oxfmt/binding-android-arm64@0.35.0': + '@oxfmt/binding-android-arm64@0.51.0': optional: true - '@oxfmt/binding-darwin-arm64@0.35.0': + '@oxfmt/binding-darwin-arm64@0.51.0': optional: true - '@oxfmt/binding-darwin-x64@0.35.0': + '@oxfmt/binding-darwin-x64@0.51.0': optional: true - '@oxfmt/binding-freebsd-x64@0.35.0': + '@oxfmt/binding-freebsd-x64@0.51.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.35.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.51.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.35.0': + '@oxfmt/binding-linux-arm-musleabihf@0.51.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.35.0': + '@oxfmt/binding-linux-arm64-gnu@0.51.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.35.0': + '@oxfmt/binding-linux-arm64-musl@0.51.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.35.0': + '@oxfmt/binding-linux-ppc64-gnu@0.51.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.35.0': + '@oxfmt/binding-linux-riscv64-gnu@0.51.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.35.0': + '@oxfmt/binding-linux-riscv64-musl@0.51.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.35.0': + '@oxfmt/binding-linux-s390x-gnu@0.51.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.35.0': + '@oxfmt/binding-linux-x64-gnu@0.51.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.35.0': + '@oxfmt/binding-linux-x64-musl@0.51.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.35.0': + '@oxfmt/binding-openharmony-arm64@0.51.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.35.0': + '@oxfmt/binding-win32-arm64-msvc@0.51.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.35.0': + '@oxfmt/binding-win32-ia32-msvc@0.51.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.35.0': + '@oxfmt/binding-win32-x64-msvc@0.51.0': optional: true - '@oxlint-tsgolint/darwin-arm64@0.15.0': + '@oxlint-tsgolint/darwin-arm64@0.23.0': optional: true - '@oxlint-tsgolint/darwin-x64@0.15.0': + '@oxlint-tsgolint/darwin-x64@0.23.0': optional: true - '@oxlint-tsgolint/linux-arm64@0.15.0': + '@oxlint-tsgolint/linux-arm64@0.23.0': optional: true - '@oxlint-tsgolint/linux-x64@0.15.0': + '@oxlint-tsgolint/linux-x64@0.23.0': optional: true - '@oxlint-tsgolint/win32-arm64@0.15.0': + '@oxlint-tsgolint/win32-arm64@0.23.0': optional: true - '@oxlint-tsgolint/win32-x64@0.15.0': + '@oxlint-tsgolint/win32-x64@0.23.0': optional: true '@oxlint/binding-android-arm-eabi@1.50.0': optional: true + '@oxlint/binding-android-arm-eabi@1.66.0': + optional: true + '@oxlint/binding-android-arm64@1.50.0': optional: true + '@oxlint/binding-android-arm64@1.66.0': + optional: true + '@oxlint/binding-darwin-arm64@1.50.0': optional: true + '@oxlint/binding-darwin-arm64@1.66.0': + optional: true + '@oxlint/binding-darwin-x64@1.50.0': optional: true + '@oxlint/binding-darwin-x64@1.66.0': + optional: true + '@oxlint/binding-freebsd-x64@1.50.0': optional: true + '@oxlint/binding-freebsd-x64@1.66.0': + optional: true + '@oxlint/binding-linux-arm-gnueabihf@1.50.0': optional: true + '@oxlint/binding-linux-arm-gnueabihf@1.66.0': + optional: true + '@oxlint/binding-linux-arm-musleabihf@1.50.0': optional: true + '@oxlint/binding-linux-arm-musleabihf@1.66.0': + optional: true + '@oxlint/binding-linux-arm64-gnu@1.50.0': optional: true + '@oxlint/binding-linux-arm64-gnu@1.66.0': + optional: true + '@oxlint/binding-linux-arm64-musl@1.50.0': optional: true + '@oxlint/binding-linux-arm64-musl@1.66.0': + optional: true + '@oxlint/binding-linux-ppc64-gnu@1.50.0': optional: true + '@oxlint/binding-linux-ppc64-gnu@1.66.0': + optional: true + '@oxlint/binding-linux-riscv64-gnu@1.50.0': optional: true + '@oxlint/binding-linux-riscv64-gnu@1.66.0': + optional: true + '@oxlint/binding-linux-riscv64-musl@1.50.0': optional: true + '@oxlint/binding-linux-riscv64-musl@1.66.0': + optional: true + '@oxlint/binding-linux-s390x-gnu@1.50.0': optional: true + '@oxlint/binding-linux-s390x-gnu@1.66.0': + optional: true + '@oxlint/binding-linux-x64-gnu@1.50.0': optional: true + '@oxlint/binding-linux-x64-gnu@1.66.0': + optional: true + '@oxlint/binding-linux-x64-musl@1.50.0': optional: true + '@oxlint/binding-linux-x64-musl@1.66.0': + optional: true + '@oxlint/binding-openharmony-arm64@1.50.0': optional: true + '@oxlint/binding-openharmony-arm64@1.66.0': + optional: true + '@oxlint/binding-win32-arm64-msvc@1.50.0': optional: true + '@oxlint/binding-win32-arm64-msvc@1.66.0': + optional: true + '@oxlint/binding-win32-ia32-msvc@1.50.0': optional: true + '@oxlint/binding-win32-ia32-msvc@1.66.0': + optional: true + '@oxlint/binding-win32-x64-msvc@1.50.0': optional: true + '@oxlint/binding-win32-x64-msvc@1.66.0': + optional: true + '@pnpm/constants@1001.3.1': {} '@pnpm/core-loggers@1001.0.9(@pnpm/logger@1001.0.1)': @@ -2783,6 +3101,8 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/semver@7.7.1': {} + '@usage-spec/commander@1.1.0': dependencies: '@usage-spec/core': 1.1.0 @@ -3251,40 +3571,40 @@ snapshots: outdent@0.5.0: {} - oxfmt@0.35.0: + oxfmt@0.51.0: dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.35.0 - '@oxfmt/binding-android-arm64': 0.35.0 - '@oxfmt/binding-darwin-arm64': 0.35.0 - '@oxfmt/binding-darwin-x64': 0.35.0 - '@oxfmt/binding-freebsd-x64': 0.35.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.35.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.35.0 - '@oxfmt/binding-linux-arm64-gnu': 0.35.0 - '@oxfmt/binding-linux-arm64-musl': 0.35.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.35.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.35.0 - '@oxfmt/binding-linux-riscv64-musl': 0.35.0 - '@oxfmt/binding-linux-s390x-gnu': 0.35.0 - '@oxfmt/binding-linux-x64-gnu': 0.35.0 - '@oxfmt/binding-linux-x64-musl': 0.35.0 - '@oxfmt/binding-openharmony-arm64': 0.35.0 - '@oxfmt/binding-win32-arm64-msvc': 0.35.0 - '@oxfmt/binding-win32-ia32-msvc': 0.35.0 - '@oxfmt/binding-win32-x64-msvc': 0.35.0 - - oxlint-tsgolint@0.15.0: + '@oxfmt/binding-android-arm-eabi': 0.51.0 + '@oxfmt/binding-android-arm64': 0.51.0 + '@oxfmt/binding-darwin-arm64': 0.51.0 + '@oxfmt/binding-darwin-x64': 0.51.0 + '@oxfmt/binding-freebsd-x64': 0.51.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.51.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.51.0 + '@oxfmt/binding-linux-arm64-gnu': 0.51.0 + '@oxfmt/binding-linux-arm64-musl': 0.51.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.51.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.51.0 + '@oxfmt/binding-linux-riscv64-musl': 0.51.0 + '@oxfmt/binding-linux-s390x-gnu': 0.51.0 + '@oxfmt/binding-linux-x64-gnu': 0.51.0 + '@oxfmt/binding-linux-x64-musl': 0.51.0 + '@oxfmt/binding-openharmony-arm64': 0.51.0 + '@oxfmt/binding-win32-arm64-msvc': 0.51.0 + '@oxfmt/binding-win32-ia32-msvc': 0.51.0 + '@oxfmt/binding-win32-x64-msvc': 0.51.0 + + oxlint-tsgolint@0.23.0: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.15.0 - '@oxlint-tsgolint/darwin-x64': 0.15.0 - '@oxlint-tsgolint/linux-arm64': 0.15.0 - '@oxlint-tsgolint/linux-x64': 0.15.0 - '@oxlint-tsgolint/win32-arm64': 0.15.0 - '@oxlint-tsgolint/win32-x64': 0.15.0 - - oxlint@1.50.0(oxlint-tsgolint@0.15.0): + '@oxlint-tsgolint/darwin-arm64': 0.23.0 + '@oxlint-tsgolint/darwin-x64': 0.23.0 + '@oxlint-tsgolint/linux-arm64': 0.23.0 + '@oxlint-tsgolint/linux-x64': 0.23.0 + '@oxlint-tsgolint/win32-arm64': 0.23.0 + '@oxlint-tsgolint/win32-x64': 0.23.0 + + oxlint@1.50.0(oxlint-tsgolint@0.23.0): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.50.0 '@oxlint/binding-android-arm64': 1.50.0 @@ -3305,7 +3625,30 @@ snapshots: '@oxlint/binding-win32-arm64-msvc': 1.50.0 '@oxlint/binding-win32-ia32-msvc': 1.50.0 '@oxlint/binding-win32-x64-msvc': 1.50.0 - oxlint-tsgolint: 0.15.0 + oxlint-tsgolint: 0.23.0 + + oxlint@1.66.0(oxlint-tsgolint@0.23.0): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.66.0 + '@oxlint/binding-android-arm64': 1.66.0 + '@oxlint/binding-darwin-arm64': 1.66.0 + '@oxlint/binding-darwin-x64': 1.66.0 + '@oxlint/binding-freebsd-x64': 1.66.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.66.0 + '@oxlint/binding-linux-arm-musleabihf': 1.66.0 + '@oxlint/binding-linux-arm64-gnu': 1.66.0 + '@oxlint/binding-linux-arm64-musl': 1.66.0 + '@oxlint/binding-linux-ppc64-gnu': 1.66.0 + '@oxlint/binding-linux-riscv64-gnu': 1.66.0 + '@oxlint/binding-linux-riscv64-musl': 1.66.0 + '@oxlint/binding-linux-s390x-gnu': 1.66.0 + '@oxlint/binding-linux-x64-gnu': 1.66.0 + '@oxlint/binding-linux-x64-musl': 1.66.0 + '@oxlint/binding-openharmony-arm64': 1.66.0 + '@oxlint/binding-win32-arm64-msvc': 1.66.0 + '@oxlint/binding-win32-ia32-msvc': 1.66.0 + '@oxlint/binding-win32-x64-msvc': 1.66.0 + oxlint-tsgolint: 0.23.0 p-filter@2.1.0: dependencies: @@ -3469,6 +3812,8 @@ snapshots: semver@7.8.0: {} + semver@7.8.1: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3547,7 +3892,7 @@ snapshots: picomatch: 4.0.4 rolldown: 1.0.1 rolldown-plugin-dts: 0.25.1(rolldown@1.0.1)(typescript@6.0.3) - semver: 7.8.0 + semver: 7.8.1 tinyexec: 1.1.2 tinyglobby: 0.2.16 tree-kill: 1.2.2 diff --git a/run-run.config.mts b/run-run.config.mts index c97ebb02..e7c7126f 100644 --- a/run-run.config.mts +++ b/run-run.config.mts @@ -1,8 +1,8 @@ import biome from "@rrlab/biome-plugin"; import { defineConfig } from "@rrlab/cli/config"; -import ts from "@rrlab/ts-plugin"; +import oxc from "@rrlab/oxc-plugin"; import tsdown from "@rrlab/tsdown-plugin"; export default defineConfig({ - plugins: [biome(), ts(), tsdown()], + plugins: [tsdown(), biome(), oxc({ only: ["tsc"] })], }); diff --git a/run-run/biome-plugin/package.json b/run-run/biome-plugin/package.json index 1a121879..30603280 100644 --- a/run-run/biome-plugin/package.json +++ b/run-run/biome-plugin/package.json @@ -46,11 +46,13 @@ }, "peerDependencies": { "@biomejs/biome": ">=2.0.0", - "@rrlab/cli": "workspace:*" + "@rrlab/cli": "workspace:^" }, "devDependencies": { - "@biomejs/biome": "2.4.4", + "@biomejs/biome": "2.4.15", "@rrlab/cli": "workspace:*", - "@rrlab/tsdown-config": "workspace:^" + "@rrlab/tsdown-config": "workspace:^", + "@types/semver": "^7.7.1", + "semver": "^7.8.1" } } diff --git a/run-run/biome-plugin/src/__tests__/setup.test.ts b/run-run/biome-plugin/src/__tests__/setup.test.ts new file mode 100644 index 00000000..ddc1528b --- /dev/null +++ b/run-run/biome-plugin/src/__tests__/setup.test.ts @@ -0,0 +1,35 @@ +import type { PluginContext } from "@rrlab/cli/plugin"; +import type { Pkg, ShellService } from "@vlandoss/clibuddy"; +import { describe, expect, it } from "vitest"; +import biome from "../index.ts"; + +function ctx(): PluginContext { + return { + shell: {} as ShellService, + // biome-ignore lint/suspicious/noExplicitAny: minimal stub + logger: {} as any, + appPkg: { dirPath: process.cwd() } as Pkg, + binPkg: { dirPath: process.cwd() } as Pkg, + cwd: process.cwd(), + }; +} + +describe("@rrlab/biome-plugin capabilities()", () => { + it("returns all capabilities (lint, format, jsc) when no `only` is supplied", async () => { + const caps = await biome().capabilities(ctx()); + expect(Object.keys(caps).sort()).toEqual(["format", "jsc", "lint"]); + }); + + it("narrows to just lint+format when `only: ['lint', 'format']` is supplied", async () => { + const caps = await biome({ only: ["lint", "format"] }).capabilities(ctx()); + expect(Object.keys(caps).sort()).toEqual(["format", "lint"]); + expect(caps.jsc).toBeUndefined(); + }); + + it("throws when `only` references an unknown capability", async () => { + await expect( + // biome-ignore lint/suspicious/noExplicitAny: bypassing the TS guard to exercise the runtime check + biome({ only: ["tsc"] as any }).capabilities(ctx()), + ).rejects.toThrow(/unknown capability 'tsc'/); + }); +}); diff --git a/run-run/biome-plugin/src/__tests__/tool-versions.test.ts b/run-run/biome-plugin/src/__tests__/tool-versions.test.ts index 4ad92ebe..35c4a989 100644 --- a/run-run/biome-plugin/src/__tests__/tool-versions.test.ts +++ b/run-run/biome-plugin/src/__tests__/tool-versions.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { minVersion, satisfies, subset } from "semver"; import { describe, expect, it } from "vitest"; import { TOOL_VERSIONS } from "../tool-versions.ts"; @@ -10,11 +11,13 @@ const pkg = JSON.parse(readFileSync(path.resolve(here, "../../package.json"), "u }; describe("TOOL_VERSIONS coherence with this plugin's package.json", () => { - for (const [name, entry] of Object.entries(TOOL_VERSIONS) as Array<[string, { install: string; peer?: string }]>) { - const expected = entry.peer; - if (!expected) continue; - it(`${name}: peer range matches package.json`, () => { - expect(pkg.peerDependencies?.[name]).toBe(expected); + for (const [name, entry] of Object.entries(TOOL_VERSIONS)) { + const peerRange = pkg.peerDependencies?.[name]; + if (!peerRange) continue; + + it(`${name}: install range fits within the declared peer range`, () => { + const ok = subset(entry.install, peerRange) || satisfies(minVersion(entry.install)?.version ?? "", peerRange); + expect(ok, `install=${entry.install} does not fit peer=${peerRange}`).toBe(true); }); } }); diff --git a/run-run/biome-plugin/src/index.ts b/run-run/biome-plugin/src/index.ts index 7e1b632f..a70e59fb 100644 --- a/run-run/biome-plugin/src/index.ts +++ b/run-run/biome-plugin/src/index.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { + decideScaffold, definePlugin, type FileOp, type FormatOptions, @@ -59,7 +60,11 @@ export class BiomeService extends ToolService implements Formatter, Linter, Stat export async function install(ctx: InstallContext): Promise { const biomeJsonPath = path.join(ctx.appPkg.dirPath, BIOME_JSON); const fileExists = await pathExists(biomeJsonPath); - const scaffoldDecision = await decideScaffoldAction(ctx, fileExists); + const scaffoldDecision = await decideScaffold(ctx, { + label: BIOME_JSON, + fileExists, + patchHint: `add ${BIOME_CONFIG_PKG} to extends, keep my other settings`, + }); if (scaffoldDecision === "skip") { return { devDependencies: { "@biomejs/biome": TOOL_VERSIONS["@biomejs/biome"].install } }; @@ -126,37 +131,6 @@ export async function uninstall(ctx: UninstallContext): Promise return { removeDependencies, files }; } -type ExistingFileAction = "skip" | "patch" | "overwrite"; - -async function decideScaffoldAction( - ctx: InstallContext, - fileExists: boolean, -): Promise<"create" | "patch" | "overwrite" | "skip"> { - if (!fileExists) { - if (ctx.flags.yes || ctx.flags.nonInteractive) return "create"; - const choice = await ctx.prompts.confirm({ - message: `Scaffold ${BIOME_JSON} with the @rrlab/biome-config preset?`, - initialValue: true, - }); - if (ctx.prompts.isCancel(choice)) throw new Error("Cancelled by user."); - return choice ? "create" : "skip"; - } - - if (ctx.flags.yes || ctx.flags.nonInteractive) return "patch"; - - const choice = await ctx.prompts.select({ - message: `${BIOME_JSON} already exists. What do you want to do?`, - options: [ - { value: "patch", label: "Patch — add @rrlab/biome-config to extends, keep my other settings" }, - { value: "skip", label: "Skip — leave it alone" }, - { value: "overwrite", label: "Overwrite — replace with a fresh scaffold" }, - ], - initialValue: "patch", - }); - if (ctx.prompts.isCancel(choice)) throw new Error("Cancelled by user."); - return choice; -} - async function pathExists(p: string): Promise { try { await fs.access(p); @@ -166,26 +140,14 @@ async function pathExists(p: string): Promise { } } -const biome = definePlugin(() => ({ +const biome = definePlugin(() => ({ name: "biome", apiVersion: 1, install, uninstall, - async setup({ shell }) { + capabilities: ({ shell }) => { const svc = new BiomeService(shell); - try { - await svc.getBinDir(); - } catch (_err) { - throw new Error( - "@rrlab/biome-plugin requires @biomejs/biome to be installed in the host project. " + - "Run: rr plugins add biome (or: pnpm add -D @biomejs/biome)", - ); - } - return { - lint: svc, - format: svc, - jsc: svc, - }; + return { lint: svc, format: svc, jsc: svc }; }, })); diff --git a/run-run/biome-plugin/src/tool-versions.ts b/run-run/biome-plugin/src/tool-versions.ts index c23f5817..89f63096 100644 --- a/run-run/biome-plugin/src/tool-versions.ts +++ b/run-run/biome-plugin/src/tool-versions.ts @@ -1,3 +1,3 @@ export const TOOL_VERSIONS = { - "@biomejs/biome": { install: "^2.0.0", peer: ">=2.0.0" }, + "@biomejs/biome": { install: "^2.0.0" }, } as const; diff --git a/run-run/cli/src/lib/plugin.ts b/run-run/cli/src/lib/plugin.ts index adf4a507..cd19a8a8 100644 --- a/run-run/cli/src/lib/plugin.ts +++ b/run-run/cli/src/lib/plugin.ts @@ -1,4 +1,6 @@ -export { definePlugin } from "#src/plugin/define-plugin.ts"; +export { type DecideScaffoldOptions, decideScaffold, type ScaffoldDecision } from "#src/plugin/decide-scaffold.ts"; +export { definePlugin, type PluginDefinition } from "#src/plugin/define-plugin.ts"; +export { type PickPresetOptions, pickPreset } from "#src/plugin/pick-preset.ts"; export { ToolService, type ToolServiceOptions } from "#src/plugin/tool-service.ts"; export type { ClackPrompts, diff --git a/run-run/cli/src/plugin/__tests__/bin-probe.test.ts b/run-run/cli/src/plugin/__tests__/bin-probe.test.ts new file mode 100644 index 00000000..d81999dc --- /dev/null +++ b/run-run/cli/src/plugin/__tests__/bin-probe.test.ts @@ -0,0 +1,69 @@ +import type { ShellService } from "@vlandoss/clibuddy"; +import { describe, expect, it } from "vitest"; +import { probeBins } from "#src/plugin/bin-probe.ts"; +import { ToolService } from "#src/plugin/tool-service.ts"; + +const FROM = import.meta.url; + +class ResolvableService extends ToolService { + constructor() { + super({ pkg: "typescript", bin: "tsc", ui: "fake", shellService: {} as ShellService, from: FROM }); + } +} + +class MissingService extends ToolService { + constructor() { + super({ + pkg: "ghostly-bin-that-does-not-exist", + ui: "fake", + shellService: {} as ShellService, + from: FROM, + }); + } +} + +class AlsoMissing extends ToolService { + constructor() { + super({ + pkg: "another-ghost-bin", + ui: "fake", + shellService: {} as ShellService, + from: FROM, + }); + } +} + +describe("probeBins", () => { + it("is a no-op when given an empty list", async () => { + await expect(probeBins([], "noop")).resolves.toBeUndefined(); + }); + + it("ignores non-ToolService values silently", async () => { + await expect(probeBins([{ random: "object" }, 42, null], "noop")).resolves.toBeUndefined(); + }); + + it("succeeds when every distinct pkg resolves", async () => { + await expect(probeBins([new ResolvableService(), new ResolvableService()], "ts")).resolves.toBeUndefined(); + }); + + it("throws with the canonical message listing the missing pkg", async () => { + await expect(probeBins([new MissingService()], "ghostly")).rejects.toThrow( + /@rrlab\/ghostly-plugin requires ghostly-bin-that-does-not-exist to be installed in the host project\. Run: rr plugins add ghostly {2}\(or: pnpm add -D ghostly-bin-that-does-not-exist\)/, + ); + }); + + it("lists multiple distinct missing pkgs in the same error", async () => { + await expect(probeBins([new MissingService(), new AlsoMissing()], "ghostly")).rejects.toThrow( + /requires .*(ghostly-bin-that-does-not-exist|another-ghost-bin).*(another-ghost-bin|ghostly-bin-that-does-not-exist)/, + ); + }); + + it("deduplicates services sharing a pkg into a single probe", async () => { + // Two MissingService instances → one error message mention of the pkg. + const err = await probeBins([new MissingService(), new MissingService()], "ghostly").catch((e) => e); + expect(err).toBeInstanceOf(Error); + const message = (err as Error).message; + const matches = message.match(/ghostly-bin-that-does-not-exist/g); + expect(matches?.length).toBe(2); // once in the "requires X" clause and once in the "pnpm add -D X" hint + }); +}); diff --git a/run-run/cli/src/plugin/__tests__/decide-scaffold.test.ts b/run-run/cli/src/plugin/__tests__/decide-scaffold.test.ts new file mode 100644 index 00000000..99fc4696 --- /dev/null +++ b/run-run/cli/src/plugin/__tests__/decide-scaffold.test.ts @@ -0,0 +1,101 @@ +import type { Pkg, ShellService } from "@vlandoss/clibuddy"; +import { describe, expect, it, vi } from "vitest"; +import { decideScaffold } from "#src/plugin/decide-scaffold.ts"; +import type { ClackPrompts, InstallContext } from "#src/plugin/types.ts"; + +function makeCtx(overrides: { prompts?: ClackPrompts; flags?: Partial } = {}): InstallContext { + return { + shell: {} as ShellService, + // biome-ignore lint/suspicious/noExplicitAny: minimal stub + logger: {} as any, + appPkg: { dirPath: process.cwd() } as Pkg, + prompts: overrides.prompts ?? ({} as ClackPrompts), + flags: { force: false, yes: false, nonInteractive: false, ...overrides.flags }, + // biome-ignore lint/suspicious/noExplicitAny: ReleaseService is not exercised here + release: {} as any, + }; +} + +describe("decideScaffold", () => { + describe("unattended (--yes or non-interactive)", () => { + it("returns 'create' when file does not exist", async () => { + const decision = await decideScaffold(makeCtx({ flags: { yes: true } }), { + label: "biome.json", + fileExists: false, + patchHint: "x", + }); + expect(decision).toBe("create"); + }); + + it("returns 'patch' when file exists and default unattendedExistingAction", async () => { + const decision = await decideScaffold(makeCtx({ flags: { yes: true } }), { + label: "biome.json", + fileExists: true, + patchHint: "x", + }); + expect(decision).toBe("patch"); + }); + + it("returns 'skip' when file exists and unattendedExistingAction: 'skip'", async () => { + const decision = await decideScaffold(makeCtx({ flags: { nonInteractive: true } }), { + label: "tsdown.config.ts", + fileExists: true, + patchHint: "x", + unattendedExistingAction: "skip", + }); + expect(decision).toBe("skip"); + }); + }); + + describe("interactive", () => { + it("confirms creation when file does not exist", async () => { + const confirm = vi.fn().mockResolvedValue(true); + const ctx = makeCtx({ + prompts: { confirm, select: vi.fn(), isCancel: () => false } as unknown as ClackPrompts, + }); + const decision = await decideScaffold(ctx, { label: "biome.json", fileExists: false, patchHint: "x" }); + expect(decision).toBe("create"); + expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("biome.json") })); + }); + + it("returns 'skip' when user declines the create confirm", async () => { + const ctx = makeCtx({ + prompts: { + confirm: vi.fn().mockResolvedValue(false), + select: vi.fn(), + isCancel: () => false, + } as unknown as ClackPrompts, + }); + const decision = await decideScaffold(ctx, { label: "biome.json", fileExists: false, patchHint: "x" }); + expect(decision).toBe("skip"); + }); + + it("shows the patch/skip/overwrite select when file exists", async () => { + const select = vi.fn().mockResolvedValue("overwrite"); + const ctx = makeCtx({ + prompts: { confirm: vi.fn(), select, isCancel: () => false } as unknown as ClackPrompts, + }); + const decision = await decideScaffold(ctx, { + label: "biome.json", + fileExists: true, + patchHint: "add @rrlab/biome-config to extends", + }); + expect(decision).toBe("overwrite"); + const call = select.mock.calls[0]?.[0] as { options: Array<{ value: string; label: string }> }; + expect(call.options.map((o) => o.value).sort()).toEqual(["overwrite", "patch", "skip"]); + expect(call.options.find((o) => o.value === "patch")?.label).toMatch(/add @rrlab\/biome-config to extends/); + }); + + it("throws 'Cancelled by user.' when the user cancels", async () => { + const cancelSymbol = Symbol("cancel"); + const ctx = makeCtx({ + prompts: { + confirm: vi.fn().mockResolvedValue(cancelSymbol), + select: vi.fn(), + isCancel: (v: unknown) => v === cancelSymbol, + } as unknown as ClackPrompts, + }); + await expect(decideScaffold(ctx, { label: "x", fileExists: false, patchHint: "y" })).rejects.toThrow("Cancelled by user."); + }); + }); +}); diff --git a/run-run/cli/src/plugin/__tests__/define-plugin.test.ts b/run-run/cli/src/plugin/__tests__/define-plugin.test.ts new file mode 100644 index 00000000..ac0f54c9 --- /dev/null +++ b/run-run/cli/src/plugin/__tests__/define-plugin.test.ts @@ -0,0 +1,111 @@ +import type { Pkg, ShellService } from "@vlandoss/clibuddy"; +import { describe, expect, it } from "vitest"; +import { definePlugin } from "#src/plugin/define-plugin.ts"; +import { ToolService } from "#src/plugin/tool-service.ts"; +import type { Formatter, Linter, PluginContext, StaticChecker, TypeChecker } from "#src/plugin/types.ts"; + +function ctx(): PluginContext { + return { + shell: {} as ShellService, + // biome-ignore lint/suspicious/noExplicitAny: minimal stub + logger: {} as any, + appPkg: { dirPath: process.cwd() } as Pkg, + binPkg: { dirPath: process.cwd() } as Pkg, + cwd: process.cwd(), + }; +} + +const FROM = import.meta.url; + +class FakeBiome extends ToolService implements Linter, Formatter, StaticChecker { + constructor() { + super({ pkg: "@biomejs/biome", bin: "biome", ui: "fake", shellService: {} as ShellService, from: FROM }); + } + async lint() {} + async format() {} + async check() {} +} + +class FakeTsc extends ToolService implements TypeChecker { + constructor() { + super({ pkg: "typescript", bin: "tsc", ui: "fake", shellService: {} as ShellService, from: FROM }); + } + async check() {} +} + +class MissingService extends ToolService implements Linter { + constructor() { + super({ + pkg: "ghostly-bin-that-does-not-exist", + ui: "fake", + shellService: {} as ShellService, + from: FROM, + }); + } + async lint() {} +} + +describe("definePlugin", () => { + it("returns a Plugin shape the registry expects", async () => { + const factory = definePlugin(() => ({ + name: "fake-linter", + apiVersion: 1 as const, + capabilities: () => ({ lint: new FakeBiome() }), + })); + const plugin = factory(); + expect(plugin.name).toBe("fake-linter"); + expect(plugin.apiVersion).toBe(1); + expect(typeof plugin.capabilities).toBe("function"); + const caps = await plugin.capabilities(ctx()); + expect(Object.keys(caps)).toEqual(["lint"]); + }); + + it("filters capabilities by 'only'", async () => { + const factory = definePlugin(() => ({ + name: "biome", + apiVersion: 1 as const, + capabilities: () => { + const svc = new FakeBiome(); + return { lint: svc, format: svc, jsc: svc }; + }, + })); + const caps = await factory({ only: ["lint", "format"] }).capabilities(ctx()); + expect(Object.keys(caps).sort()).toEqual(["format", "lint"]); + }); + + it("throws on unknown kind in 'only' with the canonical message", async () => { + const factory = definePlugin(() => ({ + name: "biome", + apiVersion: 1 as const, + capabilities: () => { + const svc = new FakeBiome(); + return { lint: svc, format: svc, jsc: svc }; + }, + })); + await expect( + // biome-ignore lint/suspicious/noExplicitAny: bypass TS to exercise the runtime guard + factory({ only: ["tsc"] as any }).capabilities(ctx()), + ).rejects.toThrow(/@rrlab\/biome-plugin: unknown capability 'tsc' in 'only'\. Available: /); + }); + + it("throws when a required pkg is missing in the host", async () => { + const factory = definePlugin(() => ({ + name: "ghostly", + apiVersion: 1 as const, + capabilities: () => ({ lint: new MissingService() }), + })); + await expect(factory().capabilities(ctx())).rejects.toThrow( + /@rrlab\/ghostly-plugin requires ghostly-bin-that-does-not-exist to be installed/, + ); + }); + + it("succeeds when distinct services share a pkg (deduplicated probe)", async () => { + const factory = definePlugin(() => ({ + name: "ts", + apiVersion: 1 as const, + capabilities: () => ({ tsc: new FakeTsc() }), + })); + const caps = await factory().capabilities(ctx()); + expect(Object.keys(caps)).toEqual(["tsc"]); + }); +}); diff --git a/run-run/cli/src/plugin/__tests__/pick-preset.test.ts b/run-run/cli/src/plugin/__tests__/pick-preset.test.ts new file mode 100644 index 00000000..7c7de065 --- /dev/null +++ b/run-run/cli/src/plugin/__tests__/pick-preset.test.ts @@ -0,0 +1,72 @@ +import type { Pkg, ShellService } from "@vlandoss/clibuddy"; +import { describe, expect, it, vi } from "vitest"; +import { pickPreset } from "#src/plugin/pick-preset.ts"; +import type { ClackPrompts, InstallContext } from "#src/plugin/types.ts"; + +function makeCtx(overrides: { prompts?: ClackPrompts; flags?: Partial } = {}): InstallContext { + return { + shell: {} as ShellService, + // biome-ignore lint/suspicious/noExplicitAny: minimal stub + logger: {} as any, + appPkg: { dirPath: process.cwd() } as Pkg, + prompts: overrides.prompts ?? ({} as ClackPrompts), + flags: { force: false, yes: false, nonInteractive: false, ...overrides.flags }, + // biome-ignore lint/suspicious/noExplicitAny: ReleaseService not exercised here + release: {} as any, + }; +} + +const PRESETS = { + lib: { label: "Library" }, + bin: { label: "CLI / Node binary" }, +}; + +describe("pickPreset", () => { + it("returns the defaultPreset under --yes", async () => { + const choice = await pickPreset(makeCtx({ flags: { yes: true } }), { + message: "Which kind of build?", + presets: PRESETS, + defaultPreset: "lib", + }); + expect(choice).toBe("lib"); + }); + + it("returns the defaultPreset under non-interactive", async () => { + const choice = await pickPreset(makeCtx({ flags: { nonInteractive: true } }), { + message: "x", + presets: PRESETS, + defaultPreset: "bin", + }); + expect(choice).toBe("bin"); + }); + + it("returns the user's interactive choice", async () => { + const select = vi.fn().mockResolvedValue("bin"); + const ctx = makeCtx({ + prompts: { confirm: vi.fn(), select, isCancel: () => false } as unknown as ClackPrompts, + }); + const choice = await pickPreset(ctx, { + message: "Which kind of build?", + presets: PRESETS, + defaultPreset: "lib", + }); + expect(choice).toBe("bin"); + const call = select.mock.calls[0]?.[0] as { options: Array<{ value: string; label: string }> }; + expect(call.options).toEqual([ + { value: "lib", label: "Library" }, + { value: "bin", label: "CLI / Node binary" }, + ]); + }); + + it("throws 'Cancelled by user.' when the user cancels", async () => { + const cancelSymbol = Symbol("cancel"); + const ctx = makeCtx({ + prompts: { + confirm: vi.fn(), + select: vi.fn().mockResolvedValue(cancelSymbol), + isCancel: (v: unknown) => v === cancelSymbol, + } as unknown as ClackPrompts, + }); + await expect(pickPreset(ctx, { message: "x", presets: PRESETS, defaultPreset: "lib" })).rejects.toThrow("Cancelled by user."); + }); +}); diff --git a/run-run/cli/src/plugin/__tests__/registry.test.ts b/run-run/cli/src/plugin/__tests__/registry.test.ts index 20d9665c..62e5e3c1 100644 --- a/run-run/cli/src/plugin/__tests__/registry.test.ts +++ b/run-run/cli/src/plugin/__tests__/registry.test.ts @@ -15,7 +15,7 @@ function plugin(name: string): Plugin { return { name, apiVersion: 1, - setup: () => ({}), + capabilities: async () => ({}), }; } @@ -74,6 +74,13 @@ describe("PluginRegistry", () => { expect(() => registry.get("lint")).toThrowError(/biome.*eslint|eslint.*biome/); }); + it("suggests the 'only' option when reporting multi-provider conflicts", () => { + const registry = new PluginRegistry(); + registry.register(plugin("biome"), { lint: fakeLinter() }); + registry.register(plugin("oxc"), { lint: fakeLinter() }); + expect(() => registry.get("lint")).toThrowError(/only:\s*\['lint'\]/); + }); + it("supports tsc and pack kinds", () => { const registry = new PluginRegistry(); const tc = fakeTypeChecker(); diff --git a/run-run/cli/src/plugin/bin-probe.ts b/run-run/cli/src/plugin/bin-probe.ts new file mode 100644 index 00000000..89a974b9 --- /dev/null +++ b/run-run/cli/src/plugin/bin-probe.ts @@ -0,0 +1,30 @@ +import { ToolService } from "./tool-service.ts"; + +export async function probeBins(services: readonly unknown[], pluginName: string): Promise { + const toolServices = services.filter((s): s is ToolService => s instanceof ToolService); + const distinct = new Map(); + for (const svc of toolServices) { + if (!distinct.has(svc.pkg)) distinct.set(svc.pkg, svc); + } + + if (distinct.size === 0) return; + + const probes = [...distinct.values()].map(async (svc) => { + try { + await svc.getBinDir(); + } catch { + return svc.pkg; + } + return null; + }); + + const results = await Promise.all(probes); + const missing = results.filter((p): p is string => p !== null); + if (missing.length === 0) return; + + const pkgName = `@rrlab/${pluginName}-plugin`; + throw new Error( + `${pkgName} requires ${missing.join(", ")} to be installed in the host project. ` + + `Run: rr plugins add ${pluginName} (or: pnpm add -D ${missing.join(" ")})`, + ); +} diff --git a/run-run/cli/src/plugin/decide-scaffold.ts b/run-run/cli/src/plugin/decide-scaffold.ts new file mode 100644 index 00000000..e55493a4 --- /dev/null +++ b/run-run/cli/src/plugin/decide-scaffold.ts @@ -0,0 +1,46 @@ +import type { InstallContext } from "./types.ts"; + +export type ScaffoldDecision = "create" | "patch" | "overwrite" | "skip"; + +export type DecideScaffoldOptions = { + /** The config file label shown to the user (e.g. `"biome.json"`, `"tsdown.config.ts"`). */ + label: string; + /** Whether the file currently exists in the app project. */ + fileExists: boolean; + /** Short description of what "patch" does, shown in the select option. */ + patchHint: string; + /** + * What to return when the file exists and the run is unattended (`--yes` / non-interactive). + * - `"patch"` (default): assume the user wants to merge our config into theirs (safe for JSON we can edit). + * - `"skip"`: assume the user owns the file (right for TS modules we'd otherwise rewrite blindly). + */ + unattendedExistingAction?: "patch" | "skip"; +}; + +export async function decideScaffold(ctx: InstallContext, opts: DecideScaffoldOptions): Promise { + const { label, fileExists, patchHint, unattendedExistingAction = "patch" } = opts; + + if (!fileExists) { + if (ctx.flags.yes || ctx.flags.nonInteractive) return "create"; + const choice = await ctx.prompts.confirm({ + message: `Scaffold ${label}?`, + initialValue: true, + }); + if (ctx.prompts.isCancel(choice)) throw new Error("Cancelled by user."); + return choice ? "create" : "skip"; + } + + if (ctx.flags.yes || ctx.flags.nonInteractive) return unattendedExistingAction; + + const choice = await ctx.prompts.select({ + message: `${label} already exists. What do you want to do?`, + options: [ + { value: "patch", label: `Patch — ${patchHint}` }, + { value: "skip", label: "Skip — leave it alone" }, + { value: "overwrite", label: "Overwrite — replace with a fresh scaffold" }, + ], + initialValue: "patch", + }); + if (ctx.prompts.isCancel(choice)) throw new Error("Cancelled by user."); + return choice; +} diff --git a/run-run/cli/src/plugin/define-plugin.ts b/run-run/cli/src/plugin/define-plugin.ts index 7975a32f..1ff1253e 100644 --- a/run-run/cli/src/plugin/define-plugin.ts +++ b/run-run/cli/src/plugin/define-plugin.ts @@ -1,5 +1,54 @@ -import type { Plugin } from "./types.ts"; +import { probeBins } from "./bin-probe.ts"; +import type { + InstallContext, + InstallResult, + Plugin, + PluginCapabilities, + PluginContext, + PluginKind, + UninstallContext, + UninstallResult, +} from "./types.ts"; -export function definePlugin(factory: (options: T) => Plugin): (options: T) => Plugin { - return factory; +type Caps = Partial; + +export type PluginDefinition = { + name: string; + apiVersion: 1; + capabilities(ctx: PluginContext): TCaps | Promise; + install?(this: void, ctx: InstallContext): Promise; + uninstall?(this: void, ctx: UninstallContext): Promise; +}; + +type WithOnly = TOptions extends void + ? { only?: readonly TKind[] } + : TOptions & { only?: readonly TKind[] }; + +export function definePlugin( + factory: (options: TOptions) => PluginDefinition, +): (options?: WithOnly) => Plugin { + return (options) => { + // biome-ignore lint/suspicious/noExplicitAny: factory accepts TOptions; callers without options pass undefined + const def = factory(options as any); + const only = (options as { only?: readonly string[] } | undefined)?.only; + const pkgName = `@rrlab/${def.name}-plugin`; + + return { + name: def.name, + apiVersion: def.apiVersion, + install: def.install, + uninstall: def.uninstall, + async capabilities(ctx: PluginContext): Promise { + const map = (await def.capabilities(ctx)) as PluginCapabilities; + await probeBins(Object.values(map), def.name); + if (!only) return map; + for (const k of only) { + if (!(k in map)) { + throw new Error(`${pkgName}: unknown capability '${k}' in 'only'. Available: ${Object.keys(map).join(", ")}.`); + } + } + return Object.fromEntries(only.map((k) => [k, map[k as PluginKind]])) as PluginCapabilities; + }, + }; + }; } diff --git a/run-run/cli/src/plugin/pick-preset.ts b/run-run/cli/src/plugin/pick-preset.ts new file mode 100644 index 00000000..d983fb60 --- /dev/null +++ b/run-run/cli/src/plugin/pick-preset.ts @@ -0,0 +1,23 @@ +import type { InstallContext } from "./types.ts"; + +export type PickPresetOptions = { + message: string; + presets: Record; + defaultPreset: K; +}; + +export async function pickPreset(ctx: InstallContext, opts: PickPresetOptions): Promise { + const { message, presets, defaultPreset } = opts; + if (ctx.flags.yes || ctx.flags.nonInteractive) return defaultPreset; + + const choice = await ctx.prompts.select({ + message, + options: (Object.entries(presets) as Array<[K, { label: string }]>).map(([value, meta]) => ({ + value, + label: meta.label, + })), + initialValue: defaultPreset, + }); + if (ctx.prompts.isCancel(choice)) throw new Error("Cancelled by user."); + return choice; +} diff --git a/run-run/cli/src/plugin/registry.ts b/run-run/cli/src/plugin/registry.ts index e5225706..9ee2bdc2 100644 --- a/run-run/cli/src/plugin/registry.ts +++ b/run-run/cli/src/plugin/registry.ts @@ -18,9 +18,10 @@ export class PluginRegistry { if (!first) return undefined; if (rest.length > 0) { const names = providers.map(({ plugin }) => plugin.name).join(", "); + const example = providers.map(({ plugin }) => `${plugin.name}({ only: ['${kind}'] })`).join(" or "); throw new Error( `Multiple plugins provide capability '${kind}': ${names}. ` + - "Disambiguate by narrowing each plugin's capabilities in run-run.config.ts.", + `Narrow each plugin's capabilities in run-run.config.ts using the 'only' option — e.g. ${example}.`, ); } return first.impl; diff --git a/run-run/cli/src/plugin/types.ts b/run-run/cli/src/plugin/types.ts index f6d20399..99ff179f 100644 --- a/run-run/cli/src/plugin/types.ts +++ b/run-run/cli/src/plugin/types.ts @@ -20,7 +20,7 @@ export type { export type Packer = { bin: string; ui: string; - pack(): Promise; + pack: () => Promise; }; export const PLUGIN_KINDS = ["lint", "format", "jsc", "tsc", "pack"] as const; @@ -46,7 +46,7 @@ export type PluginContext = { export type Plugin = { name: string; apiVersion: 1; - setup(ctx: PluginContext): Promise | PluginCapabilities; + capabilities(ctx: PluginContext): Promise; install?(ctx: InstallContext): Promise; uninstall?(ctx: UninstallContext): Promise; }; diff --git a/run-run/cli/src/program/commands/check.ts b/run-run/cli/src/program/commands/check.ts index 3cd35bfa..ed22012b 100644 --- a/run-run/cli/src/program/commands/check.ts +++ b/run-run/cli/src/program/commands/check.ts @@ -1,5 +1,6 @@ import { type Command, createCommand } from "commander"; -import { TOOL_LABELS } from "#src/program/ui.ts"; +import { pluginAnnotation } from "#src/program/ui.ts"; +import type { Context } from "#src/services/ctx.ts"; import { logger } from "#src/services/logger.ts"; /** @@ -15,9 +16,9 @@ import { logger } from "#src/services/logger.ts"; * `command.js` — `fn.apply(this, actionArgs)`). `this.parent` gives us the * parent program without any late-binding ceremony. */ -export function createCheckCommand() { +export function createCheckCommand(ctx: Context) { return createCommand("check") - .summary(`run static checks (${TOOL_LABELS.RUN_RUN})`) + .summary(`run static checks${checkAnnotation(ctx)}`) .description( "Runs `rr jsc` and `rr tsc` concurrently in-process. Aggregates their exit codes — non-zero when either subcommand fails.", ) @@ -61,3 +62,31 @@ export function createCheckCommand() { function findCommand(program: Command, name: string): Command | undefined { return program.commands.find((c) => c.name() === name || c.aliases().includes(name)); } + +/** + * Mirrors the provider resolution of `jsc` + `tsc` and flattens the + * underlying tool labels — e.g. biome (composed lint+format) + oxc (tsc) + * renders as `(biome, oxlint)` rather than `(biome + biome, oxlint)`. When + * neither sibling has a provider, falls back to the standard `(not + * configured)` annotation so the help reads consistently with other + * commands. + */ +function checkAnnotation(ctx: Context): string { + const directJsc = ctx.registry.get("jsc"); + const linter = ctx.registry.get("lint"); + const formatter = ctx.registry.get("format"); + const tsc = ctx.registry.get("tsc"); + + const labels: string[] = []; + if (directJsc) { + labels.push(directJsc.ui); + } else { + if (linter) labels.push(linter.ui); + if (formatter) labels.push(formatter.ui); + } + if (tsc) labels.push(tsc.ui); + + if (labels.length === 0) return pluginAnnotation(undefined); + const distinct = [...new Set(labels)]; + return pluginAnnotation({ ui: distinct.join(", ") }); +} diff --git a/run-run/cli/src/program/commands/plugins.ts b/run-run/cli/src/program/commands/plugins.ts index 6492f0cc..7e93c18d 100644 --- a/run-run/cli/src/program/commands/plugins.ts +++ b/run-run/cli/src/program/commands/plugins.ts @@ -212,7 +212,7 @@ async function runRemove(ctx: Context, alias: OfficialAlias, opts: RemoveOptions }); } } catch (err) { - clack.log.warn(`Could not load ${pkgName} for uninstall hook: ${err instanceof Error ? err.message : err}`); + clack.log.warn(`Could not load ${pkgName} for uninstall hook: ${err instanceof Error ? err.message : String(err)}`); } } @@ -290,9 +290,9 @@ function isDistTag(spec: string): boolean { function hasInPackageJson(ctx: Context, pkgName: string): boolean { const pkg = ctx.appPkg.packageJson; const deps = { - ...(pkg.dependencies ?? {}), - ...(pkg.devDependencies ?? {}), - ...(pkg.peerDependencies ?? {}), + ...pkg.dependencies, + ...pkg.devDependencies, + ...pkg.peerDependencies, }; return pkgName in deps; } diff --git a/run-run/cli/src/program/index.ts b/run-run/cli/src/program/index.ts index 90cfde27..07ab8f64 100644 --- a/run-run/cli/src/program/index.ts +++ b/run-run/cli/src/program/index.ts @@ -40,7 +40,7 @@ export async function createProgram(options: Options) { .addCommand(createTsCheckCommand(ctx)) .addCommand(createLintCommand(ctx)) .addCommand(createFormatCommand(ctx)) - .addCommand(createCheckCommand()) + .addCommand(createCheckCommand(ctx)) .addCommand(createDoctorCommand(ctx)) .addCommand(createPluginsCommand(ctx)) .addCommand(createCleanCommand()) diff --git a/run-run/cli/src/services/ctx.ts b/run-run/cli/src/services/ctx.ts index efc922c1..b6976d73 100644 --- a/run-run/cli/src/services/ctx.ts +++ b/run-run/cli/src/services/ctx.ts @@ -52,13 +52,12 @@ export async function createContext(binDir: string): Promise { }; for (const plugin of config.config.plugins ?? []) { - if (plugin.apiVersion !== 1) { - throw new Error( - `Plugin '${plugin.name}' targets apiVersion ${plugin.apiVersion}, but this kernel supports only apiVersion 1.`, - ); + const got = plugin.apiVersion as number; + if (got !== 1) { + throw new Error(`Plugin '${plugin.name}' targets apiVersion ${got}, but this kernel supports only apiVersion 1.`); } debug("registering plugin: %s", plugin.name); - const capabilities = await plugin.setup(pluginContext); + const capabilities = await plugin.capabilities(pluginContext); registry.register(plugin, capabilities); } diff --git a/run-run/cli/src/types/tool.ts b/run-run/cli/src/types/tool.ts index ba3f4ba8..cd78c5df 100644 --- a/run-run/cli/src/types/tool.ts +++ b/run-run/cli/src/types/tool.ts @@ -24,25 +24,25 @@ export type DoctorResult = { export type Doctor = { ui: string; - doctor(): Promise; + doctor: () => Promise; }; export type Formatter = { bin: string; ui: string; - format(options: FormatOptions): Promise; + format: (options: FormatOptions) => Promise; }; export type Linter = { bin: string; ui: string; - lint(options: LintOptions): Promise; + lint: (options: LintOptions) => Promise; }; export type StaticChecker = { bin: string; ui: string; - check(options: StaticCheckerOptions): Promise; + check: (options: StaticCheckerOptions) => Promise; }; export type TypeCheckOptions = { @@ -53,5 +53,5 @@ export type TypeCheckOptions = { export type TypeChecker = { bin: string; ui: string; - check(options?: TypeCheckOptions): Promise; + check: (options?: TypeCheckOptions) => Promise; }; diff --git a/run-run/cli/test/helpers.ts b/run-run/cli/test/helpers.ts index df622c8d..0452c007 100644 --- a/run-run/cli/test/helpers.ts +++ b/run-run/cli/test/helpers.ts @@ -54,6 +54,18 @@ export function makeFixture(name: string, files: FixtureFiles): { dir: string; c type PluginAlias = "biome" | "oxc" | "ts" | "tsdown"; +type PluginEntry = PluginAlias | { alias: PluginAlias; only: readonly string[] }; + +function aliasOf(entry: PluginEntry): PluginAlias { + return typeof entry === "string" ? entry : entry.alias; +} + +function callOf(entry: PluginEntry): string { + if (typeof entry === "string") return `${entry}()`; + const only = entry.only.map((k) => `"${k}"`).join(", "); + return `${entry.alias}({ only: [${only}] })`; +} + export const fixtures = { pkg: (name = "rr-test-fixture") => `${JSON.stringify({ name, version: "0.0.0", private: true }, null, 2)}\n`, biomeNoop: () => @@ -77,9 +89,9 @@ export const fixtures = { null, 2, )}\n`, - config: (plugins: PluginAlias[]) => { - const imports = plugins.map((p) => `import ${p} from "@rrlab/${p}-plugin";`).join("\n"); - const list = plugins.map((p) => `${p}()`).join(", "); + config: (plugins: readonly PluginEntry[]) => { + const imports = plugins.map((p) => `import ${aliasOf(p)} from "@rrlab/${aliasOf(p)}-plugin";`).join("\n"); + const list = plugins.map(callOf).join(", "); return `${imports}\nimport { defineConfig } from "@rrlab/cli/config";\n\nexport default defineConfig({\n plugins: [${list}],\n});\n`; }, }; diff --git a/run-run/cli/test/integration/only.test.ts b/run-run/cli/test/integration/only.test.ts new file mode 100644 index 00000000..9e68584a --- /dev/null +++ b/run-run/cli/test/integration/only.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { createTestCli, fixtures, makeFixture } from "#test/helpers.ts"; + +const cli = createTestCli(); + +describe("plugin { only } narrowing", () => { + let fixture: { dir: string; cleanup: () => void }; + + afterEach(() => fixture?.cleanup()); + + describe("biome({only:['lint','format']}) + oxc({only:['tsc']})", () => { + beforeEach(() => { + fixture = makeFixture("only-biome-oxc", { + "package.json": fixtures.pkg(), + "biome.json": fixtures.biomeNoop(), + "tsconfig.json": fixtures.tsconfig(), + "run-run.config.mts": fixtures.config([ + { alias: "biome", only: ["lint", "format"] }, + { alias: "oxc", only: ["tsc"] }, + ]), + "src/ok.ts": "export const ok = 1;\n", + }); + }); + + test("rr lint dispatches to biome", () => { + const r = cli("lint", { cwd: fixture.dir }); + const combined = r.stdout + r.stderr; + expect(combined).toMatch(/\$ biome check .*--formatter-enabled=false/); + expect(r.status).toBe(0); + }); + + test("rr format dispatches to biome", () => { + const r = cli("format", { cwd: fixture.dir }); + const combined = r.stdout + r.stderr; + expect(combined).toMatch(/\$ biome format/); + expect(r.status).toBe(0); + }); + + test("rr tsc dispatches to oxlint with --type-aware --type-check", () => { + const r = cli("tsc", { cwd: fixture.dir }); + const combined = r.stdout + r.stderr; + expect(combined).toMatch(/\$ oxlint --type-aware --type-check/); + }, 15_000); + + test("rr jsc composes biome's lint+format (biome's direct jsc was narrowed away)", () => { + const r = cli("jsc", { cwd: fixture.dir }); + const combined = r.stdout + r.stderr; + expect(combined).toMatch(/\$ biome (check|format)/); + expect(r.status).toBe(0); + }); + }); + + describe("oxc({only:['tsc']}) alone", () => { + beforeEach(() => { + fixture = makeFixture("only-oxc-tsc", { + "package.json": fixtures.pkg(), + "run-run.config.mts": fixtures.config([{ alias: "oxc", only: ["tsc"] }]), + "src/ok.ts": "export const ok = 1;\n", + }); + }); + + test("rr lint has no provider — oxc's lint was narrowed away", () => { + const r = cli("lint", { cwd: fixture.dir }); + expect(r.status).not.toBe(0); + expect(r.stdout + r.stderr).toMatch(/no plugin provides|lint/i); + }); + }); + + describe("conflict — biome and oxc both unrestricted", () => { + beforeEach(() => { + fixture = makeFixture("only-conflict", { + "package.json": fixtures.pkg(), + "biome.json": fixtures.biomeNoop(), + "run-run.config.mts": fixtures.config(["biome", "oxc"]), + "src/ok.ts": "export const ok = 1;\n", + }); + }); + + test("rr lint reports the multi-provider conflict and suggests the 'only' option", () => { + const r = cli("lint", { cwd: fixture.dir }); + expect(r.status).not.toBe(0); + const combined = r.stdout + r.stderr; + expect(combined).toMatch(/Multiple plugins provide capability 'lint'/); + expect(combined).toMatch(/only:\s*\['lint'\]/); + }); + }); +}); diff --git a/run-run/cli/test/integration/plugin-discipline.test.ts b/run-run/cli/test/integration/plugin-discipline.test.ts new file mode 100644 index 00000000..378af0f4 --- /dev/null +++ b/run-run/cli/test/integration/plugin-discipline.test.ts @@ -0,0 +1,56 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { dirnameOf } from "@vlandoss/clibuddy"; +import { describe, expect, it } from "vitest"; + +const HERE = dirnameOf(import.meta); +const RUN_RUN = path.resolve(HERE, "../../.."); + +const PLUGIN_DIRS = [ + path.resolve(RUN_RUN, "biome-plugin/src"), + path.resolve(RUN_RUN, "oxc-plugin/src"), + path.resolve(RUN_RUN, "ts-plugin/src"), + path.resolve(RUN_RUN, "tsdown-plugin/src"), +] as const; + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + if (entry === "__tests__") continue; // tests have permission to do whatever + const abs = path.join(dir, entry); + const st = statSync(abs); + if (st.isDirectory()) out.push(...walk(abs)); + else if (entry.endsWith(".ts")) out.push(abs); + } + return out; +} + +const PROMPT_USAGE = /\bctx\.prompts\b/; +const CLACK_IMPORT = /from\s+["']@clack\/prompts["']/; + +describe("plugin discipline", () => { + it("no plugin source file accesses ctx.prompts directly", () => { + const violations: string[] = []; + for (const dir of PLUGIN_DIRS) { + for (const file of walk(dir)) { + const src = readFileSync(file, "utf8"); + if (PROMPT_USAGE.test(src)) violations.push(path.relative(RUN_RUN, file)); + } + } + expect( + violations, + `Plugins must not call ctx.prompts directly — route through @rrlab/cli/plugin helpers (decideScaffold, pickPreset). Offenders: ${violations.join(", ")}`, + ).toEqual([]); + }); + + it("no plugin source file imports @clack/prompts directly", () => { + const violations: string[] = []; + for (const dir of PLUGIN_DIRS) { + for (const file of walk(dir)) { + const src = readFileSync(file, "utf8"); + if (CLACK_IMPORT.test(src)) violations.push(path.relative(RUN_RUN, file)); + } + } + expect(violations, `Plugins must not import @clack/prompts directly. Offenders: ${violations.join(", ")}`).toEqual([]); + }); +}); diff --git a/run-run/cli/test/integration/plugins.test.ts b/run-run/cli/test/integration/plugins.test.ts index 54a24921..a5e86eda 100644 --- a/run-run/cli/test/integration/plugins.test.ts +++ b/run-run/cli/test/integration/plugins.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import { createTestCli, fixtures, makeFixture } from "#test/helpers.ts"; @@ -127,5 +129,19 @@ describe("rr plugins", () => { expect(r.stdout + r.stderr).toMatch(/Removed biome\(\) from run-run\.config\.mts/); expect(r.status).toBe(0); }); + + test("removes an entry that carries an `only` option object", () => { + fixture = makeFixture("plugins-remove-only", { + "package.json": fixtures.pkg(), + "run-run.config.mts": fixtures.config([{ alias: "biome", only: ["lint", "format"] }, "ts"]), + }); + const r = cli("plugins remove biome --yes", { cwd: fixture.dir }); + expect(r.stdout + r.stderr).toMatch(/Removed biome\(\) from run-run\.config\.mts/); + expect(r.status).toBe(0); + + const after = readFileSync(path.join(fixture.dir, "run-run.config.mts"), "utf8"); + expect(after).not.toMatch(/biome/); + expect(after).toMatch(/ts\(\)/); + }); }); }); diff --git a/run-run/oxc-plugin/README.md b/run-run/oxc-plugin/README.md index efcbfa44..4789139a 100644 --- a/run-run/oxc-plugin/README.md +++ b/run-run/oxc-plugin/README.md @@ -1,6 +1,6 @@ # @rrlab/oxc-plugin -[oxc](https://oxc.rs) plugin for [`@rrlab/cli`](https://npmjs.com/package/@rrlab/cli). Provides `lint` (oxlint) and `format` (oxfmt) capabilities. +[oxc](https://oxc.rs) plugin for [`@rrlab/cli`](https://npmjs.com/package/@rrlab/cli). Provides `lint` (oxlint), `format` (oxfmt), and `tsc` (oxlint type-aware) capabilities. ## Install @@ -8,17 +8,37 @@ rr plugins add oxc ``` -Installs `@rrlab/oxc-plugin` and adds `oxlint`, `oxfmt`, and `oxlint-tsgolint` (optional) as `devDependencies`. No config file is scaffolded — oxlint and oxfmt work with sensible defaults and the projects that need to customise add their own `oxlintrc.json` / `.oxfmtrc` on demand. +Installs `@rrlab/oxc-plugin` and adds `oxlint`, `oxfmt`, and `oxlint-tsgolint` as `devDependencies`. No config file is scaffolded — oxlint and oxfmt work with sensible defaults and the projects that need to customise add their own `oxlintrc.json` / `.oxfmtrc` on demand. For `rr jsc` (lint + format together), the kernel composes oxlint + oxfmt automatically when both capabilities are present and no plugin provides `jsc` directly. ## What it provides -| Capability | Surface | -|---|---| -| `lint` | `rr lint`, `rr lint doctor` | -| `format` | `rr format`, `rr format doctor` | -| `jsc` (composed) | `rr jsc` (synthesised lint + format) | +| Capability | Surface | Underlying command | +|---|---|---| +| `lint` | `rr lint`, `rr lint doctor` | `oxlint --check` / `--fix` | +| `format` | `rr format`, `rr format doctor` | `oxfmt --check` / `--fix` | +| `tsc` | `rr tsc`, `rr tsc doctor` | `oxlint --type-aware --type-check` (via `oxlint-tsgolint`) | +| `jsc` (composed) | `rr jsc` (synthesised lint + format) | | + +## Picking only some capabilities + +Pass `only` to mix oxc with another plugin. Example: biome for lint+format, oxc just for the 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"] }), + ], +}); +``` + +The `only` array is typed against the kinds *this* plugin provides (`"lint" | "format" | "tsc"` for oxc), so typos like `oxc({ only: ["pack"] })` are caught at compile time. ## Removal diff --git a/run-run/oxc-plugin/package.json b/run-run/oxc-plugin/package.json index d7ae8bca..03cf8312 100644 --- a/run-run/oxc-plugin/package.json +++ b/run-run/oxc-plugin/package.json @@ -44,7 +44,7 @@ "@vlandoss/clibuddy": "workspace:*" }, "peerDependencies": { - "@rrlab/cli": "workspace:*", + "@rrlab/cli": "workspace:^", "oxfmt": ">=0.30.0", "oxlint": ">=1.0.0", "oxlint-tsgolint": ">=0.15.0" @@ -57,8 +57,10 @@ "devDependencies": { "@rrlab/cli": "workspace:*", "@rrlab/tsdown-config": "workspace:^", - "oxfmt": "0.35.0", - "oxlint": "1.50.0", - "oxlint-tsgolint": "0.15.0" + "@types/semver": "^7.7.1", + "oxfmt": "0.51.0", + "oxlint": "1.66.0", + "oxlint-tsgolint": "0.23.0", + "semver": "^7.8.1" } } diff --git a/run-run/oxc-plugin/src/__tests__/setup.test.ts b/run-run/oxc-plugin/src/__tests__/setup.test.ts new file mode 100644 index 00000000..12ad3c66 --- /dev/null +++ b/run-run/oxc-plugin/src/__tests__/setup.test.ts @@ -0,0 +1,47 @@ +import type { PluginContext } from "@rrlab/cli/plugin"; +import type { Pkg, ShellService } from "@vlandoss/clibuddy"; +import { describe, expect, it } from "vitest"; +import oxc, { OxlintTypeCheckService } from "../index.ts"; + +function ctx(): PluginContext { + return { + shell: {} as ShellService, + // biome-ignore lint/suspicious/noExplicitAny: minimal stub + logger: {} as any, + appPkg: { dirPath: process.cwd() } as Pkg, + binPkg: { dirPath: process.cwd() } as Pkg, + cwd: process.cwd(), + }; +} + +describe("@rrlab/oxc-plugin capabilities()", () => { + it("returns all capabilities (lint, format, tsc) when no `only` is supplied", async () => { + const caps = await oxc().capabilities(ctx()); + expect(Object.keys(caps).sort()).toEqual(["format", "lint", "tsc"]); + }); + + it("narrows to just `tsc` when `only: ['tsc']` is supplied", async () => { + const caps = await oxc({ only: ["tsc"] }).capabilities(ctx()); + expect(Object.keys(caps)).toEqual(["tsc"]); + expect(caps.tsc).toBeInstanceOf(OxlintTypeCheckService); + }); + + it("narrows to lint+format when `only: ['lint', 'format']` is supplied", async () => { + const caps = await oxc({ only: ["lint", "format"] }).capabilities(ctx()); + expect(Object.keys(caps).sort()).toEqual(["format", "lint"]); + }); + + it("throws when `only` references an unknown capability", async () => { + await expect( + // biome-ignore lint/suspicious/noExplicitAny: bypassing the TS guard to exercise the runtime check + oxc({ only: ["pack"] as any }).capabilities(ctx()), + ).rejects.toThrow(/unknown capability 'pack'/); + }); +}); + +describe("OxlintTypeCheckService", () => { + it("constructs against the oxlint binary", () => { + const svc = new OxlintTypeCheckService({} as ShellService); + expect(svc.bin).toBe("oxlint"); + }); +}); diff --git a/run-run/oxc-plugin/src/__tests__/tool-versions.test.ts b/run-run/oxc-plugin/src/__tests__/tool-versions.test.ts index 4ad92ebe..35c4a989 100644 --- a/run-run/oxc-plugin/src/__tests__/tool-versions.test.ts +++ b/run-run/oxc-plugin/src/__tests__/tool-versions.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { minVersion, satisfies, subset } from "semver"; import { describe, expect, it } from "vitest"; import { TOOL_VERSIONS } from "../tool-versions.ts"; @@ -10,11 +11,13 @@ const pkg = JSON.parse(readFileSync(path.resolve(here, "../../package.json"), "u }; describe("TOOL_VERSIONS coherence with this plugin's package.json", () => { - for (const [name, entry] of Object.entries(TOOL_VERSIONS) as Array<[string, { install: string; peer?: string }]>) { - const expected = entry.peer; - if (!expected) continue; - it(`${name}: peer range matches package.json`, () => { - expect(pkg.peerDependencies?.[name]).toBe(expected); + for (const [name, entry] of Object.entries(TOOL_VERSIONS)) { + const peerRange = pkg.peerDependencies?.[name]; + if (!peerRange) continue; + + it(`${name}: install range fits within the declared peer range`, () => { + const ok = subset(entry.install, peerRange) || satisfies(minVersion(entry.install)?.version ?? "", peerRange); + expect(ok, `install=${entry.install} does not fit peer=${peerRange}`).toBe(true); }); } }); diff --git a/run-run/oxc-plugin/src/index.ts b/run-run/oxc-plugin/src/index.ts index 853e5e8e..dd4b0457 100644 --- a/run-run/oxc-plugin/src/index.ts +++ b/run-run/oxc-plugin/src/index.ts @@ -7,6 +7,8 @@ import { type Linter, type LintOptions, ToolService, + type TypeChecker, + type TypeCheckOptions, type UninstallContext, type UninstallResult, } from "@rrlab/cli/plugin"; @@ -40,6 +42,16 @@ export class OxfmtService extends ToolService implements Formatter { } } +export class OxlintTypeCheckService extends ToolService implements TypeChecker { + constructor(shellService: ShellService) { + super({ pkg: "oxlint", ui: UI_LINT, shellService, from: FROM }); + } + + async check(options: TypeCheckOptions = {}): Promise { + await this.exec(["--type-aware", "--type-check"], { cwd: options.cwd, verbose: !options.cwd }); + } +} + export async function install(_ctx: InstallContext): Promise { return { devDependencies: { @@ -54,27 +66,16 @@ export async function uninstall(_ctx: UninstallContext): Promise(() => ({ +const oxc = definePlugin(() => ({ name: "oxc", apiVersion: 1, install, uninstall, - async setup({ shell }) { - const lintSvc = new OxlintService(shell); - const fmtSvc = new OxfmtService(shell); - try { - await Promise.all([lintSvc.getBinDir(), fmtSvc.getBinDir()]); - } catch (_err) { - throw new Error( - "@rrlab/oxc-plugin requires oxlint and oxfmt to be installed in the host project. " + - "Run: rr plugins add oxc (or: pnpm add -D oxlint oxfmt)", - ); - } - return { - lint: lintSvc, - format: fmtSvc, - }; - }, + capabilities: ({ shell }) => ({ + lint: new OxlintService(shell), + format: new OxfmtService(shell), + tsc: new OxlintTypeCheckService(shell), + }), })); export default oxc; diff --git a/run-run/oxc-plugin/src/tool-versions.ts b/run-run/oxc-plugin/src/tool-versions.ts index 18bd3c53..99fe5a74 100644 --- a/run-run/oxc-plugin/src/tool-versions.ts +++ b/run-run/oxc-plugin/src/tool-versions.ts @@ -1,5 +1,5 @@ export const TOOL_VERSIONS = { - oxlint: { install: "^1.0.0", peer: ">=1.0.0" }, - oxfmt: { install: "^0.30.0", peer: ">=0.30.0" }, - "oxlint-tsgolint": { install: "^0.15.0", peer: ">=0.15.0" }, + oxlint: { install: "^1.0.0" }, + oxfmt: { install: "^0.51.0" }, + "oxlint-tsgolint": { install: "^0.23.0" }, } as const; diff --git a/run-run/ts-plugin/package.json b/run-run/ts-plugin/package.json index 464cc114..c492f737 100644 --- a/run-run/ts-plugin/package.json +++ b/run-run/ts-plugin/package.json @@ -45,12 +45,14 @@ "comment-json": "4.2.5" }, "peerDependencies": { - "@rrlab/cli": "workspace:*", + "@rrlab/cli": "workspace:^", "typescript": ">=5.0.0" }, "devDependencies": { "@rrlab/cli": "workspace:*", "@rrlab/tsdown-config": "workspace:^", + "@types/semver": "^7.7.1", + "semver": "^7.8.1", "typescript": "6.0.3" } } diff --git a/run-run/ts-plugin/src/__tests__/setup.test.ts b/run-run/ts-plugin/src/__tests__/setup.test.ts new file mode 100644 index 00000000..86688f7b --- /dev/null +++ b/run-run/ts-plugin/src/__tests__/setup.test.ts @@ -0,0 +1,34 @@ +import type { PluginContext } from "@rrlab/cli/plugin"; +import type { Pkg, ShellService } from "@vlandoss/clibuddy"; +import { describe, expect, it } from "vitest"; +import ts from "../index.ts"; + +function ctx(): PluginContext { + return { + shell: {} as ShellService, + // biome-ignore lint/suspicious/noExplicitAny: minimal stub + logger: {} as any, + appPkg: { dirPath: process.cwd() } as Pkg, + binPkg: { dirPath: process.cwd() } as Pkg, + cwd: process.cwd(), + }; +} + +describe("@rrlab/ts-plugin capabilities()", () => { + it("returns the tsc capability when no `only` is supplied", async () => { + const caps = await ts().capabilities(ctx()); + expect(Object.keys(caps)).toEqual(["tsc"]); + }); + + it("returns the tsc capability when `only: ['tsc']` is supplied", async () => { + const caps = await ts({ only: ["tsc"] }).capabilities(ctx()); + expect(Object.keys(caps)).toEqual(["tsc"]); + }); + + it("throws when `only` references an unknown capability", async () => { + await expect( + // biome-ignore lint/suspicious/noExplicitAny: bypassing the TS guard to exercise the runtime check + ts({ only: ["lint"] as any }).capabilities(ctx()), + ).rejects.toThrow(/unknown capability 'lint'/); + }); +}); diff --git a/run-run/ts-plugin/src/__tests__/tool-versions.test.ts b/run-run/ts-plugin/src/__tests__/tool-versions.test.ts index 4ad92ebe..35c4a989 100644 --- a/run-run/ts-plugin/src/__tests__/tool-versions.test.ts +++ b/run-run/ts-plugin/src/__tests__/tool-versions.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { minVersion, satisfies, subset } from "semver"; import { describe, expect, it } from "vitest"; import { TOOL_VERSIONS } from "../tool-versions.ts"; @@ -10,11 +11,13 @@ const pkg = JSON.parse(readFileSync(path.resolve(here, "../../package.json"), "u }; describe("TOOL_VERSIONS coherence with this plugin's package.json", () => { - for (const [name, entry] of Object.entries(TOOL_VERSIONS) as Array<[string, { install: string; peer?: string }]>) { - const expected = entry.peer; - if (!expected) continue; - it(`${name}: peer range matches package.json`, () => { - expect(pkg.peerDependencies?.[name]).toBe(expected); + for (const [name, entry] of Object.entries(TOOL_VERSIONS)) { + const peerRange = pkg.peerDependencies?.[name]; + if (!peerRange) continue; + + it(`${name}: install range fits within the declared peer range`, () => { + const ok = subset(entry.install, peerRange) || satisfies(minVersion(entry.install)?.version ?? "", peerRange); + expect(ok, `install=${entry.install} does not fit peer=${peerRange}`).toBe(true); }); } }); diff --git a/run-run/ts-plugin/src/index.ts b/run-run/ts-plugin/src/index.ts index 14307401..8100b0ec 100644 --- a/run-run/ts-plugin/src/index.ts +++ b/run-run/ts-plugin/src/index.ts @@ -1,10 +1,12 @@ import fs from "node:fs/promises"; import path from "node:path"; import { + decideScaffold, definePlugin, type FileOp, type InstallContext, type InstallResult, + pickPreset, ToolService, type TypeChecker, type TypeCheckOptions, @@ -50,18 +52,24 @@ const PRESETS: Record = { const DEFAULT_PRESET: Preset = "no-dom-app"; -type ExistingFileAction = "skip" | "patch" | "overwrite"; - export async function install(ctx: InstallContext): Promise { const tsconfigPath = path.join(ctx.appPkg.dirPath, TSCONFIG); const fileExists = await pathExists(tsconfigPath); + const scaffoldDecision = await decideScaffold(ctx, { + label: TSCONFIG, + fileExists, + patchHint: "update extends, keep my other settings", + }); - const scaffoldDecision = await decideScaffoldAction(ctx, fileExists); if (scaffoldDecision === "skip") { return { devDependencies: { typescript: TOOL_VERSIONS.typescript.install } }; } - const preset = await pickPreset(ctx); + const preset = await pickPreset(ctx, { + message: "Which kind of TS project do you need?", + presets: PRESETS, + defaultPreset: DEFAULT_PRESET, + }); const presetInfo = PRESETS[preset]; const devDependencies: Record = { @@ -96,7 +104,6 @@ export async function uninstall(ctx: UninstallContext): Promise return { removeDependencies }; } - // Read the current tsconfig to decide between full delete and surgical unset. let existing: Record | undefined; try { const text = await fs.readFile(tsconfigPath, "utf8"); @@ -119,51 +126,6 @@ export async function uninstall(ctx: UninstallContext): Promise return { removeDependencies, files }; } -async function decideScaffoldAction( - ctx: InstallContext, - fileExists: boolean, -): Promise<"create" | "patch" | "overwrite" | "skip"> { - if (!fileExists) { - if (ctx.flags.yes || ctx.flags.nonInteractive) return "create"; - const choice = await ctx.prompts.confirm({ - message: `Scaffold ${TSCONFIG} with an @rrlab/ts-config preset?`, - initialValue: true, - }); - if (ctx.prompts.isCancel(choice)) throw new Error("Cancelled by user."); - return choice ? "create" : "skip"; - } - - // Existing file. Default to patch for migration safety. - if (ctx.flags.yes || ctx.flags.nonInteractive) return "patch"; - - const choice = await ctx.prompts.select({ - message: `${TSCONFIG} already exists. What do you want to do?`, - options: [ - { value: "patch", label: "Patch — update extends, keep my other settings" }, - { value: "skip", label: "Skip — leave it alone" }, - { value: "overwrite", label: "Overwrite — replace with a fresh scaffold" }, - ], - initialValue: "patch", - }); - if (ctx.prompts.isCancel(choice)) throw new Error("Cancelled by user."); - return choice; -} - -async function pickPreset(ctx: InstallContext): Promise { - if (ctx.flags.yes || ctx.flags.nonInteractive) return DEFAULT_PRESET; - - const choice = await ctx.prompts.select({ - message: "Which kind of TS project do you need?", - options: (Object.entries(PRESETS) as Array<[Preset, PresetInfo]>).map(([value, meta]) => ({ - value, - label: meta.label, - })), - initialValue: DEFAULT_PRESET, - }); - if (ctx.prompts.isCancel(choice)) throw new Error("Cancelled by user."); - return choice; -} - async function pathExists(p: string): Promise { try { await fs.access(p); @@ -173,23 +135,12 @@ async function pathExists(p: string): Promise { } } -const ts = definePlugin(() => ({ +const ts = definePlugin(() => ({ name: "ts", apiVersion: 1, install, uninstall, - async setup({ shell }) { - const svc = new TscService(shell); - try { - await svc.getBinDir(); - } catch (_err) { - throw new Error( - "@rrlab/ts-plugin requires typescript to be installed in the host project. " + - "Run: rr plugins add ts (or: pnpm add -D typescript)", - ); - } - return { tsc: svc }; - }, + capabilities: ({ shell }) => ({ tsc: new TscService(shell) }), })); export default ts; diff --git a/run-run/ts-plugin/src/tool-versions.ts b/run-run/ts-plugin/src/tool-versions.ts index c31e06c1..7f9871f6 100644 --- a/run-run/ts-plugin/src/tool-versions.ts +++ b/run-run/ts-plugin/src/tool-versions.ts @@ -1,6 +1,7 @@ export const TOOL_VERSIONS = { - // install range > peer range on purpose: pin latest stable for fresh installs, - // accept TS 5+ if the host already has it. - typescript: { install: "^6.0.0", peer: ">=5.0.0" }, + // `install` is the prescriptive pin used by `rr plugins add`'s nypm call. + // For typescript we want fresh installs on the latest stable; the looser + // `>=5.0.0` contract lives in package.json#peerDependencies. + typescript: { install: "^6.0.0" }, "@types/node": { install: ">=20" }, } as const; diff --git a/run-run/tsdown-plugin/package.json b/run-run/tsdown-plugin/package.json index 313535ae..d103e14b 100644 --- a/run-run/tsdown-plugin/package.json +++ b/run-run/tsdown-plugin/package.json @@ -45,12 +45,14 @@ "magicast": "0.3.5" }, "peerDependencies": { - "@rrlab/cli": "workspace:*", + "@rrlab/cli": "workspace:^", "tsdown": ">=0.22.0" }, "devDependencies": { "@rrlab/cli": "workspace:*", "@rrlab/tsdown-config": "workspace:^", + "@types/semver": "^7.7.1", + "semver": "^7.8.1", "tsdown": "0.22.0" } } diff --git a/run-run/tsdown-plugin/src/__tests__/setup.test.ts b/run-run/tsdown-plugin/src/__tests__/setup.test.ts new file mode 100644 index 00000000..326c62bd --- /dev/null +++ b/run-run/tsdown-plugin/src/__tests__/setup.test.ts @@ -0,0 +1,34 @@ +import type { PluginContext } from "@rrlab/cli/plugin"; +import type { Pkg, ShellService } from "@vlandoss/clibuddy"; +import { describe, expect, it } from "vitest"; +import tsdown from "../index.ts"; + +function ctx(): PluginContext { + return { + shell: {} as ShellService, + // biome-ignore lint/suspicious/noExplicitAny: minimal stub + logger: {} as any, + appPkg: { dirPath: process.cwd() } as Pkg, + binPkg: { dirPath: process.cwd() } as Pkg, + cwd: process.cwd(), + }; +} + +describe("@rrlab/tsdown-plugin capabilities()", () => { + it("returns the pack capability when no `only` is supplied", async () => { + const caps = await tsdown().capabilities(ctx()); + expect(Object.keys(caps)).toEqual(["pack"]); + }); + + it("returns the pack capability when `only: ['pack']` is supplied", async () => { + const caps = await tsdown({ only: ["pack"] }).capabilities(ctx()); + expect(Object.keys(caps)).toEqual(["pack"]); + }); + + it("throws when `only` references an unknown capability", async () => { + await expect( + // biome-ignore lint/suspicious/noExplicitAny: bypassing the TS guard to exercise the runtime check + tsdown({ only: ["lint"] as any }).capabilities(ctx()), + ).rejects.toThrow(/unknown capability 'lint'/); + }); +}); diff --git a/run-run/tsdown-plugin/src/__tests__/tool-versions.test.ts b/run-run/tsdown-plugin/src/__tests__/tool-versions.test.ts index 4ad92ebe..35c4a989 100644 --- a/run-run/tsdown-plugin/src/__tests__/tool-versions.test.ts +++ b/run-run/tsdown-plugin/src/__tests__/tool-versions.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { minVersion, satisfies, subset } from "semver"; import { describe, expect, it } from "vitest"; import { TOOL_VERSIONS } from "../tool-versions.ts"; @@ -10,11 +11,13 @@ const pkg = JSON.parse(readFileSync(path.resolve(here, "../../package.json"), "u }; describe("TOOL_VERSIONS coherence with this plugin's package.json", () => { - for (const [name, entry] of Object.entries(TOOL_VERSIONS) as Array<[string, { install: string; peer?: string }]>) { - const expected = entry.peer; - if (!expected) continue; - it(`${name}: peer range matches package.json`, () => { - expect(pkg.peerDependencies?.[name]).toBe(expected); + for (const [name, entry] of Object.entries(TOOL_VERSIONS)) { + const peerRange = pkg.peerDependencies?.[name]; + if (!peerRange) continue; + + it(`${name}: install range fits within the declared peer range`, () => { + const ok = subset(entry.install, peerRange) || satisfies(minVersion(entry.install)?.version ?? "", peerRange); + expect(ok, `install=${entry.install} does not fit peer=${peerRange}`).toBe(true); }); } }); diff --git a/run-run/tsdown-plugin/src/index.ts b/run-run/tsdown-plugin/src/index.ts index 47c2a4f7..6c9ddbba 100644 --- a/run-run/tsdown-plugin/src/index.ts +++ b/run-run/tsdown-plugin/src/index.ts @@ -1,9 +1,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import { + decideScaffold, definePlugin, type InstallContext, type InstallResult, + pickPreset, ToolService, type UninstallContext, type UninstallResult, @@ -41,8 +43,6 @@ const PRESETS: Record = { }; const DEFAULT_PRESET: Preset = "lib"; -type ExistingFileAction = "patch" | "skip" | "overwrite"; - export class TsdownService extends ToolService { constructor(shellService: ShellService) { super({ pkg: "tsdown", ui: UI, shellService, from: FROM }); @@ -55,12 +55,22 @@ export class TsdownService extends ToolService { export async function install(ctx: InstallContext): Promise { const existingPath = await findExistingConfig(ctx.appPkg.dirPath); - const action = await decideScaffoldAction(ctx, existingPath); + const action = await decideScaffold(ctx, { + label: existingPath ? path.relative(ctx.appPkg.dirPath, existingPath) : DEFAULT_CONFIG_FILENAME, + fileExists: existingPath !== null, + patchHint: `rewrite to use ${CONFIG_PKG}, keep my options`, + unattendedExistingAction: "skip", + }); + if (action === "skip") { return { devDependencies: { tsdown: TOOL_VERSIONS.tsdown.install } }; } - const preset = await pickPreset(ctx); + const preset = await pickPreset(ctx, { + message: "Which kind of build?", + presets: PRESETS, + defaultPreset: DEFAULT_PRESET, + }); const { factory } = PRESETS[preset]; const devDependencies: Record = { @@ -150,49 +160,6 @@ async function findExistingConfig(cwd: string): Promise { return null; } -async function decideScaffoldAction(ctx: InstallContext, existingPath: string | null): Promise<"create" | ExistingFileAction> { - if (!existingPath) { - if (ctx.flags.yes || ctx.flags.nonInteractive) return "create"; - const choice = await ctx.prompts.confirm({ - message: `Scaffold ${DEFAULT_CONFIG_FILENAME} from ${CONFIG_PKG}?`, - initialValue: true, - }); - if (ctx.prompts.isCancel(choice)) throw new Error("Cancelled by user."); - return choice ? "create" : "skip"; - } - - // Existing file: don't silently rewrite under --yes — that's user code. - if (ctx.flags.yes || ctx.flags.nonInteractive) return "skip"; - - const relPath = path.relative(ctx.appPkg.dirPath, existingPath); - const choice = await ctx.prompts.select({ - message: `${relPath} already exists. What do you want to do?`, - options: [ - { value: "patch", label: `Patch — rewrite to use ${CONFIG_PKG}, keep my options` }, - { value: "skip", label: "Skip — leave it alone" }, - { value: "overwrite", label: "Overwrite — replace with a fresh scaffold" }, - ], - initialValue: "patch", - }); - if (ctx.prompts.isCancel(choice)) throw new Error("Cancelled by user."); - return choice; -} - -async function pickPreset(ctx: InstallContext): Promise { - if (ctx.flags.yes || ctx.flags.nonInteractive) return DEFAULT_PRESET; - - const choice = await ctx.prompts.select({ - message: "Which kind of build?", - options: (Object.entries(PRESETS) as Array<[Preset, PresetInfo]>).map(([value, meta]) => ({ - value, - label: meta.label, - })), - initialValue: DEFAULT_PRESET, - }); - if (ctx.prompts.isCancel(choice)) throw new Error("Cancelled by user."); - return choice; -} - function renderScaffold(factory: FactoryName): string { return `import { ${factory} } from "${CONFIG_PKG}";\n\nexport default ${factory}();\n`; } @@ -302,23 +269,12 @@ function setCalleeName(mod: ProxifiedModule, newName: string): void { ast.callee.name = newName; } -const tsdown = definePlugin(() => ({ +const tsdown = definePlugin(() => ({ name: "tsdown", apiVersion: 1, install, uninstall, - async setup({ shell }) { - const svc = new TsdownService(shell); - try { - await svc.getBinDir(); - } catch (_err) { - throw new Error( - "@rrlab/tsdown-plugin requires tsdown to be installed in the host project. " + - "Run: rr plugins add tsdown (or: pnpm add -D tsdown)", - ); - } - return { pack: svc }; - }, + capabilities: ({ shell }) => ({ pack: new TsdownService(shell) }), })); export default tsdown; diff --git a/run-run/tsdown-plugin/src/tool-versions.ts b/run-run/tsdown-plugin/src/tool-versions.ts index 5413cd9c..c7c8aae6 100644 --- a/run-run/tsdown-plugin/src/tool-versions.ts +++ b/run-run/tsdown-plugin/src/tool-versions.ts @@ -1,3 +1,3 @@ export const TOOL_VERSIONS = { - tsdown: { install: "^0.22.0", peer: ">=0.22.0" }, + tsdown: { install: "^0.22.0" }, } as const; diff --git a/shared/loggy/src/loggy.ts b/shared/loggy/src/loggy.ts index 3ce708e1..2378b19e 100644 --- a/shared/loggy/src/loggy.ts +++ b/shared/loggy/src/loggy.ts @@ -40,15 +40,15 @@ export class Loggy implements AnyLogger { this.#consola.error(this.#format(...args)); } - info(opts: LogFnOptions | unknown, ...args: unknown[]) { + info(opts: LogFnOptions | string, ...args: unknown[]) { this.#consola.info(this.#format(opts, ...args)); } - trace(opts: LogFnOptions | unknown, ...args: unknown[]) { + trace(opts: LogFnOptions | string, ...args: unknown[]) { this.#consola.trace(this.#format(opts, ...args)); } - warn(opts: LogFnOptions | unknown, ...args: unknown[]) { + warn(opts: LogFnOptions | string, ...args: unknown[]) { this.#consola.warn(this.#format(opts, ...args)); } @@ -60,11 +60,11 @@ export class Loggy implements AnyLogger { }); } - start(opts: LogFnOptions | unknown, ...args: unknown[]) { + start(opts: LogFnOptions | string, ...args: unknown[]) { this.#consola.start(this.#format(opts, ...args)); } - success(opts: LogFnOptions | unknown, ...args: unknown[]) { + success(opts: LogFnOptions | string, ...args: unknown[]) { this.#consola.success(this.#format(opts, ...args)); } diff --git a/shared/loggy/src/types.ts b/shared/loggy/src/types.ts index 90de755b..4ac6c476 100644 --- a/shared/loggy/src/types.ts +++ b/shared/loggy/src/types.ts @@ -2,20 +2,22 @@ import type { FormatOptions } from "consola"; export type AnyLogFn = (...args: unknown[]) => void; +export type LogFn = (opts: LogFnOptions | string, ...args: unknown[]) => void; + export type Formatters = Record string>; export type AnyLogger = { namespace: string; debug: AnyLogFn; error: AnyLogFn; - info: AnyLogFn; - trace: AnyLogFn; - warn: AnyLogFn; + info: LogFn; + trace: LogFn; + warn: LogFn; child: (options: CreateOptions) => AnyLogger; // { extras subdebug: (namespace: string) => AnyLogFn; - start: AnyLogFn; - success: AnyLogFn; + start: LogFn; + success: LogFn; // } }; diff --git a/vland/cli/src/actions/init.ts b/vland/cli/src/actions/init.ts index e31c5c17..8dbcb8f2 100644 --- a/vland/cli/src/actions/init.ts +++ b/vland/cli/src/actions/init.ts @@ -23,7 +23,7 @@ const PACKAGE_MANAGER = "pnpm" as const; const NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/; function validateProjectName(name: string): string | undefined { - if (!name || !name.trim()) return "Name is required."; + if (!name?.trim()) return "Name is required."; if (/\s/.test(name)) return "Name cannot contain whitespace."; if (name.startsWith(".") || name.startsWith("/") || name.startsWith("\\")) { return "Name cannot start with '.', '/' or '\\'.";