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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/did-you-mean-suggestions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte-vitals': minor
---

A mistyped sub-command or rule id now gets a `did you mean …?` hint appended after the existing error message, on four surfaces: `svelte-vitals <mistyped-subcommand>` falling through to the root analyzer as a path that doesn't exist on disk (e.g. `svelte-vitals isntall`), `docs <unknown-subcommand>`, `ci <unknown-subcommand>`, and `explain <unknown-rule-id>`. The hint only appears when the typed token is close to a real name and, for the root analyzer, when the explicit path does not exist on disk — an existing path is always analyzed as asked, never redirected. Nothing about the existing error wording, exit codes, or stdout/stderr split changes; the hint is a new, additional line.
64 changes: 64 additions & 0 deletions docs/superpowers/specs/2026-08-10-gunshi-cli-migration-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,3 +400,67 @@ pins en/ja byte-identity so a future edit can't translate one side without the o
No changeset: doc-site content plus an internal generator/test/build-entry, zero change to any
published package's runtime behavior or public export surface (`gunshi-registry` is a build
artifact, not an `exports` entry).

## Addendum (2026-08-11): `did you mean …?` suggestions via `@gunshi/plugin-suggestion`'s matcher, not its plugin

Probed `@gunshi/plugin-suggestion@0.37.1` (added to the catalog, exact pin, alongside the other
`gunshi`-family packages) to determine whether it could fire on any of this CLI's five real
surfaces. It cannot, confirmed two ways — reading the pinned `gunshi` 0.37.1 source
(`cliCore`/`resolveCommandTree` in `node_modules/gunshi/lib/core-*.js`) and a live probe reproducing
this repo's exact bone wiring:

