svelte-vitals 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,17 +75,17 @@ Useful as a CI gate.
75
75
 
76
76
  ### `svelte-vitals install`
77
77
 
78
- An interactive wizard that wires up the [Vite plugin](https://www.npmjs.com/package/@svelte-vitals/vite)'s live dashboard, a Cursor rules file, a `svelte-vitals.config` file, and a GitHub Actions CI workflow grouped by category in the picker so it's clear what each target is for:
78
+ An interactive wizard that wires up the [Vite plugin](https://www.npmjs.com/package/@svelte-vitals/vite)'s live dashboard, a Cursor rules file, a `svelte-vitals.config` file, and a GitHub Actions CI workflow, grouped by category in the picker so it's clear what each target is for:
79
79
 
80
80
  ```bash
81
81
  npx svelte-vitals@latest install
82
82
  ```
83
83
 
84
- The `/setup-svelte-vitals`, `/improve-svelte` and `/svelte-vitals` Agent Skills are not installer targets install them with `npx skills add oekazuma/svelte-vitals`.
84
+ The `/setup-svelte-vitals`, `/improve-svelte` and `/svelte-vitals` Agent Skills are not installer targets. Install them with `npx skills add oekazuma/svelte-vitals`.
85
85
 
86
86
  ### `svelte-vitals docs` / `svelte-vitals explain`
87
87
 
88
- Both read out of the CLI itself, so the answer always matches the installed version and needs no network the thing an AI agent otherwise guesses at or fetches from a page describing a different release.
88
+ Both read out of the CLI itself, so the answer always matches the installed version and needs no network, which is the thing an AI agent otherwise guesses at or fetches from a page describing a different release.
89
89
 
90
90
  ```bash
91
91
  npx svelte-vitals@latest docs list # every bundled topic, with a one-line description
@@ -94,11 +94,11 @@ npx svelte-vitals@latest explain --list # every rule, by category
94
94
  npx svelte-vitals@latest explain performance/heavy-import
95
95
  ```
96
96
 
97
- `explain <rule-id>` prints that rule's rationale, docs link, fix template, and for a configurable rule every option's default, bounds, and how a configured value merges with the built-in default: the detail needed to decide whether a finding is a defect or a threshold disagreement. `docs list` and both forms of `explain` take `--json`; `docs show` prints the topic as Markdown.
97
+ `explain <rule-id>` prints that rule's rationale, docs link, fix template, and, for a configurable rule, every option's default, bounds, and how a configured value merges with the built-in default: the detail needed to decide whether a finding is a defect or a threshold disagreement. `docs list` and both forms of `explain` take `--json`; `docs show` prints the topic as Markdown.
98
98
 
99
99
  ### CI integration
100
100
 
101
- `svelte-vitals ci install` scaffolds a GitHub Actions workflow around `@svelte-vitals/action` inline PR annotations, a job summary, and a sticky PR comment, no YAML to hand-write. The same workflow is also a selectable `ci-workflow` target inside `svelte-vitals install`, so it can be set up in the same pass as everything else. See [CI integration](https://oekazuma.github.io/svelte-vitals/guides/ci/).
101
+ `svelte-vitals ci install` scaffolds a GitHub Actions workflow around `@svelte-vitals/action`: inline PR annotations, a job summary, and a sticky PR comment, with no YAML to hand-write. The same workflow is also a selectable `ci-workflow` target inside `svelte-vitals install`, so it can be set up in the same pass as everything else. See [CI integration](https://oekazuma.github.io/svelte-vitals/guides/ci/).
102
102
 
103
103
  ### Agent-native output
104
104
 
@@ -110,7 +110,7 @@ It is selected **automatically** when run inside a known AI-agent harness (e.g.
110
110
 
111
111
  svelte-vitals resolves the effective `<head>` of every route by walking the layout chain (`+layout.svelte` → … → `+page.svelte`) and parsing `<svelte:head>` with `svelte/compiler`.
112
112
 
113
- A dynamic title such as `<title>{data.title}</title>` the most common, correct SvelteKit pattern is **never** flagged as missing; it passes with a `↯` marker. Only genuinely missing or empty metadata is penalized.
113
+ A dynamic title such as `<title>{data.title}</title>`, the most common and correct SvelteKit pattern, is **never** flagged as missing; it passes with a `↯` marker. Only genuinely missing or empty metadata is penalized.
114
114
 
115
115
  See the [full documentation](https://oekazuma.github.io/svelte-vitals/) for every flag, rule, and reporter, or the [project README](https://github.com/oekazuma/svelte-vitals#readme) for the full picture and roadmap.
116
116
 
@@ -1,4 +1,4 @@
1
- import { c as findUnknownRuleIds, h as readPackageVersion, i as run, l as knownRuleIds, m as readCoreVersion, p as isReporterName } from "./src-hgEeAWHb.js";
1
+ import { c as findUnknownRuleIds, h as readPackageVersion, i as run, l as knownRuleIds, m as readCoreVersion, p as isReporterName } from "./src-YdyHF4je.js";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { spawnSync } from "node:child_process";
package/dist/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { g as realIO, i as resolveLocale, m as consoleIO, n as runAnalyzeCliGunshi } from "./analyze-njXPaqmd.js";
2
+ import { g as realIO, i as resolveLocale, m as consoleIO, n as runAnalyzeCliGunshi } from "./analyze-BMmHtlXw.js";
3
3
  //#region src/cli.ts
4
4
  /**
5
5
  * CLI dispatch: routes `docs`/`explain`/`install`/`ci` subcommands, otherwise hands off to the
@@ -16,35 +16,35 @@ async function runCli(argv, io = consoleIO, env = process.env) {
16
16
  try {
17
17
  const locale = resolveLocale(env);
18
18
  if (argv[0] === "complete") {
19
- const { runCompleteCliGunshi } = await import("./complete-Cy8SdF0S.js");
19
+ const { runCompleteCliGunshi } = await import("./complete-Ch90r0sl.js");
20
20
  return {
21
21
  code: await runCompleteCliGunshi(argv, io),
22
22
  exit: "natural"
23
23
  };
24
24
  }
25
25
  if (argv[0] === "docs") {
26
- const { runDocsCliGunshi } = await import("./docs-YEEkUHTO.js");
26
+ const { runDocsCliGunshi } = await import("./docs-d-yH1BK6.js");
27
27
  return {
28
28
  code: await runDocsCliGunshi(argv.slice(1), io, locale),
29
29
  exit: "natural"
30
30
  };
31
31
  }
32
32
  if (argv[0] === "explain") {
33
- const { runExplainCliGunshi } = await import("./explain-B0DWOujg.js");
33
+ const { runExplainCliGunshi } = await import("./explain-CibqNEff.js");
34
34
  return {
35
35
  code: await runExplainCliGunshi(argv.slice(1), io, locale),
36
36
  exit: "natural"
37
37
  };
38
38
  }
39
39
  if (argv[0] === "install") {
40
- const { runInstallCliGunshi } = await import("./install-CzogylBI.js").then((n) => n.n);
40
+ const { runInstallCliGunshi } = await import("./install-BrrV4ATR.js").then((n) => n.n);
41
41
  return {
42
42
  code: await runInstallCliGunshi(argv.slice(1), io, locale),
43
43
  exit: "immediate"
44
44
  };
45
45
  }
46
46
  if (argv[0] === "ci") {
47
- const { runCiCliGunshi } = await import("./ci-BWM2gYNJ.js");
47
+ const { runCiCliGunshi } = await import("./ci-BDOPcOtr.js");
48
48
  return {
49
49
  code: await runCiCliGunshi(argv.slice(1), {
50
50
  ...realIO(),
@@ -1,4 +1,4 @@
1
- import { a as guardArgs, c as stripUnknownFlags, g as realIO, l as suggestClosest, o as splitAtTerminator, r as localizedOptionsSection, s as stripAutoVersionLine } from "./analyze-njXPaqmd.js";
1
+ import { a as guardArgs, c as stripUnknownFlags, g as realIO, l as suggestClosest, o as splitAtTerminator, r as localizedOptionsSection, s as stripAutoVersionLine } from "./analyze-BMmHtlXw.js";
2
2
  import { a as planWorkflowWrite, i as buildWorkflowYaml, n as ACTION_VERSION, r as WORKFLOW_PATH, t as ACTION_SHA } from "./action-pin.generated-C2XJJ3NW.js";
3
3
  import { join } from "node:path";
4
4
  import { cli } from "gunshi/bone";
@@ -1,9 +1,9 @@
1
- import { f as REPORTER_NAMES } from "./src-hgEeAWHb.js";
2
- import { d as FAIL_ON_VALUES, f as TREAT_DYNAMIC_AS_VALUES, m as consoleIO, t as ROOT_ARGS, u as CATEGORIES } from "./analyze-njXPaqmd.js";
3
- import { t as INSTALL_ARGS } from "./install-CzogylBI.js";
4
- import { CI_ARGS, CI_UPGRADE_ARGS } from "./ci-BWM2gYNJ.js";
5
- import { DOCS_LIST_ARGS, DOCS_ROOT_ARGS, DOCS_SHOW_ARGS } from "./docs-YEEkUHTO.js";
6
- import { EXPLAIN_ARGS } from "./explain-B0DWOujg.js";
1
+ import { f as REPORTER_NAMES } from "./src-YdyHF4je.js";
2
+ import { d as FAIL_ON_VALUES, f as TREAT_DYNAMIC_AS_VALUES, m as consoleIO, t as ROOT_ARGS, u as CATEGORIES } from "./analyze-BMmHtlXw.js";
3
+ import { t as INSTALL_ARGS } from "./install-BrrV4ATR.js";
4
+ import { CI_ARGS, CI_UPGRADE_ARGS } from "./ci-BDOPcOtr.js";
5
+ import { DOCS_LIST_ARGS, DOCS_ROOT_ARGS, DOCS_SHOW_ARGS } from "./docs-d-yH1BK6.js";
6
+ import { EXPLAIN_ARGS } from "./explain-CibqNEff.js";
7
7
  import { cli } from "gunshi/bone";
8
8
  import { define } from "gunshi/definition";
9
9
  import { kebabnize } from "gunshi/utils";
@@ -0,0 +1,253 @@
1
+ import { l as knownRuleIds } from "./src-YdyHF4je.js";
2
+ import { a as guardArgs, c as stripUnknownFlags, l as suggestClosest, m as consoleIO, o as splitAtTerminator, r as localizedOptionsSection, s as stripAutoVersionLine } from "./analyze-BMmHtlXw.js";
3
+ import { docsUrlFor } from "@svelte-vitals/core/internal";
4
+ import { cli } from "gunshi/bone";
5
+ import { define } from "gunshi/definition";
6
+ import "gunshi/generator";
7
+ //#region src/docs/generated.ts
8
+ const EMBEDDED_DOCS = [
9
+ {
10
+ name: "ci",
11
+ title: "Running in CI",
12
+ description: "Scaffold a GitHub Actions PR gate with `ci install`, what the generated workflow does, and how to gate a pipeline without the Action.",
13
+ body: "# Running in CI\n\n## Scaffold the GitHub Actions gate\n\n```bash\nnpx svelte-vitals@latest ci install # writes .github/workflows/svelte-vitals.yml\nnpx svelte-vitals@latest ci upgrade # bump only the pinned action ref in an existing file\n```\n\n`ci install` also exists as the `ci-workflow` target inside `svelte-vitals install`, so it can be\nset up in the same pass as everything else. Both support `--dry-run` and `--force`.\n\n## What the generated workflow does\n\nOn every `pull_request` it checks out with `fetch-depth: 0`, then calls `@svelte-vitals/action`,\nwhich runs the analysis **in-process** scoped to the PR (`diff`/`baseline` at `origin/<base>`).\nFrom that one analysis it produces:\n\n- inline annotations on the diff,\n- a job summary,\n- a sticky PR comment (a hidden `<!-- svelte-vitals-report -->` marker updates the same comment\n instead of piling up new ones).\n\nIt fails the job **after** writing those, so a failing run still leaves the feedback behind.\n\nInputs: `path` (default `.`), `diff`, `baseline`, `github-token`. No `reporter` input. The\nfan-out is fixed. It reads your committed `svelte-vitals.config.*` and\n`svelte-vitals-suppressions.json`, so rule policy stays in those files.\n\nPermissions: `contents: read`, `pull-requests: write`. On fork PRs GitHub downgrades the token\nregardless, so the action skips the comment (never failing the job); annotations and summary\nstill work.\n\n## Without the Action\n\nAny CI can run the CLI directly. On GitHub, `GITHUB_ACTIONS=true` auto-selects the `github`\nreporter, so annotations come for free, but a detected AI-agent environment outranks it, so a\njob driven by an agent gets `agent` instead. Pass `--reporter github` when you need the\nannotations regardless of who is running the job:\n\n```bash\nnpx svelte-vitals@latest --reporter github --fail-on warning\n```\n\nFor a PR gate that ignores a legacy backlog, pair the two scoping flags:\n\n```bash\nnpx svelte-vitals@latest --diff origin/main --baseline origin/main --fail-on warning\n```\n\nGate on the score instead of, or as well as, individual findings with `--min-health <0-100>`.\n\nExit `1` means findings gated the run; exit `2` means the run did not happen. Fail the job\nloudly on `2` rather than treating it as a pass.\n\n## Related\n\n- `svelte-vitals docs show scoping`: what `--diff` / `--baseline` / suppressions each do\n- `svelte-vitals docs show output`: reporters and exit codes"
14
+ },
15
+ {
16
+ name: "config",
17
+ title: "The config file",
18
+ description: "Where svelte-vitals.config lives, every top-level option, how to disable or re-grade a rule, and how to scope rules to routes or files.",
19
+ body: "# The config file\n\n## Where it lives\n\nIn the **analyzed directory only**, with no upward search. First match wins:\n\n1. `svelte-vitals.config.js`\n2. `svelte-vitals.config.ts`\n\nNo file means built-in defaults. `svelte-vitals install --client config-file` scaffolds one with\nevery option commented out.\n\n`--config <path>` analyzes under the config file at that path instead of the one in the analyzed\ndirectory: no discovery, no merge. A relative path resolves against the directory you run the\ncommand from, never the analyzed directory: from a repo root,\n`svelte-vitals apps/web --config shared/sv.config.js` reads `./shared/sv.config.js`. It accepts\n`.js` and `.ts` only, and a missing or unreadable file exits `2`. Useful for trying a config out\nbefore committing it, and for sharing one config across the apps in a monorepo. CLI only: the Vite\nplugin resolves its own config from the `cwd` passed to `svelteVitals({ ... })` (else the Vite\nconfig root). Share with it by importing the shared file in `vite.config.ts` and spreading it\ninto the plugin's options.\n\n```js\n// svelte-vitals.config.js\nexport default {\n treatDynamicAs: 'warn',\n metaComponents: ['Seo'],\n rules: { 'seo/json-ld': 'off' },\n failOn: 'warning',\n weights: { seo: 2 }\n};\n```\n\nA `.ts` config can `import { defineConfig } from 'svelte-vitals'` for type-checking, but that is a\n**runtime** import: it needs svelte-vitals as a declared dependency. A plain `export default {}`\nin `.js` behaves identically and needs no dependency. Both are ESM, so the project must be\n`\"type\": \"module\"` (SvelteKit's default).\n\n## Options\n\n| Option | Type | Default |\n| ---------------- | -------------------------------------------------------------- | ------------------ |\n| `treatDynamicAs` | `'pass' \\| 'warn' \\| 'fail'` | `'pass'` |\n| `metaComponents` | `string[]` | `[]` |\n| `rules` | `Record<ruleId, 'off' \\| Severity \\| { severity?, options? }>` | `{}` |\n| `failOn` | `'critical' \\| 'warning' \\| 'info'` | `'critical'` |\n| `weights` | `Partial<Record<Category, number>>` | every category `1` |\n| `overrides` | `RuleOverride[]` | (none) |\n\n`Severity` is `'critical' | 'warning' | 'info'`. `Category` is `'seo' | 'performance' |\n'correctness' | 'security' | 'architecture' | 'a11y'`. A weight of `0` drops a category from the Health\naverage; setting every category to `0` is an error (exit `2`).\n\n`metaComponents` names head-metadata components the analyzer cannot resolve (e.g. from an npm\npackage without an adapter). Components the analyzer can resolve in your own repo are followed\nautomatically, so declaring one of those is a no-op; the declaration only kicks in when\nresolution fails.\n\n## Turning a rule off or down\n\n```js\nexport default {\n rules: {\n 'seo/json-ld': 'off', // remove its findings entirely\n 'architecture/prop-count': 'info' // keep it, stop it failing the build\n }\n};\n```\n\nMany rules take options, so check whether the finding is a **threshold disagreement** rather than\na defect first. `svelte-vitals explain <rule-id>` prints each option's name, default, bounds, and\nmerge semantics (`integer` replaces, `string-list` appends, `string-map` is spread over).\n\n```js\nexport default {\n rules: {\n 'architecture/prop-count': { options: { max: 12 } }\n }\n};\n```\n\n## Scoping to routes or files (`overrides`)\n\n`rules` applies everywhere; `overrides` applies only where it matches, typically routes that\nare deliberately not public.\n\n```js\nexport default {\n overrides: [\n { files: 'src/routes/(app)/**', rules: { seo: 'off' } },\n { route: '/admin/**', rules: { 'seo/title-presence': 'info' } }\n ]\n};\n```\n\nEach entry needs `rules` (keys are rule ids **or** category names) plus at least one of:\n\n- `route`: glob(s) against the route id as reported (`/blog/[slug]`). SvelteKit `(group)`\n segments are **not** in the route id, so use `files` to target a group.\n- `files`: glob(s) against the source path.\n\nGlobs are deliberately small: `*` within a segment, `**` across segments, a trailing `/**` also\nmatches the bare prefix. Everything else is literal, including `(`, `)`, `[` and `]`. Later entries win.\n\n## Precedence\n\nPer field: **CLI flag > config file > built-in default**. One exception: `--rules` and `--ignore`\nare selection, not configuration: `--rules` narrows the run to the ids it names and overrides a\nconfig-file `off` for them, but keeps their declared severity and options; `--ignore` adds `off`\nentries for the ids it names, layered on top of whatever `rules` resolved to, and beats `--rules`\nwhen both name the same rule.\n\n`overrides` has no CLI flag; route policy belongs in a committed file.\n\n## Validation\n\nAn unknown rule id or category, a negative weight, a malformed `overrides` entry, or an invalid\nrule setting is a **hard error (exit `2`)**: a typo must not silently un-gate CI. An unrecognized\n`treatDynamicAs`/`failOn` value, or an unknown top-level key, only warns.\n\n## Related\n\n- `svelte-vitals explain --list`: every rule id\n- `svelte-vitals docs show scoping`: accepting an existing backlog instead of disabling rules"
20
+ },
21
+ {
22
+ name: "monorepo",
23
+ title: "Monorepos",
24
+ description: "How svelte-vitals picks which SvelteKit app to analyze, why it exits 2 instead of prompting in a non-interactive shell, and how to name the app explicitly.",
25
+ body: "# Monorepos\n\n## Naming the app is always safest\n\n```bash\nnpx svelte-vitals@latest apps/web\n```\n\nAn explicit `path`, or running from inside the app directory, skips detection entirely. Prefer\nit in a script, a hook, or an agent's shell.\n\n## What happens without a path\n\nWhen no `path` is given and the current directory is not itself a SvelteKit app, svelte-vitals\nlooks for nearby apps, meaning a directory with `src/routes` **and** either a\n`svelte.config.{js,ts}` or a `package.json` declaring `@sveltejs/kit` (current `sv create`\noutput folds the SvelteKit config into `vite.config.ts` and emits no `svelte.config` file):\n\n| Found | Interactive terminal | Non-interactive (CI, agents, piped output) |\n| ----------- | ----------------------------------- | ---------------------------------------------------------- |\n| exactly one | analyzed, notice on stderr | same: analyzed, notice on stderr |\n| several | single-select prompt | **exit `2`** listing the apps, asking for an explicit path |\n| none | exit `2`, \"not a SvelteKit project\" | same |\n\n**svelte-vitals never prompts when stdout is not a TTY.** It fails fast with the list instead of\nhanging or guessing. Re-run with the path it printed. Cancelling the interactive prompt exits `0`.\n\n## `install` in a monorepo\n\n`svelte-vitals install` splits its targets by where they belong:\n\n- `vite-plugin`, `vite-hooks`, `config-file` write into the **app** directory; they resolve it\n the same way the analyzer does, and `--app <app>` names it explicitly.\n- `cursor-rules` and `ci-workflow` always write at the **current** directory, because the repo\n root is their correct home.\n\n```bash\nnpx svelte-vitals@latest install --client vite-plugin,config-file --app apps/web --yes\n```\n\n`--app` pointing at a directory that is not a SvelteKit app is an error (exit `2`).\n\n## Related\n\n- `svelte-vitals docs show output`: what exit `2` means versus exit `1`"
26
+ },
27
+ {
28
+ name: "output",
29
+ title: "Reading the output",
30
+ description: "Which reporter to use, how one is auto-selected, what goes to stdout vs stderr, and what each exit code means.",
31
+ body: "# Reading the output\n\n## Pick a reporter\n\n`--reporter <fmt>`: `console` (default) · `json` · `agent` · `sarif` · `github` · `html` · `md`.\n\n- `agent`: a Markdown remediation document. Each failing finding with its location, a concrete fix\n (with snippet), and an acceptance check. Use this when something will act on the findings.\n- `json`: the full structured report (scores per route and site-wide; every finding with\n `fix`, `recommendation`, `docsUrl`). Use it to filter or count.\n- `console`: for a human. Grouped and capped; `--verbose` for everything.\n- `md`: a compact summary table for a PR comment or job summary (capped at 50 rows).\n- `sarif`: SARIF v2.1, for GitHub Code Scanning and other SAST tooling.\n- `github`: `::error` / `::warning` workflow annotations.\n- `html`: a self-contained report file; `--out-file <path>`, or `--out-file -` for stdout.\n\n## Auto-selection\n\nFirst match wins:\n\n1. an explicit `--reporter <fmt>`\n2. `SVELTE_VITALS_REPORTER=<fmt>`\n3. a recognized AI-agent harness (Claude Code, Cursor, Codex, and others; detection is\n delegated to gunshi's agent profile and evolves with it) or `SVELTE_VITALS_AGENT=1` set → `agent`\n4. `GITHUB_ACTIONS=true` → `github`\n5. otherwise → `console`\n\nInside an agent harness you get `agent` without asking; a one-line override hint goes to stderr.\n\n## stdout vs stderr\n\nThe report goes to **stdout**; every diagnostic goes to **stderr**: hints, suppression counts, app-detection\nnotices, warnings, errors. Piping stdout never mixes the two.\n\n`--reporter html` is the exception: it writes a file and prints the path to stderr, unless you\npass `--out-file -`.\n\n## Exit codes\n\n| Code | Meaning |\n| ---- | -------------------------------------------------------------------------- |\n| `0` | no failing findings |\n| `1` | a critical finding is present, or `--fail-on` / `--min-health` was reached |\n| `2` | execution error: not a SvelteKit project, bad flag, unreadable config |\n\n`2` is never a clean result; the run did not happen. `--fail-on <critical|warning|info>` lowers\nthe bar for `1`; `--min-health <0-100>` adds a score gate.\n\n## Related\n\n- `svelte-vitals docs show scoping`: report only what a change introduced\n- `svelte-vitals explain <rule-id>`: one rule's rationale, fix and options"
32
+ },
33
+ {
34
+ name: "scoping",
35
+ title: "Scoping findings to a change",
36
+ description: "Use --diff, --staged, --baseline and the suppressions file so only what a change introduced is reported, instead of a legacy backlog.",
37
+ body: "# Scoping findings to a change\n\nAn existing project usually has a backlog nobody is about to fix. Scope the report rather than\ndisabling rules.\n\n## Scope by file\n\n- `--diff [ref]`: only findings in files changed versus `ref` (default `HEAD`).\n- `--staged`: only findings in staged files. The pre-commit gate.\n\n```bash\nsvelte-vitals . --diff --reporter agent # after editing: what did I just break?\nsvelte-vitals . --staged # before committing\n```\n\nBoth work when the project is not at the git repo root.\n\n## Scope by finding (`--baseline <ref>`)\n\nReports only findings **not already present** at `ref`. Scopes by finding identity rather than by\nfile, so a pre-existing problem in a file you touched does not fail the gate. No default ref.\n\n```bash\nsvelte-vitals --diff origin/main --baseline origin/main --fail-on warning # PR gate\n```\n\nIt checks `ref` out into a temporary worktree and subtracts those findings. On failure (no git,\nbad ref) it warns and reports everything rather than failing the run.\n\n## Accept a backlog once (`svelte-vitals-suppressions.json`)\n\n`--baseline` handles the transient case. For a persistent ramp, record today's findings once:\n\n```bash\nsvelte-vitals --update-suppressions\ngit add svelte-vitals-suppressions.json\n```\n\nThis analyzes the whole project (`--diff`/`--staged`/`--baseline` are ignored), writes every\npenalized finding, and exits `0` without a report.\n\nThe file then applies automatically on every run, after `--diff`/`--staged` and `--baseline`.\nFixing an accepted finding leaves a **stale** entry, reported on stderr but never failing the run.\n`--no-suppressions` ignores the file for one run.\n\nAn entry covers whatever its rule reports at that route and location, not just the message\nrecorded when written, so a different finding from the same rule at the same spot still matches\nand stays suppressed (and not stale).\n\nA malformed suppressions file is a hard error (exit `2`), not a silent skip.\n\n## Suppress one occurrence inline\n\nFor a single finding that is correct by design, put a `svelte-vitals-disable-next-line` comment on\nthe line directly above it:\n\n```html\n<!-- svelte-vitals-disable-next-line security/raw-html -->\n<div>{@html sanitized}</div>\n```\n\nInside `<script>`, use `// svelte-vitals-disable-next-line <rule-id>`. Omit the id to suppress every\nrule on the next line, or list several comma-separated.\n\nOnly findings the report anchors to a **line** can be reached this way, not the `<head>` metadata\nrules, which report what a route never set. A directive in a component silences that finding on\nevery route composing it; for one route, use the suppressions file or `overrides`.\n\n## Which one\n\n| Situation | Use |\n| -------------------------------------- | ---------------------------------------- |\n| Checking an edit you just made | `--diff` |\n| One occurrence that is right as-is | `svelte-vitals-disable-next-line` |\n| Pre-commit hook | `--staged` |\n| PR gate against a base branch | `--diff <base> --baseline <base>` |\n| Adopting on a legacy project, for good | `--update-suppressions`, commit the file |\n\nMatching ignores line numbers in both `--baseline` and the suppressions file, so a second\nviolation of the same rule lower in the same file does not surface as new.\n\n## Related\n\n- `svelte-vitals docs show ci`: the generated PR gate already does the `--diff`/`--baseline` pairing\n- `svelte-vitals docs show config`: turning a rule off for good, when that is genuinely right"
38
+ }
39
+ ];
40
+ //#endregion
41
+ //#region src/gunshi/docs.ts
42
+ /**
43
+ * Frozen error-path text: printed verbatim on `docs`'s non-help exit-2 paths (bare `docs`,
44
+ * unknown subcommand); `docs --help` builds a separate, generated OPTIONS block around this
45
+ * prose instead — see `buildDocsHelpText` below.
46
+ */
47
+ const DOCS_HELP = `svelte-vitals docs — read the bundled guides without leaving the terminal
48
+
49
+ Usage:
50
+ svelte-vitals docs list [--json] List every topic with a one-line description
51
+ svelte-vitals docs show <name> Print a topic
52
+
53
+ Options:
54
+ --json Machine-readable output (list only)
55
+ -h, --help Show this help
56
+
57
+ The topics ship inside the CLI, so they always match the version you are running and need no
58
+ network. The full docs site is at https://oekazuma.github.io/svelte-vitals.
59
+
60
+ \`docs\` is a subcommand, so it wins over a directory of the same name: to analyze a directory
61
+ called \`docs\`, write \`svelte-vitals ./docs\`.`;
62
+ /** Mirrors `knownRuleIds()`. */
63
+ function knownTopicNames() {
64
+ return EMBEDDED_DOCS.map((d) => d.name).join(", ");
65
+ }
66
+ function renderList() {
67
+ const width = Math.max(...EMBEDDED_DOCS.map((d) => d.name.length));
68
+ return [
69
+ "Topics (read one with `svelte-vitals docs show <name>`):",
70
+ "",
71
+ ...EMBEDDED_DOCS.map((d) => ` ${d.name.padEnd(width)} ${d.description}`),
72
+ "",
73
+ "Rule-level detail is a separate command: `svelte-vitals explain --list`."
74
+ ].join("\n");
75
+ }
76
+ /** docs declares no value-carrying flags today — see guard.ts's own doc comment for why the list is still passed explicitly. */
77
+ const BOOLEAN_FLAGS = ["json", "help"];
78
+ const HELP_ARG = { help: {
79
+ type: "boolean",
80
+ short: "h",
81
+ description: "Show this help"
82
+ } };
83
+ const JSON_ARG = { json: {
84
+ type: "boolean",
85
+ description: "Machine-readable output (list only)"
86
+ } };
87
+ /** Family-wide, not per-subcommand — see guard.ts's `stripUnknownFlags` doc comment: the legacy
88
+ * runner parses `--json`/`-h`/`--help` in one flat pass, so `show` (which never reads `--json`
89
+ * itself) still needs it declared below to keep gunshi's own per-command resolution from
90
+ * mistaking it for an unknown flag and swallowing the positional after it. */
91
+ const KNOWN_LONG_FLAGS = new Set(BOOLEAN_FLAGS);
92
+ const KNOWN_SHORT_FLAGS = /* @__PURE__ */ new Set(["h"]);
93
+ /** Exported for gunshi/complete.ts — the completion tree's docs args mirror these, never a second copy. */
94
+ const DOCS_ROOT_ARGS = {
95
+ ...JSON_ARG,
96
+ ...HELP_ARG
97
+ };
98
+ const DOCS_LIST_ARGS = {
99
+ ...JSON_ARG,
100
+ ...HELP_ARG
101
+ };
102
+ const DOCS_SHOW_ARGS = {
103
+ name: {
104
+ type: "positional",
105
+ required: false
106
+ },
107
+ ...JSON_ARG,
108
+ ...HELP_ARG
109
+ };
110
+ /**
111
+ * Hybrid `docs --help` text: hand-written header/usage/footer preserved verbatim from `DOCS_HELP`
112
+ * (the frozen text `gunshi/docs.ts`'s error paths still print as-is), OPTIONS generated from this
113
+ * command's own `args` — same technique as `gunshi/analyze.ts`'s `buildHelpText`. `generate()`
114
+ * always injects a `-v, --version` line (gunshi's hardcoded internal `global()` plugin — confirmed
115
+ * empirically, undocumented) even though `docs` has no working `--version`; `stripAutoVersionLine`
116
+ * drops it so help never advertises a flag that doesn't exist. ja-localized when `locale` is 'ja'
117
+ * (`docs/superpowers/specs/2026-08-11-cli-ja-help-design.md`).
118
+ */
119
+ async function buildDocsHelpText(rootCommand, locale) {
120
+ const ja = locale === "ja" ? await import("./ja-BDLP3KLG.js").then((n) => n.n) : void 0;
121
+ const optionsSection = stripAutoVersionLine(await localizedOptionsSection(rootCommand, "svelte-vitals docs", locale, ja?.JA_ARG_DESCRIPTIONS.docs ?? {}));
122
+ if (locale === "ja") return ja.docsHelpJa(optionsSection);
123
+ return `svelte-vitals docs — read the bundled guides without leaving the terminal
124
+
125
+ Usage:
126
+ svelte-vitals docs list [--json] List every topic with a one-line description
127
+ svelte-vitals docs show <name> Print a topic
128
+
129
+ ${optionsSection}
130
+
131
+ The topics ship inside the CLI, so they always match the version you are running and need no
132
+ network. The full docs site is at https://oekazuma.github.io/svelte-vitals.
133
+
134
+ \`docs\` is a subcommand, so it wins over a directory of the same name: to analyze a directory
135
+ called \`docs\`, write \`svelte-vitals ./docs\`.`;
136
+ }
137
+ /**
138
+ * gunshi/bone port of `docs/cli.ts`'s dispatch (design doc: Phase 2a). `docs` is passed as its
139
+ * own `cli()` entry (not nested under a shared root) so an unmatched sub-command token reaches
140
+ * this file's own root `run()` via `fallbackToEntry` directly — reproducing
141
+ * `unknown docs subcommand '<x>'` verbatim without having to catch and re-render gunshi's own
142
+ * `CommandNotFoundError`, which is what nesting `docs` under a shared entry would require (that
143
+ * error only carries `commandPath`/`candidates` at the *intermediate* level, not the entry level;
144
+ * see the exit-code closure note below for the other half of why this shape was chosen).
145
+ *
146
+ * Exit codes have no return-value channel in gunshi (`executeCommand` discards a non-string
147
+ * runner return) — `exitCode` is the closure every `run` below sets instead. Commands are built
148
+ * fresh on every call (not module-level singletons) so concurrent invocations can't race across
149
+ * `cli()`'s internal `await`s and clobber each other's closure.
150
+ */
151
+ async function runDocsCliGunshi(args, io = consoleIO, locale = "en") {
152
+ const { head, tail } = splitAtTerminator(args);
153
+ const argvForGunshi = stripUnknownFlags(guardArgs(head, [], BOOLEAN_FLAGS).argv, KNOWN_LONG_FLAGS, KNOWN_SHORT_FLAGS);
154
+ const promoted = !argvForGunshi.some((t) => !t.startsWith("-")) && (tail[0] === "list" || tail[0] === "show");
155
+ const finalArgv = promoted ? [...argvForGunshi, tail[0]] : argvForGunshi;
156
+ const tailRest = promoted ? tail.slice(1) : tail;
157
+ let exitCode = 0;
158
+ const listCommand = define({
159
+ name: "list",
160
+ args: DOCS_LIST_ARGS,
161
+ run: async (ctx) => {
162
+ if (ctx.values.help) {
163
+ io.log(await buildDocsHelpText(rootCommand, locale));
164
+ exitCode = 0;
165
+ return;
166
+ }
167
+ if ([...ctx.positionals.slice(ctx.commandPath.length), ...tailRest].length > 0) {
168
+ io.errorLog("svelte-vitals: docs list takes no arguments; use `docs show <name>` to read one.");
169
+ exitCode = 2;
170
+ return;
171
+ }
172
+ io.log(ctx.values.json ? JSON.stringify(EMBEDDED_DOCS.map((d) => ({
173
+ name: d.name,
174
+ title: d.title,
175
+ description: d.description
176
+ })), null, 2) : renderList());
177
+ exitCode = 0;
178
+ }
179
+ });
180
+ const showCommand = define({
181
+ name: "show",
182
+ args: DOCS_SHOW_ARGS,
183
+ run: async (ctx) => {
184
+ if (ctx.values.help) {
185
+ io.log(await buildDocsHelpText(rootCommand, locale));
186
+ exitCode = 0;
187
+ return;
188
+ }
189
+ const rest = [...ctx.positionals.slice(ctx.commandPath.length), ...tailRest];
190
+ if (rest.length !== 1) {
191
+ io.errorLog(rest.length === 0 ? "svelte-vitals: docs show needs a topic name, e.g. `svelte-vitals docs show config`." : "svelte-vitals: docs show takes one topic at a time.");
192
+ io.errorLog(`svelte-vitals: known topics: ${knownTopicNames()}.`);
193
+ exitCode = 2;
194
+ return;
195
+ }
196
+ const name = rest[0];
197
+ const doc = EMBEDDED_DOCS.find((d) => d.name === name);
198
+ if (!doc) {
199
+ const ruleId = name.startsWith("rules/") ? name.slice(6) : name;
200
+ if (knownRuleIds().includes(ruleId)) {
201
+ io.errorLog(`svelte-vitals: '${ruleId}' is a rule, not a docs topic.`);
202
+ io.errorLog(`svelte-vitals: rule detail: \`svelte-vitals explain ${ruleId}\`; web: ${docsUrlFor(ruleId)}`);
203
+ exitCode = 2;
204
+ return;
205
+ }
206
+ io.errorLog(`svelte-vitals: unknown docs topic '${name}'.`);
207
+ io.errorLog(`svelte-vitals: known topics: ${knownTopicNames()}.`);
208
+ exitCode = 2;
209
+ return;
210
+ }
211
+ io.log(doc.body);
212
+ exitCode = 0;
213
+ }
214
+ });
215
+ const rootCommand = define({
216
+ name: "docs",
217
+ args: DOCS_ROOT_ARGS,
218
+ subCommands: {
219
+ list: listCommand,
220
+ show: showCommand
221
+ },
222
+ run: async (ctx) => {
223
+ if (ctx.values.help) {
224
+ io.log(await buildDocsHelpText(rootCommand, locale));
225
+ exitCode = 0;
226
+ return;
227
+ }
228
+ const [sub] = [...ctx.positionals, ...tailRest];
229
+ if (sub === void 0) {
230
+ io.errorLog(DOCS_HELP);
231
+ exitCode = 2;
232
+ return;
233
+ }
234
+ io.errorLog(`svelte-vitals: unknown docs subcommand '${sub}'; expected list|show.`);
235
+ const hint = suggestClosest(sub, ["list", "show"]);
236
+ if (hint) io.errorLog(`svelte-vitals: did you mean \`svelte-vitals docs ${hint}\`?`);
237
+ io.errorLog(DOCS_HELP);
238
+ exitCode = 2;
239
+ }
240
+ });
241
+ await cli(finalArgv, rootCommand, {
242
+ name: "svelte-vitals docs",
243
+ subCommands: {
244
+ list: listCommand,
245
+ show: showCommand
246
+ },
247
+ fallbackToEntry: true,
248
+ usageSilent: true
249
+ });
250
+ return exitCode;
251
+ }
252
+ //#endregion
253
+ export { DOCS_LIST_ARGS, DOCS_ROOT_ARGS, DOCS_SHOW_ARGS, runDocsCliGunshi };
@@ -1,5 +1,5 @@
1
- import { l as knownRuleIds } from "./src-hgEeAWHb.js";
2
- import { a as guardArgs, c as stripUnknownFlags, l as suggestClosest, m as consoleIO, o as splitAtTerminator, r as localizedOptionsSection, s as stripAutoVersionLine } from "./analyze-njXPaqmd.js";
1
+ import { l as knownRuleIds } from "./src-YdyHF4je.js";
2
+ import { a as guardArgs, c as stripUnknownFlags, l as suggestClosest, m as consoleIO, o as splitAtTerminator, r as localizedOptionsSection, s as stripAutoVersionLine } from "./analyze-BMmHtlXw.js";
3
3
  import { CATEGORIES } from "@svelte-vitals/core";
4
4
  import { allRules, explainRule } from "@svelte-vitals/core/internal";
5
5
  import { cli } from "gunshi/bone";
@@ -1,4 +1,4 @@
1
- import { t as ROOT_ARGS } from "./analyze-njXPaqmd.js";
2
- import { t as INSTALL_ARGS } from "./install-CzogylBI.js";
1
+ import { t as ROOT_ARGS } from "./analyze-BMmHtlXw.js";
2
+ import { t as INSTALL_ARGS } from "./install-BrrV4ATR.js";
3
3
  import { t as JA_ARG_DESCRIPTIONS } from "./ja-BDLP3KLG.js";
4
4
  export { INSTALL_ARGS, JA_ARG_DESCRIPTIONS, ROOT_ARGS };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as spinnerEnabled, b as ProjectError, c as findUnknownRuleIds, d as ruleOptionsSpec, i as run, l as knownRuleIds, n as applyScope, o as CONFIG_FILENAMES, r as defineConfig, s as loadConfigFile, t as analyzeProject, u as registryTag, y as routeMatcher } from "./src-hgEeAWHb.js";
1
+ import { a as spinnerEnabled, b as ProjectError, c as findUnknownRuleIds, d as ruleOptionsSpec, i as run, l as knownRuleIds, n as applyScope, o as CONFIG_FILENAMES, r as defineConfig, s as loadConfigFile, t as analyzeProject, u as registryTag, y as routeMatcher } from "./src-YdyHF4je.js";
2
2
  export { CONFIG_FILENAMES, ProjectError, analyzeProject, applyScope, defineConfig, findUnknownRuleIds, knownRuleIds, loadConfigFile, registryTag, routeMatcher, ruleOptionsSpec, run, spinnerEnabled };
@@ -1,6 +1,6 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
- import { _ as hasDep, g as discoverApps, h as readPackageVersion, o as CONFIG_FILENAMES, v as readPkg } from "./src-hgEeAWHb.js";
3
- import { a as guardArgs, c as stripUnknownFlags, g as realIO, h as clackPrompts, m as consoleIO, o as splitAtTerminator, p as toList, r as localizedOptionsSection, s as stripAutoVersionLine } from "./analyze-njXPaqmd.js";
2
+ import { _ as hasDep, g as discoverApps, h as readPackageVersion, o as CONFIG_FILENAMES, v as readPkg } from "./src-YdyHF4je.js";
3
+ import { a as guardArgs, c as stripUnknownFlags, g as realIO, h as clackPrompts, m as consoleIO, o as splitAtTerminator, p as toList, r as localizedOptionsSection, s as stripAutoVersionLine } from "./analyze-BMmHtlXw.js";
4
4
  import { a as planWorkflowWrite, i as buildWorkflowYaml, n as ACTION_VERSION, r as WORKFLOW_PATH, t as ACTION_SHA } from "./action-pin.generated-C2XJJ3NW.js";
5
5
  import { buildCursorRules, installHeader } from "./install/skill-content.js";
6
6
  import { join } from "node:path";
@@ -727,11 +727,11 @@ function collectHeadings(node, source, acc) {
727
727
  });
728
728
  for (const key of CHILD_NODE_KEYS) if (key in node) collectHeadings(childOf(node, key), source, acc);
729
729
  }
730
- const LANDMARK_TAGS = {
731
- main: "main",
732
- header: "banner",
733
- footer: "contentinfo"
734
- };
730
+ const LANDMARK_TAGS = /* @__PURE__ */ new Map([
731
+ ["main", "main"],
732
+ ["header", "banner"],
733
+ ["footer", "contentinfo"]
734
+ ]);
735
735
  /**
736
736
  * HTML-AAM: an `<aside>` scoped to `body` or `main` is a `complementary` landmark; scoped to
737
737
  * sectioning content it is one only when it has an accessible name. `main` is deliberately absent
@@ -901,7 +901,7 @@ function collectA11y(fragment, source) {
901
901
  const attrs = node.attributes;
902
902
  const roleAttr = findAttr(attrs, "role");
903
903
  const role = roleAttr ? splitTokens(attrTextOf(roleAttr))[0] : void 0;
904
- let landmark = roleAttr ? role && LANDMARK_ROLES.has(role) ? role : void 0 : LANDMARK_TAGS[node.name];
904
+ let landmark = roleAttr ? role && LANDMARK_ROLES.has(role) ? role : void 0 : LANDMARK_TAGS.get(node.name);
905
905
  if (!roleAttr && node.name === "aside") landmark = ctx.asideDemoting === 0 || hasAccessibleName(attrs) ? "complementary" : void 0;
906
906
  if (landmark) {
907
907
  const headerFooter = !roleAttr && (node.name === "header" || node.name === "footer");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-vitals",
3
- "version": "0.52.0",
3
+ "version": "0.54.0",
4
4
  "description": "A deterministic SvelteKit code-health scanner (SEO, performance, correctness, security, architecture, accessibility).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,6 +43,7 @@
43
43
  "dist"
44
44
  ],
45
45
  "dependencies": {
46
+ "@svelte-vitals/core": "0.50.0",
46
47
  "@clack/prompts": "^1.7.0",
47
48
  "@gunshi/plugin-completion": "0.37.1",
48
49
  "@gunshi/plugin-i18n": "0.37.1",
@@ -50,8 +51,7 @@
50
51
  "gunshi": "0.37.1",
51
52
  "magicast": "^0.5.4",
52
53
  "svelte": "^5.56.10",
53
- "tinyglobby": "^0.2.17",
54
- "@svelte-vitals/core": "0.48.0"
54
+ "tinyglobby": "^0.2.17"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@gunshi/docs": "0.37.1",
@@ -1,253 +0,0 @@
1
- import { l as knownRuleIds } from "./src-hgEeAWHb.js";
2
- import { a as guardArgs, c as stripUnknownFlags, l as suggestClosest, m as consoleIO, o as splitAtTerminator, r as localizedOptionsSection, s as stripAutoVersionLine } from "./analyze-njXPaqmd.js";
3
- import { docsUrlFor } from "@svelte-vitals/core/internal";
4
- import { cli } from "gunshi/bone";
5
- import { define } from "gunshi/definition";
6
- import "gunshi/generator";
7
- //#region src/docs/generated.ts
8
- const EMBEDDED_DOCS = [
9
- {
10
- name: "ci",
11
- title: "Running in CI",
12
- description: "Scaffold a GitHub Actions PR gate with `ci install`, what the generated workflow does, and how to gate a pipeline without the Action.",
13
- body: "# Running in CI\n\n## Scaffold the GitHub Actions gate\n\n```bash\nnpx svelte-vitals@latest ci install # writes .github/workflows/svelte-vitals.yml\nnpx svelte-vitals@latest ci upgrade # bump only the pinned action ref in an existing file\n```\n\n`ci install` also exists as the `ci-workflow` target inside `svelte-vitals install`, so it can be\nset up in the same pass as everything else. Both support `--dry-run` and `--force`.\n\n## What the generated workflow does\n\nOn every `pull_request` it checks out with `fetch-depth: 0`, then calls `@svelte-vitals/action`,\nwhich runs the analysis **in-process** scoped to the PR (`diff`/`baseline` at `origin/<base>`).\nFrom that one analysis it produces:\n\n- inline annotations on the diff,\n- a job summary,\n- a sticky PR comment (a hidden `<!-- svelte-vitals-report -->` marker updates the same comment\n instead of piling up new ones).\n\nIt fails the job **after** writing those, so a failing run still leaves the feedback behind.\n\nInputs: `path` (default `.`), `diff`, `baseline`, `github-token`. No `reporter` input — the\nfan-out is fixed. It reads your committed `svelte-vitals.config.*` and\n`svelte-vitals-suppressions.json`, so rule policy stays in those files.\n\nPermissions: `contents: read`, `pull-requests: write`. On fork PRs GitHub downgrades the token\nregardless, so the action skips the comment (never failing the job); annotations and summary\nstill work.\n\n## Without the Action\n\nAny CI can run the CLI directly. On GitHub, `GITHUB_ACTIONS=true` auto-selects the `github`\nreporter, so annotations come for free — but a detected AI-agent environment outranks it, so a\njob driven by an agent gets `agent` instead. Pass `--reporter github` when you need the\nannotations regardless of who is running the job:\n\n```bash\nnpx svelte-vitals@latest --reporter github --fail-on warning\n```\n\nFor a PR gate that ignores a legacy backlog, pair the two scoping flags:\n\n```bash\nnpx svelte-vitals@latest --diff origin/main --baseline origin/main --fail-on warning\n```\n\nGate on the score instead of, or as well as, individual findings with `--min-health <0-100>`.\n\nExit `1` means findings gated the run; exit `2` means the run did not happen — fail the job\nloudly on `2` rather than treating it as a pass.\n\n## Related\n\n- `svelte-vitals docs show scoping` — what `--diff` / `--baseline` / suppressions each do\n- `svelte-vitals docs show output` — reporters and exit codes"
14
- },
15
- {
16
- name: "config",
17
- title: "The config file",
18
- description: "Where svelte-vitals.config lives, every top-level option, how to disable or re-grade a rule, and how to scope rules to routes or files.",
19
- body: "# The config file\n\n## Where it lives\n\nIn the **analyzed directory only** — no upward search. First match wins:\n\n1. `svelte-vitals.config.js`\n2. `svelte-vitals.config.ts`\n\nNo file means built-in defaults. `svelte-vitals install --client config-file` scaffolds one with\nevery option commented out.\n\n`--config <path>` analyzes under the config file at that path instead of the one in the analyzed\ndirectory — no discovery, no merge. A relative path resolves against the directory you run the\ncommand from, never the analyzed directory: from a repo root,\n`svelte-vitals apps/web --config shared/sv.config.js` reads `./shared/sv.config.js`. It accepts\n`.js` and `.ts` only, and a missing or unreadable file exits `2`. Useful for trying a config out\nbefore committing it, and for sharing one config across the apps in a monorepo. CLI only: the Vite\nplugin resolves its own config from the `cwd` passed to `svelteVitals({ ... })` (else the Vite\nconfig root) — share with it by importing the shared file in `vite.config.ts` and spreading it\ninto the plugin's options.\n\n```js\n// svelte-vitals.config.js\nexport default {\n treatDynamicAs: 'warn',\n metaComponents: ['Seo'],\n rules: { 'seo/json-ld': 'off' },\n failOn: 'warning',\n weights: { seo: 2 }\n};\n```\n\nA `.ts` config can `import { defineConfig } from 'svelte-vitals'` for type-checking, but that is a\n**runtime** import: it needs svelte-vitals as a declared dependency. A plain `export default {}`\nin `.js` behaves identically and needs no dependency. Both are ESM — the project must be\n`\"type\": \"module\"` (SvelteKit's default).\n\n## Options\n\n| Option | Type | Default |\n| ---------------- | -------------------------------------------------------------- | ------------------ |\n| `treatDynamicAs` | `'pass' \\| 'warn' \\| 'fail'` | `'pass'` |\n| `metaComponents` | `string[]` | `[]` |\n| `rules` | `Record<ruleId, 'off' \\| Severity \\| { severity?, options? }>` | `{}` |\n| `failOn` | `'critical' \\| 'warning' \\| 'info'` | `'critical'` |\n| `weights` | `Partial<Record<Category, number>>` | every category `1` |\n| `overrides` | `RuleOverride[]` | (none) |\n\n`Severity` is `'critical' | 'warning' | 'info'`. `Category` is `'seo' | 'performance' |\n'correctness' | 'security' | 'architecture' | 'a11y'`. A weight of `0` drops a category from the Health\naverage; setting every category to `0` is an error (exit `2`).\n\n`metaComponents` names head-metadata components the analyzer cannot resolve (e.g. from an npm\npackage without an adapter). Components the analyzer can resolve in your own repo are followed\nautomatically — declaring one of those is a no-op; the declaration only kicks in when\nresolution fails.\n\n## Turning a rule off or down\n\n```js\nexport default {\n rules: {\n 'seo/json-ld': 'off', // remove its findings entirely\n 'architecture/prop-count': 'info' // keep it, stop it failing the build\n }\n};\n```\n\nMany rules take options, so check whether the finding is a **threshold disagreement** rather than\na defect first. `svelte-vitals explain <rule-id>` prints each option's name, default, bounds, and\nmerge semantics (`integer` replaces, `string-list` appends, `string-map` is spread over).\n\n```js\nexport default {\n rules: {\n 'architecture/prop-count': { options: { max: 12 } }\n }\n};\n```\n\n## Scoping to routes or files (`overrides`)\n\n`rules` applies everywhere; `overrides` applies only where it matches — typically routes that\nare deliberately not public.\n\n```js\nexport default {\n overrides: [\n { files: 'src/routes/(app)/**', rules: { seo: 'off' } },\n { route: '/admin/**', rules: { 'seo/title-presence': 'info' } }\n ]\n};\n```\n\nEach entry needs `rules` (keys are rule ids **or** category names) plus at least one of:\n\n- **`route`** — glob(s) against the route id as reported (`/blog/[slug]`). SvelteKit `(group)`\n segments are **not** in the route id, so use `files` to target a group.\n- **`files`** — glob(s) against the source path.\n\nGlobs are deliberately small: `*` within a segment, `**` across segments, a trailing `/**` also\nmatches the bare prefix. Everything else — including `(`, `)`, `[`, `]` — is literal. Later entries win.\n\n## Precedence\n\nPer field: **CLI flag > config file > built-in default**. One exception — `--rules` and `--ignore`\nare selection, not configuration: `--rules` narrows the run to the ids it names and overrides a\nconfig-file `off` for them, but keeps their declared severity and options; `--ignore` adds `off`\nentries for the ids it names, layered on top of whatever `rules` resolved to, and beats `--rules`\nwhen both name the same rule.\n\n`overrides` has no CLI flag; route policy belongs in a committed file.\n\n## Validation\n\nAn unknown rule id or category, a negative weight, a malformed `overrides` entry, or an invalid\nrule setting is a **hard error (exit `2`)** — a typo must not silently un-gate CI. An unrecognized\n`treatDynamicAs`/`failOn` value, or an unknown top-level key, only warns.\n\n## Related\n\n- `svelte-vitals explain --list` — every rule id\n- `svelte-vitals docs show scoping` — accepting an existing backlog instead of disabling rules"
20
- },
21
- {
22
- name: "monorepo",
23
- title: "Monorepos",
24
- description: "How svelte-vitals picks which SvelteKit app to analyze, why it exits 2 instead of prompting in a non-interactive shell, and how to name the app explicitly.",
25
- body: "# Monorepos\n\n## Naming the app is always safest\n\n```bash\nnpx svelte-vitals@latest apps/web\n```\n\nAn explicit `path` — or running from inside the app directory — skips detection entirely. Prefer\nit in a script, a hook, or an agent's shell.\n\n## What happens without a path\n\nWhen no `path` is given and the current directory is not itself a SvelteKit app, svelte-vitals\nlooks for nearby apps — a directory with `src/routes` **and** either a\n`svelte.config.{js,ts}` or a `package.json` declaring `@sveltejs/kit` (current `sv create`\noutput folds the SvelteKit config into `vite.config.ts` and emits no `svelte.config` file):\n\n| Found | Interactive terminal | Non-interactive (CI, agents, piped output) |\n| ----------- | ----------------------------------- | ---------------------------------------------------------- |\n| exactly one | analyzed, notice on stderr | same — analyzed, notice on stderr |\n| several | single-select prompt | **exit `2`** listing the apps, asking for an explicit path |\n| none | exit `2`, \"not a SvelteKit project\" | same |\n\n**svelte-vitals never prompts when stdout is not a TTY** — it fails fast with the list instead of\nhanging or guessing. Re-run with the path it printed. Cancelling the interactive prompt exits `0`.\n\n## `install` in a monorepo\n\n`svelte-vitals install` splits its targets by where they belong:\n\n- `vite-plugin`, `vite-hooks`, `config-file` write into the **app** directory — they resolve it\n the same way the analyzer does, and `--app <app>` names it explicitly.\n- `cursor-rules` and `ci-workflow` always write at the **current** directory, because the repo\n root is their correct home.\n\n```bash\nnpx svelte-vitals@latest install --client vite-plugin,config-file --app apps/web --yes\n```\n\n`--app` pointing at a directory that is not a SvelteKit app is an error (exit `2`).\n\n## Related\n\n- `svelte-vitals docs show output` — what exit `2` means versus exit `1`"
26
- },
27
- {
28
- name: "output",
29
- title: "Reading the output",
30
- description: "Which reporter to use, how one is auto-selected, what goes to stdout vs stderr, and what each exit code means.",
31
- body: "# Reading the output\n\n## Pick a reporter\n\n`--reporter <fmt>`: `console` (default) · `json` · `agent` · `sarif` · `github` · `html` · `md`.\n\n- **`agent`** — Markdown remediation: each failing finding with its location, a concrete fix\n (with snippet), and an acceptance check. Use this when something will act on the findings.\n- **`json`** — the full structured report (scores per route and site-wide; every finding with\n `fix`, `recommendation`, `docsUrl`). Use it to filter or count.\n- **`console`** — for a human. Grouped and capped; `--verbose` for everything.\n- **`md`** — a compact summary table for a PR comment or job summary (capped at 50 rows).\n- **`sarif`** — SARIF v2.1, for GitHub Code Scanning and other SAST tooling.\n- **`github`** — `::error` / `::warning` workflow annotations.\n- **`html`** — a self-contained report file; `--out-file <path>`, or `--out-file -` for stdout.\n\n## Auto-selection\n\nFirst match wins:\n\n1. an explicit `--reporter <fmt>`\n2. `SVELTE_VITALS_REPORTER=<fmt>`\n3. a recognized AI-agent harness (Claude Code, Cursor, Codex, and others — detection is\n delegated to gunshi's agent profile and evolves with it) or `SVELTE_VITALS_AGENT=1` set → `agent`\n4. `GITHUB_ACTIONS=true` → `github`\n5. otherwise → `console`\n\nInside an agent harness you get `agent` without asking; a one-line override hint goes to stderr.\n\n## stdout vs stderr\n\nThe report goes to **stdout**; every diagnostic — hints, suppression counts, app-detection\nnotices, warnings, errors — to **stderr**. Piping stdout never mixes the two.\n\n`--reporter html` is the exception: it writes a file and prints the path to stderr, unless you\npass `--out-file -`.\n\n## Exit codes\n\n| Code | Meaning |\n| ---- | -------------------------------------------------------------------------- |\n| `0` | no failing findings |\n| `1` | a critical finding is present, or `--fail-on` / `--min-health` was reached |\n| `2` | execution error — not a SvelteKit project, bad flag, unreadable config |\n\n`2` is never a clean result — the run did not happen. `--fail-on <critical|warning|info>` lowers\nthe bar for `1`; `--min-health <0-100>` adds a score gate.\n\n## Related\n\n- `svelte-vitals docs show scoping` — report only what a change introduced\n- `svelte-vitals explain <rule-id>` — one rule's rationale, fix and options"
32
- },
33
- {
34
- name: "scoping",
35
- title: "Scoping findings to a change",
36
- description: "Use --diff, --staged, --baseline and the suppressions file so only what a change introduced is reported, instead of a legacy backlog.",
37
- body: "# Scoping findings to a change\n\nAn existing project usually has a backlog nobody is about to fix. Scope the report rather than\ndisabling rules.\n\n## Scope by file\n\n- **`--diff [ref]`** — only findings in files changed versus `ref` (default `HEAD`).\n- **`--staged`** — only findings in staged files. The pre-commit gate.\n\n```bash\nsvelte-vitals . --diff --reporter agent # after editing: what did I just break?\nsvelte-vitals . --staged # before committing\n```\n\nBoth work when the project is not at the git repo root.\n\n## Scope by finding (`--baseline <ref>`)\n\nReports only findings **not already present** at `ref`. Scopes by finding identity rather than by\nfile, so a pre-existing problem in a file you touched does not fail the gate. No default ref.\n\n```bash\nsvelte-vitals --diff origin/main --baseline origin/main --fail-on warning # PR gate\n```\n\nIt checks `ref` out into a temporary worktree and subtracts those findings. On failure (no git,\nbad ref) it warns and reports everything rather than failing the run.\n\n## Accept a backlog once (`svelte-vitals-suppressions.json`)\n\n`--baseline` handles the transient case. For a persistent ramp, record today's findings once:\n\n```bash\nsvelte-vitals --update-suppressions\ngit add svelte-vitals-suppressions.json\n```\n\nThis analyzes the whole project (`--diff`/`--staged`/`--baseline` are ignored), writes every\npenalized finding, and exits `0` without a report.\n\nThe file then applies automatically on every run, after `--diff`/`--staged` and `--baseline`.\nFixing an accepted finding leaves a **stale** entry, reported on stderr but never failing the run.\n`--no-suppressions` ignores the file for one run.\n\nAn entry covers whatever its rule reports at that route and location, not just the message\nrecorded when written — a different finding from the same rule at the same spot still matches\nand stays suppressed (and not stale).\n\nA malformed suppressions file is a hard error (exit `2`), not a silent skip.\n\n## Suppress one occurrence inline\n\nFor a single finding that is correct by design, put a `svelte-vitals-disable-next-line` comment on\nthe line directly above it:\n\n```html\n<!-- svelte-vitals-disable-next-line security/raw-html -->\n<div>{@html sanitized}</div>\n```\n\nInside `<script>`, use `// svelte-vitals-disable-next-line <rule-id>`. Omit the id to suppress every\nrule on the next line, or list several comma-separated.\n\nOnly findings the report anchors to a **line** can be reached this way — not the `<head>` metadata\nrules, which report what a route never set. A directive in a component silences that finding on\nevery route composing it; for one route, use the suppressions file or `overrides`.\n\n## Which one\n\n| Situation | Use |\n| -------------------------------------- | ---------------------------------------- |\n| Checking an edit you just made | `--diff` |\n| One occurrence that is right as-is | `svelte-vitals-disable-next-line` |\n| Pre-commit hook | `--staged` |\n| PR gate against a base branch | `--diff <base> --baseline <base>` |\n| Adopting on a legacy project, for good | `--update-suppressions`, commit the file |\n\nMatching ignores line numbers in both `--baseline` and the suppressions file, so a second\nviolation of the same rule lower in the same file does not surface as new.\n\n## Related\n\n- `svelte-vitals docs show ci` — the generated PR gate already does the `--diff`/`--baseline` pairing\n- `svelte-vitals docs show config` — turning a rule off for good, when that is genuinely right"
38
- }
39
- ];
40
- //#endregion
41
- //#region src/gunshi/docs.ts
42
- /**
43
- * Frozen error-path text: printed verbatim on `docs`'s non-help exit-2 paths (bare `docs`,
44
- * unknown subcommand); `docs --help` builds a separate, generated OPTIONS block around this
45
- * prose instead — see `buildDocsHelpText` below.
46
- */
47
- const DOCS_HELP = `svelte-vitals docs — read the bundled guides without leaving the terminal
48
-
49
- Usage:
50
- svelte-vitals docs list [--json] List every topic with a one-line description
51
- svelte-vitals docs show <name> Print a topic
52
-
53
- Options:
54
- --json Machine-readable output (list only)
55
- -h, --help Show this help
56
-
57
- The topics ship inside the CLI, so they always match the version you are running and need no
58
- network. The full docs site is at https://oekazuma.github.io/svelte-vitals.
59
-
60
- \`docs\` is a subcommand, so it wins over a directory of the same name: to analyze a directory
61
- called \`docs\`, write \`svelte-vitals ./docs\`.`;
62
- /** Mirrors `knownRuleIds()`. */
63
- function knownTopicNames() {
64
- return EMBEDDED_DOCS.map((d) => d.name).join(", ");
65
- }
66
- function renderList() {
67
- const width = Math.max(...EMBEDDED_DOCS.map((d) => d.name.length));
68
- return [
69
- "Topics (read one with `svelte-vitals docs show <name>`):",
70
- "",
71
- ...EMBEDDED_DOCS.map((d) => ` ${d.name.padEnd(width)} ${d.description}`),
72
- "",
73
- "Rule-level detail is a separate command: `svelte-vitals explain --list`."
74
- ].join("\n");
75
- }
76
- /** docs declares no value-carrying flags today — see guard.ts's own doc comment for why the list is still passed explicitly. */
77
- const BOOLEAN_FLAGS = ["json", "help"];
78
- const HELP_ARG = { help: {
79
- type: "boolean",
80
- short: "h",
81
- description: "Show this help"
82
- } };
83
- const JSON_ARG = { json: {
84
- type: "boolean",
85
- description: "Machine-readable output (list only)"
86
- } };
87
- /** Family-wide, not per-subcommand — see guard.ts's `stripUnknownFlags` doc comment: the legacy
88
- * runner parses `--json`/`-h`/`--help` in one flat pass, so `show` (which never reads `--json`
89
- * itself) still needs it declared below to keep gunshi's own per-command resolution from
90
- * mistaking it for an unknown flag and swallowing the positional after it. */
91
- const KNOWN_LONG_FLAGS = new Set(BOOLEAN_FLAGS);
92
- const KNOWN_SHORT_FLAGS = /* @__PURE__ */ new Set(["h"]);
93
- /** Exported for gunshi/complete.ts — the completion tree's docs args mirror these, never a second copy. */
94
- const DOCS_ROOT_ARGS = {
95
- ...JSON_ARG,
96
- ...HELP_ARG
97
- };
98
- const DOCS_LIST_ARGS = {
99
- ...JSON_ARG,
100
- ...HELP_ARG
101
- };
102
- const DOCS_SHOW_ARGS = {
103
- name: {
104
- type: "positional",
105
- required: false
106
- },
107
- ...JSON_ARG,
108
- ...HELP_ARG
109
- };
110
- /**
111
- * Hybrid `docs --help` text: hand-written header/usage/footer preserved verbatim from `DOCS_HELP`
112
- * (the frozen text `gunshi/docs.ts`'s error paths still print as-is), OPTIONS generated from this
113
- * command's own `args` — same technique as `gunshi/analyze.ts`'s `buildHelpText`. `generate()`
114
- * always injects a `-v, --version` line (gunshi's hardcoded internal `global()` plugin — confirmed
115
- * empirically, undocumented) even though `docs` has no working `--version`; `stripAutoVersionLine`
116
- * drops it so help never advertises a flag that doesn't exist. ja-localized when `locale` is 'ja'
117
- * (`docs/superpowers/specs/2026-08-11-cli-ja-help-design.md`).
118
- */
119
- async function buildDocsHelpText(rootCommand, locale) {
120
- const ja = locale === "ja" ? await import("./ja-BDLP3KLG.js").then((n) => n.n) : void 0;
121
- const optionsSection = stripAutoVersionLine(await localizedOptionsSection(rootCommand, "svelte-vitals docs", locale, ja?.JA_ARG_DESCRIPTIONS.docs ?? {}));
122
- if (locale === "ja") return ja.docsHelpJa(optionsSection);
123
- return `svelte-vitals docs — read the bundled guides without leaving the terminal
124
-
125
- Usage:
126
- svelte-vitals docs list [--json] List every topic with a one-line description
127
- svelte-vitals docs show <name> Print a topic
128
-
129
- ${optionsSection}
130
-
131
- The topics ship inside the CLI, so they always match the version you are running and need no
132
- network. The full docs site is at https://oekazuma.github.io/svelte-vitals.
133
-
134
- \`docs\` is a subcommand, so it wins over a directory of the same name: to analyze a directory
135
- called \`docs\`, write \`svelte-vitals ./docs\`.`;
136
- }
137
- /**
138
- * gunshi/bone port of `docs/cli.ts`'s dispatch (design doc: Phase 2a). `docs` is passed as its
139
- * own `cli()` entry (not nested under a shared root) so an unmatched sub-command token reaches
140
- * this file's own root `run()` via `fallbackToEntry` directly — reproducing
141
- * `unknown docs subcommand '<x>'` verbatim without having to catch and re-render gunshi's own
142
- * `CommandNotFoundError`, which is what nesting `docs` under a shared entry would require (that
143
- * error only carries `commandPath`/`candidates` at the *intermediate* level, not the entry level;
144
- * see the exit-code closure note below for the other half of why this shape was chosen).
145
- *
146
- * Exit codes have no return-value channel in gunshi (`executeCommand` discards a non-string
147
- * runner return) — `exitCode` is the closure every `run` below sets instead. Commands are built
148
- * fresh on every call (not module-level singletons) so concurrent invocations can't race across
149
- * `cli()`'s internal `await`s and clobber each other's closure.
150
- */
151
- async function runDocsCliGunshi(args, io = consoleIO, locale = "en") {
152
- const { head, tail } = splitAtTerminator(args);
153
- const argvForGunshi = stripUnknownFlags(guardArgs(head, [], BOOLEAN_FLAGS).argv, KNOWN_LONG_FLAGS, KNOWN_SHORT_FLAGS);
154
- const promoted = !argvForGunshi.some((t) => !t.startsWith("-")) && (tail[0] === "list" || tail[0] === "show");
155
- const finalArgv = promoted ? [...argvForGunshi, tail[0]] : argvForGunshi;
156
- const tailRest = promoted ? tail.slice(1) : tail;
157
- let exitCode = 0;
158
- const listCommand = define({
159
- name: "list",
160
- args: DOCS_LIST_ARGS,
161
- run: async (ctx) => {
162
- if (ctx.values.help) {
163
- io.log(await buildDocsHelpText(rootCommand, locale));
164
- exitCode = 0;
165
- return;
166
- }
167
- if ([...ctx.positionals.slice(ctx.commandPath.length), ...tailRest].length > 0) {
168
- io.errorLog("svelte-vitals: docs list takes no arguments; use `docs show <name>` to read one.");
169
- exitCode = 2;
170
- return;
171
- }
172
- io.log(ctx.values.json ? JSON.stringify(EMBEDDED_DOCS.map((d) => ({
173
- name: d.name,
174
- title: d.title,
175
- description: d.description
176
- })), null, 2) : renderList());
177
- exitCode = 0;
178
- }
179
- });
180
- const showCommand = define({
181
- name: "show",
182
- args: DOCS_SHOW_ARGS,
183
- run: async (ctx) => {
184
- if (ctx.values.help) {
185
- io.log(await buildDocsHelpText(rootCommand, locale));
186
- exitCode = 0;
187
- return;
188
- }
189
- const rest = [...ctx.positionals.slice(ctx.commandPath.length), ...tailRest];
190
- if (rest.length !== 1) {
191
- io.errorLog(rest.length === 0 ? "svelte-vitals: docs show needs a topic name, e.g. `svelte-vitals docs show config`." : "svelte-vitals: docs show takes one topic at a time.");
192
- io.errorLog(`svelte-vitals: known topics: ${knownTopicNames()}.`);
193
- exitCode = 2;
194
- return;
195
- }
196
- const name = rest[0];
197
- const doc = EMBEDDED_DOCS.find((d) => d.name === name);
198
- if (!doc) {
199
- const ruleId = name.startsWith("rules/") ? name.slice(6) : name;
200
- if (knownRuleIds().includes(ruleId)) {
201
- io.errorLog(`svelte-vitals: '${ruleId}' is a rule, not a docs topic.`);
202
- io.errorLog(`svelte-vitals: rule detail: \`svelte-vitals explain ${ruleId}\`; web: ${docsUrlFor(ruleId)}`);
203
- exitCode = 2;
204
- return;
205
- }
206
- io.errorLog(`svelte-vitals: unknown docs topic '${name}'.`);
207
- io.errorLog(`svelte-vitals: known topics: ${knownTopicNames()}.`);
208
- exitCode = 2;
209
- return;
210
- }
211
- io.log(doc.body);
212
- exitCode = 0;
213
- }
214
- });
215
- const rootCommand = define({
216
- name: "docs",
217
- args: DOCS_ROOT_ARGS,
218
- subCommands: {
219
- list: listCommand,
220
- show: showCommand
221
- },
222
- run: async (ctx) => {
223
- if (ctx.values.help) {
224
- io.log(await buildDocsHelpText(rootCommand, locale));
225
- exitCode = 0;
226
- return;
227
- }
228
- const [sub] = [...ctx.positionals, ...tailRest];
229
- if (sub === void 0) {
230
- io.errorLog(DOCS_HELP);
231
- exitCode = 2;
232
- return;
233
- }
234
- io.errorLog(`svelte-vitals: unknown docs subcommand '${sub}'; expected list|show.`);
235
- const hint = suggestClosest(sub, ["list", "show"]);
236
- if (hint) io.errorLog(`svelte-vitals: did you mean \`svelte-vitals docs ${hint}\`?`);
237
- io.errorLog(DOCS_HELP);
238
- exitCode = 2;
239
- }
240
- });
241
- await cli(finalArgv, rootCommand, {
242
- name: "svelte-vitals docs",
243
- subCommands: {
244
- list: listCommand,
245
- show: showCommand
246
- },
247
- fallbackToEntry: true,
248
- usageSilent: true
249
- });
250
- return exitCode;
251
- }
252
- //#endregion
253
- export { DOCS_LIST_ARGS, DOCS_ROOT_ARGS, DOCS_SHOW_ARGS, runDocsCliGunshi };