- The plugin decorates gunshi's own `CommandNotFoundError`/`ArgsValidationError` rendering — it
detects nothing itself (its own README says so). Both error kinds are structurally unreachable
here: `fallbackToEntry: true` (every sub-commanded surface, i.e. `docs`) resolves an unmatched
first-level token straight to the entry command inside `resolveCommandTree` — `targetCommand` is
already truthy by the time `cliCore` checks whether to construct a `CommandNotFoundError`, so it
never is, at any `strict` setting. `stripUnknownFlags` (guard.ts) removes any undeclared flag
before gunshi's own parser ever sees the token, so `ArgsValidationError`'s `unknownOption` case
(which only fires when `cliOptions.strict` is true AND the parser actually saw the flag) is
equally unreachable — and no surface here sets `strict: true` regardless. A probe script wiring
`suggestion()` into a `docs`-shaped bone `cli()` call (subCommands + `fallbackToEntry: true`,
both `strict` settings) confirmed this empirically: the entry `run()` always received the
mismatched token as an ordinary positional, `ctx.validationError` always `undefined`. Flipping
`fallbackToEntry: false` (not this CLI's shape, tested only to see what the plugin _would_ have
produced) reproduces the OTHER already-recorded Phase-2 finding instead: `bone`'s decorator throws
a raw `Error` on any validation error, discarding the rendered/decorated message — so the plugin's
hint text is unreachable there too, for the same class of reason full `cli()` was rejected for in
Phase 2. `ci` doesn't run its sub-subcommand split through gunshi at all (a literal `args[0]`
compare, by design — see Phase 3 verdict above), so it was never a candidate either.
- The package DOES export a reusable, non-hand-rolled matcher, unrelated to its plugin/error-hooking
half: `import { levenshtein, defineSuggestNames } from '@gunshi/plugin-suggestion'`.
`levenshtein(a, b): number` is a plain edit-distance function; `defineSuggestNames(options)`
is the plugin's own candidate-ranking factory (filter by `maxDistance`, sort by distance then
input order, cap at `maxSuggestions`) — the same one its `suggestion()` plugin builds internally.
`resolveSuggestionOptions` (translates partial user options to defaults: `maxDistance: 2`,
`maxSuggestions: 1`) is NOT exported, so the default threshold is reproduced by hand at the one
call site (`gunshi/guard.ts`'s `suggestClosest`) instead of imported — values only, not logic.

Shape chosen: `suggestClosest(typed, candidates)` in `gunshi/guard.ts`, built once from
`defineSuggestNames`, called directly from each surface's own existing error path — never wiring
`suggestion()` into any `cli()` call, since (per above) it would be dead weight. Four surfaces:
root `runAnalyzeCliGunshi` (a `did you mean` line appended after the `No SvelteKit project found`
message, gated on the explicit path not existing on disk at all — an existing directory of that name
is always analyzed, never redirected), `docs`'s and `ci`'s unknown-subcommand paths, and `explain`'s
unknown-rule-id path (scanning 70+ ids per call is well under a millisecond — `defineSuggestNames`
is a single filter+sort over the candidate list, no measurable cost). Every insertion is additive:
the existing error line(s) print unchanged, in the same order, with the hint as one new line: right
after the specific "unknown …" complaint (docs/explain), right before the unchanged `CI_HELP` block
(`ci`, which has no per-token complaint line to append after), or as the sole new line (root, which
had only the one message).

Dependency footprint: `@gunshi/plugin-suggestion` depends on `@gunshi/plugin` and peer-depends on
`@gunshi/plugin-i18n` (same non-optional-peer shape as `@gunshi/plugin-completion`, this doc's prior
addendum) — both were already resolved into the lockfile by `@gunshi/plugin-completion`, so adding
this package resolved **zero new packages** (confirmed via lockfile diff: only
`@gunshi/plugin-suggestion` itself gained entries).

Unlike `@gunshi/plugin-completion` (reached only via the dynamic `import()` behind `complete`),
`suggestClosest` lives in `gunshi/guard.ts`, which `gunshi/analyze.ts` — the root analyzer,
statically imported by `cli.ts` and therefore on every invocation's hot path, not behind a dynamic
import — already imports. So this DOES add to the hot path, unlike the completion feature; measured
rather than assumed: median of 10 runs of `node --input-type=module -e "import('@gunshi/plugin-
suggestion')"` minus the bare `node -e "import('node:path')"` floor on the same machine is ~1 ms
(the package is a single small file depending only on the already-resolved `@gunshi/plugin`), and
`svelte-vitals --help` end to end still runs ~130 ms, in line with this doc's own pre-existing
gunshi-adoption baseline (~157 ms) rather than measurably above it.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@svelte-vitals/core": "workspace:*",
"@clack/prompts": "catalog:",
"@gunshi/plugin-completion": "catalog:",
"@gunshi/plugin-suggestion": "catalog:",
"gunshi": "catalog:",
"log-update": "catalog:",
"magicast": "catalog:",
Expand Down
29 changes: 28 additions & 1 deletion packages/cli/src/gunshi/analyze.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { existsSync } from 'node:fs';
import { parseArgs } from 'node:util';
import { cli } from 'gunshi/bone';
import { define } from 'gunshi/definition';
Expand All @@ -8,7 +9,15 @@ import { parseRunArgs, resolveArgs, VALUE_FLAGS, type CliArgv } from '../resolve
import { selectAppPrompt } from '../install/cli.js';
import { consoleIO, type CliIO } from '../cli-io.js';
import type { CliResult } from '../cli.js';
import { guardArgs, splitAtTerminator, stripUnknownFlags } from './guard.js';
import { guardArgs, splitAtTerminator, stripUnknownFlags, suggestClosest } from './guard.js';

/**
* `runCli` (cli.ts)'s reserved first-token dispatch names, hand-kept in sync (five short, stable
* tokens) — used only for the `did-you-mean` hint below on an explicit path that resolves to
* nothing on disk, e.g. `svelte-vitals isntall`; an existing directory of the same name is always
* analyzed as-is, never redirected (see the `existsSync` check at its one call site).
*/
const KNOWN_TOP_LEVEL_SUBCOMMANDS = ['docs', 'explain', 'install', 'ci', 'complete'];

const VERSION = readPackageVersion();

Expand Down Expand Up @@ -298,13 +307,31 @@ export async function runAnalyzeCliGunshi(args: string[], io: CliIO = consoleIO)
return;
}

// Computed before the run — never after — so a coincidental match against something the
// analysis itself printed (a finding, a rule id) can't masquerade as a subcommand typo.
// Gated on the path not existing at all: an explicit path that IS a real directory is
// analyzed as the user asked, even if its name happens to resemble a subcommand.
// `options.cwd` is optional only in `RunOptions`'s general shape (embedding callers may omit
// it); `resolveArgs` itself always fills it in (`positional ?? process.cwd()`).
const explicitCwd = options.cwd ?? process.cwd();
const suggestedSubcommand =
options.explicitPath && !existsSync(explicitCwd)
? suggestClosest(explicitCwd, KNOWN_TOP_LEVEL_SUBCOMMANDS)
: undefined;

const code = await run({
...options,
minHealth,
selectApp,
log: io.log,
errorLog: io.errorLog
});
// `options.cwd` not existing on disk forces `detectProject` to throw `ProjectError` (every
// check it runs needs a file under that path), so this is the one message `run()` could have
// just printed — appended, never replacing it (design doc invariants).
if (suggestedSubcommand !== undefined && code === 2) {
io.errorLog(`svelte-vitals: did you mean \`svelte-vitals ${suggestedSubcommand}\`?`);
}
// A write to a pipe is asynchronous, so `process.exit` can discard what has not drained — the report is
// the largest thing this CLI writes and the first pipe buffer is 65,536 bytes. The empty write's callback
// fires once the stream has flushed, so it's safe for the thin entry to call `process.exit` as soon as
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/gunshi/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { realIO } from '../install/cli.js';
import { WORKFLOW_PATH, buildWorkflowYaml, planWorkflowWrite } from '../ci/workflow.js';
import { upgradeActionPin } from '../ci/upgrade.js';
import { ACTION_SHA, ACTION_VERSION } from '../ci/action-pin.generated.js';
import { guardArgs, splitAtTerminator, stripUnknownFlags, stripAutoVersionLine } from './guard.js';
import { guardArgs, splitAtTerminator, stripUnknownFlags, stripAutoVersionLine, suggestClosest } from './guard.js';

/**
* Frozen error-path text: printed verbatim on `ci`'s non-help exit-2 paths (bare `ci`, an unknown
Expand Down Expand Up @@ -256,6 +256,12 @@ export async function runCiCliGunshi(args: string[], io: InstallIO = realIO()):
return runCiUpgrade(args.slice(1), io, helpSource);
}
if (sub !== 'install') {
// A bare `ci` (sub undefined) has no typed token to match against — only a wrong sub-subcommand
// name gets a suggestion.
if (sub !== undefined) {
const hint = suggestClosest(sub, ['install', 'upgrade']);
if (hint) io.errorLog(`svelte-vitals: did you mean \`svelte-vitals ci ${hint}\`?`);
}
// Declared movement (design doc invariants / this PR's changeset): stderr, not stdout — the
// one exit-2 path that used to leave stdout non-empty for callers piping it.
io.errorLog(CI_HELP);
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/gunshi/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { consoleIO, type CliIO } from '../cli-io.js';
import { knownRuleIds } from '../rules-config.js';
import { EMBEDDED_DOCS } from '../docs/generated.js';
import { DOCS_HELP, knownTopicNames, renderList } from '../docs/cli.js';
import { guardArgs, splitAtTerminator, stripUnknownFlags, stripAutoVersionLine } from './guard.js';
import { guardArgs, splitAtTerminator, stripUnknownFlags, stripAutoVersionLine, suggestClosest } from './guard.js';

/** docs declares no value-carrying flags today — see guard.ts's own doc comment for why the list is still passed explicitly. */
const BOOLEAN_FLAGS = ['json', 'help'] as const;
Expand Down Expand Up @@ -205,6 +205,8 @@ export async function runDocsCliGunshi(args: string[], io: CliIO = consoleIO): P
return;
}
io.errorLog(`svelte-vitals: unknown docs subcommand '${sub}'; expected list|show.`);
const hint = suggestClosest(sub, ['list', 'show']);
if (hint) io.errorLog(`svelte-vitals: did you mean \`svelte-vitals docs ${hint}\`?`);
io.errorLog(DOCS_HELP);
exitCode = 2;
}
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/gunshi/explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { explainRule, allRules } from '@svelte-vitals/core';
import { consoleIO, type CliIO } from '../cli-io.js';
import { knownRuleIds } from '../rules-config.js';
import { renderRuleList, formatRuleExplanation } from '../explain.js';
import { guardArgs, splitAtTerminator, stripUnknownFlags, stripAutoVersionLine } from './guard.js';
import { guardArgs, splitAtTerminator, stripUnknownFlags, stripAutoVersionLine, suggestClosest } from './guard.js';

/** explain declares no value-carrying flags today — see guard.ts's own doc comment for why the list is still passed explicitly. */
const BOOLEAN_FLAGS = ['json', 'list', 'help'] as const;
Expand Down Expand Up @@ -111,6 +111,8 @@ export async function runExplainCliGunshi(args: string[], io: CliIO = consoleIO)
const info = explainRule(id);
if (!info) {
io.errorLog(`svelte-vitals: unknown rule id '${id}'.`);
const hint = suggestClosest(id, knownRuleIds());
if (hint) io.errorLog(`svelte-vitals: did you mean \`svelte-vitals explain ${hint}\`?`);
io.errorLog(`svelte-vitals: known rule ids: ${knownRuleIds().join(', ')}.`);
exitCode = 2;
return;
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/src/gunshi/guard.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { defineSuggestNames, levenshtein } from '@gunshi/plugin-suggestion';

/**
* Raw-argv pre-scan that every gunshi-dispatched command runs before handing argv to gunshi's
* own parser (Phase 1 spike gate (b), `docs/superpowers/specs/2026-08-10-gunshi-cli-migration-design.md`).
Expand Down Expand Up @@ -126,6 +128,36 @@ export function stripAutoVersionLine(generated: string): string {
.join('\n');
}

/**
* `Did you mean …?` closest-match lookup for a mistyped sub-command or rule id, reusing
* `@gunshi/plugin-suggestion`'s own matcher (`defineSuggestNames`/`levenshtein`) instead of a
* hand-rolled edit-distance function — the plugin's own default threshold (distance <= 2, one
* suggestion), pinned here since `resolveSuggestionOptions` itself isn't exported.
*
* The plugin's OWN error-hooking (decorating gunshi's `CommandNotFoundError`/`ArgsValidationError`
* rendering) never fires on any surface in this CLI: `fallbackToEntry: true` resolves an unmatched
* sub-command straight to the entry command with no `CommandNotFoundError` ever constructed
* (confirmed against gunshi 0.37.1's `resolveCommandTree` — the `additionalValidationErrors.push`
* call is unreachable when `resolveAsEntry()` already returned a command), and `stripUnknownFlags`
* above removes any undeclared flag before gunshi's own parser sees it, so `ArgsValidationError`'s
* `unknownOption` case is equally unreachable. Every ported surface therefore calls this function
* directly from its own error path instead of wiring the plugin into `cli()`.
*/
const suggestNames = defineSuggestNames({
maxDistance: 2,
maxSuggestions: 1,
distance: levenshtein,
normalize: (v) => v,
// Unused by `defineSuggestNames` itself (only its own `suggestion()` plugin reads these, to
// decide which of gunshi's two error kinds to look at) — required by `ResolvedSuggestionOptions`
// regardless, so set to the plugin's own defaults.
includeOptions: true,
includeCommands: true
});
export function suggestClosest(typed: string, candidates: readonly string[]): string | undefined {
return suggestNames(typed, candidates)[0];
}

export function stripUnknownFlags(
argv: string[],
knownLong: ReadonlySet<string>,
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/test/__snapshots__/gunshi-ci.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,34 @@ Done. Commit the workflow file and open a PR to see it in action.",
}
`;

exports[`gunshi/bone ci — pinned behavior across the argv-shape matrix > isntall (typo of install, close enough for a did-you-mean hint) 1`] = `
{
"code": 2,
"err": "svelte-vitals: did you mean \`svelte-vitals ci install\`?
svelte-vitals ci — scaffold CI integration

Usage:
svelte-vitals ci install [options]
svelte-vitals ci upgrade [--dry-run]

Adds a GitHub Actions workflow (.github/workflows/svelte-vitals.yml) that calls the \`@svelte-vitals/action\`
GitHub Action on pull requests: inline annotations, a job summary, and a sticky PR
comment with the findings.

\`ci upgrade\` rewrites only the pinned \`@svelte-vitals/action\` reference in an existing
workflow to the pin bundled with this CLI, leaving the rest of the file (and any other
pins, like actions/checkout) untouched. To pick up the latest pin, run
\`npx svelte-vitals@latest ci upgrade\`.

Options:
--force Overwrite an existing workflow file (install only)
--dry-run Print the plan and exit without writing
-h, --help Show this help",
"out": "",
"wrote": false,
}
`;

exports[`gunshi/bone ci — pinned behavior across the argv-shape matrix > no sub (bare ci) 1`] = `
{
"code": 2,
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/test/docs-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,18 @@ describe('svelte-vitals docs — dispatch', () => {
expect(err).toContain('list|show');
});

it('a close typo of a real subcommand gets a did-you-mean hint (design doc addendum)', async () => {
const { code, err } = await docs(['lsit']);
expect(code).toBe(2);
expect(err).toContain("unknown docs subcommand 'lsit'");
expect(err).toContain('svelte-vitals: did you mean `svelte-vitals docs list`?');
});

it('"read" is far enough from both list/show that no hint is added (already exercised above, asserted explicitly)', async () => {
const { err } = await docs(['read', 'config']);
expect(err).not.toContain('did you mean');
});

it('documents the escape hatch for a ./docs directory', async () => {
expect((await docs(['--help'])).out).toContain('svelte-vitals ./docs');
});
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/test/explain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,20 @@ describe('svelte-vitals explain', () => {
expect(err).toContain("unknown rule id 'NOPE999'");
expect(err).toContain('known rule ids:');
expect(err).toContain('seo/title-presence');
// NOPE999 is nowhere near any real rule id — no did-you-mean hint (design doc addendum).
expect(err).not.toContain('did you mean');
});

it('a one-edit typo of a real rule id gets a did-you-mean hint (design doc addendum)', async () => {
const { code, err } = await explain(['seo/ssr-disable']);
expect(code).toBe(2);
expect(err).toContain("unknown rule id 'seo/ssr-disable'");
expect(err).toContain('svelte-vitals: did you mean `svelte-vitals explain seo/ssr-disabled`?');
});

it('the wrong-case rule id above is far enough (case-sensitive match) that it gets no hint either', async () => {
const { err } = await explain(['SEO/TITLE-PRESENCE']);
expect(err).not.toContain('did you mean');
});

it('exits 2 when no rule id is given', async () => {
Expand Down
Loading