From d9cb3ba27b502ef941064bfa5519438174129796 Mon Sep 17 00:00:00 2001 From: oekazuma Date: Wed, 8 Jul 2026 14:41:50 +0900 Subject: [PATCH] feat(cli): add --category filter and --score output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --category to restrict analysis to rules in the given categories (intersects with --rules/--ignore/config-file selection), and --score to print only the combined Health score to stdout, suppressing reporter output — useful for shell prompts/scripts and for gating with --min-health without parsing JSON. --- .changeset/cli-category-score-flags.md | 5 + docs/src/content/docs/guides/cli.md | 22 +++++ docs/src/content/docs/ja/guides/cli.md | 22 +++++ packages/cli/src/bin.ts | 7 +- packages/cli/src/index.ts | 127 ++++++++++++++----------- packages/cli/src/resolve-args.ts | 44 +++++++++ packages/cli/test/resolve-args.test.ts | 52 +++++++++- packages/cli/test/run.test.ts | 47 +++++++++ 8 files changed, 266 insertions(+), 60 deletions(-) create mode 100644 .changeset/cli-category-score-flags.md diff --git a/.changeset/cli-category-score-flags.md b/.changeset/cli-category-score-flags.md new file mode 100644 index 000000000..90caf2e6b --- /dev/null +++ b/.changeset/cli-category-score-flags.md @@ -0,0 +1,5 @@ +--- +'svelte-vitals': minor +--- + +Add `--category ` to restrict analysis to rules in the given categories (intersects with `--rules`/`--ignore`/config-file selection), and `--score` to print only the combined Health score to stdout, suppressing reporter output — handy for shell prompts or scripts that just want the number, especially combined with `--min-health` for gating. diff --git a/docs/src/content/docs/guides/cli.md b/docs/src/content/docs/guides/cli.md index 94ca1cd8e..e4cfb835b 100644 --- a/docs/src/content/docs/guides/cli.md +++ b/docs/src/content/docs/guides/cli.md @@ -71,6 +71,17 @@ svelte-vitals --min-health 80 See [Health report](/svelte-vitals/guides/health-report/) for how the score is calculated. +### `--score` + +Print only the combined Health score (an integer) to stdout, suppressing all other reporter output. Useful in shell prompts or scripts that just want the number without parsing JSON. + +```bash +svelte-vitals --score +svelte-vitals --score --min-health 80 # gate on the score; exit code still reflects pass/fail +``` + +Combining `--score` with `--reporter`/`--json` is not an error, but the reporter output is suppressed and a warning is printed to stderr. The exit code is unaffected by `--score` — it still reflects `--fail-on` and `--min-health` as usual. + ### `--route ` Only analyze routes whose path matches the given glob pattern. @@ -131,6 +142,17 @@ Disable the specified rules. Accepts a comma-separated list of rule IDs. svelte-vitals --ignore PERF001 ``` +### `--category ` + +Restrict analysis to rules in the given categories. Accepts a comma-separated list, matched case-insensitively: `seo`, `performance`, `correctness`, `security`, `architecture`. + +```bash +svelte-vitals --category seo +svelte-vitals --category seo,performance +``` + +`--category` intersects with `--rules`/`--ignore`/config-file rule selection — a rule only runs if it survives both. Narrowing to a subset of categories also narrows the [Health score](/svelte-vitals/guides/health-report/): the combined score becomes the weighted average of only the categories that have findings, so it isn't directly comparable to an unfiltered run. An unknown category is an error (exit `2`). + ### `--weights ` Per-category weight overrides for the combined [Health score](/svelte-vitals/guides/health-report/). Accepts comma-separated `category=number` pairs; categories are matched case-insensitively. Unlisted categories default to weight `1`. diff --git a/docs/src/content/docs/ja/guides/cli.md b/docs/src/content/docs/ja/guides/cli.md index 044b643f3..c99569825 100644 --- a/docs/src/content/docs/ja/guides/cli.md +++ b/docs/src/content/docs/ja/guides/cli.md @@ -71,6 +71,17 @@ svelte-vitals --min-health 80 スコアの計算方法については [Health レポート](/svelte-vitals/ja/guides/health-report/) を参照してください。 +### `--score` + +組み合わせた Health スコア(整数)のみを stdout に出力し、他のレポーター出力をすべて抑制します。数値をパースせずにシェルプロンプトやスクリプトから使いたい場合に便利です。 + +```bash +svelte-vitals --score +svelte-vitals --score --min-health 80 # スコアでゲートする。終了コードは通常どおり pass/fail を反映 +``` + +`--score` を `--reporter`/`--json` と組み合わせてもエラーにはなりませんが、レポーター出力は抑制され、stderr に警告が表示されます。終了コードは `--score` の影響を受けず、`--fail-on` と `--min-health` を通常どおり反映します。 + ### `--route ` 指定した glob パターンに一致するルートのみを分析します。 @@ -131,6 +142,17 @@ svelte-vitals --rules SEO001,SEO002 svelte-vitals --ignore PERF001 ``` +### `--category ` + +指定したカテゴリのルールのみに分析を限定します。カンマ区切りのリストを受け付け、大文字小文字は区別しません: `seo`、`performance`、`correctness`、`security`、`architecture`。 + +```bash +svelte-vitals --category seo +svelte-vitals --category seo,performance +``` + +`--category` は `--rules`/`--ignore`/設定ファイルのルール選択と積集合になります — ルールは両方を通過した場合のみ実行されます。カテゴリを絞り込むと [Health スコア](/svelte-vitals/ja/guides/health-report/) も絞り込まれます。組み合わせたスコアは、検出結果が存在するカテゴリのみの加重平均になるため、フィルタなしの実行結果と直接比較することはできません。未知のカテゴリを指定するとエラーになります(終了コード `2`)。 + ### `--weights ` 組み合わせた [Health スコア](/svelte-vitals/ja/guides/health-report/) のカテゴリごとの重み上書きです。カンマ区切りの `category=number` ペアを受け付けます。カテゴリ名は大文字小文字を区別しません。指定しなかったカテゴリはデフォルトの重み `1` になります。 diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 2eb5242ff..5efcdb046 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -29,7 +29,9 @@ Options: --min-health <0-100> Fail (exit 1) when the combined Health score is below this value --rules Comma-separated rule ids to enable (all others disabled) --ignore Comma-separated rule ids to disable + --category Comma-separated categories to analyze: seo | performance | correctness | security | architecture --weights Per-category Health weight overrides, e.g. seo=2,performance=1 (unlisted categories default to 1) + --score Print only the combined Health score (works with --min-health for gating) --no-color Disable ANSI color in console output -h, --help Show this help -v, --version Show version @@ -57,7 +59,7 @@ async function main(): Promise { const argv = mri(process.argv.slice(2), { alias: { h: 'help', v: 'version' }, - boolean: ['by-route', 'json', 'fail-on-warning', 'staged', 'no-color'], + boolean: ['by-route', 'json', 'fail-on-warning', 'staged', 'no-color', 'score'], string: [ 'meta-components', 'treat-dynamic-as', @@ -70,7 +72,8 @@ async function main(): Promise { 'out-file', 'diff', 'baseline', - 'weights' + 'weights', + 'category' ] }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2150e83d1..b0028f733 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -48,10 +48,14 @@ export interface RunOptions { rules?: Record; /** Per-category weights for the combined Health score (flag > config file > default 1 each). */ weights?: Partial>; + /** Restrict analysis to rules in these categories (applied after rules/ignore selection). */ + categories?: Category[]; /** Override process.env for reporter auto-detection (mainly useful in tests). */ env?: NodeJS.ProcessEnv; /** Fail (exit 1) when the combined Health score is below this value (0–100). */ minHealth?: number; + /** Print only the combined Health score (integer) to stdout. */ + score?: boolean; /** Output path for --reporter html (default 'svelte-vitals-report.html'; '-' = stdout). */ outFile?: string; /** Injected file writer for --reporter html (defaults to node:fs writeFileSync). Mainly for tests. */ @@ -117,6 +121,8 @@ export interface AnalyzeOptions { rules?: Record; /** Per-category weights for the combined Health score (flag > config file > default 1 each). */ weights?: Partial>; + /** Restrict analysis to rules in these categories (applied after rules/ignore selection). */ + categories?: Category[]; } export interface AnalyzeResult { @@ -166,7 +172,8 @@ export async function analyzeProject(opts: AnalyzeOptions = {}): Promise opts.categories!.includes(r.category)) : selected; const results = applyRuleSeverities( await runRules(rules, { heads, images, headings, components, project, config }), config @@ -190,13 +197,15 @@ export async function run(opts: RunOptions = {}): Promise { const env = opts.env ?? process.env; const reporter = resolveReporter(opts.reporter, env); const spinner = startSpinner('Analyzing…', { - enabled: spinnerEnabled({ - reporter, - rawReporter: opts.reporter, - stderrIsTTY: opts.stderrIsTTY ?? !!process.stderr.isTTY, - env, - noColorFlag: opts.noColor - }) + enabled: + !opts.score && + spinnerEnabled({ + reporter, + rawReporter: opts.reporter, + stderrIsTTY: opts.stderrIsTTY ?? !!process.stderr.isTTY, + env, + noColorFlag: opts.noColor + }) }); let analysis: AnalyzeResult; @@ -208,7 +217,8 @@ export async function run(opts: RunOptions = {}): Promise { route: opts.route, failOn: opts.failOn, rules: opts.rules, - weights: opts.weights + weights: opts.weights, + categories: opts.categories }); } catch (err) { spinner.stop(); @@ -258,7 +268,8 @@ export async function run(opts: RunOptions = {}): Promise { route: opts.route, failOn: opts.failOn, rules: opts.rules, - weights: opts.weights + weights: opts.weights, + categories: opts.categories }); results = filterToNewFindings(results, base.results); } catch { @@ -269,54 +280,58 @@ export async function run(opts: RunOptions = {}): Promise { } } - if (reporter === 'agent' && isAutoDetectedAgent(opts.reporter, env)) { - errorLog( - 'svelte-vitals: agent reporter auto-selected (AI-agent env detected); override with --reporter console|json.' - ); - } - if (reporter === 'github' && isAutoDetectedGithub(opts.reporter, env)) { - errorLog( - 'svelte-vitals: github reporter auto-selected (GitHub Actions detected); override with --reporter console|json|sarif.' - ); - } - if (reporter === 'json') { - log(formatJsonReport(results, config, { version })); - } else if (reporter === 'agent') { - log(formatAgentReport(results, config)); - } else if (reporter === 'sarif') { - log(formatSarifReport(results, config, { version })); - } else if (reporter === 'github') { - // The github reporter returns '' when there are no findings; skip logging so - // a clean run emits no stray blank line into the Actions log. - const output = formatGithubReport(results, config); - if (output) log(output); - } else if (reporter === 'html') { - const html = formatHtmlReport(results, config, { version }); - if (opts.outFile === '-') { - log(html); + if (opts.score) { + log(String(computeHealth(results, config).health)); + } else { + if (reporter === 'agent' && isAutoDetectedAgent(opts.reporter, env)) { + errorLog( + 'svelte-vitals: agent reporter auto-selected (AI-agent env detected); override with --reporter console|json.' + ); + } + if (reporter === 'github' && isAutoDetectedGithub(opts.reporter, env)) { + errorLog( + 'svelte-vitals: github reporter auto-selected (GitHub Actions detected); override with --reporter console|json|sarif.' + ); + } + if (reporter === 'json') { + log(formatJsonReport(results, config, { version })); + } else if (reporter === 'agent') { + log(formatAgentReport(results, config)); + } else if (reporter === 'sarif') { + log(formatSarifReport(results, config, { version })); + } else if (reporter === 'github') { + // The github reporter returns '' when there are no findings; skip logging so + // a clean run emits no stray blank line into the Actions log. + const output = formatGithubReport(results, config); + if (output) log(output); + } else if (reporter === 'html') { + const html = formatHtmlReport(results, config, { version }); + if (opts.outFile === '-') { + log(html); + } else { + // `||` (not `??`) so an empty --out-file (mri yields '' for a value-less + // flag) falls back to the default instead of writing to an empty path. + const path = opts.outFile || 'svelte-vitals-report.html'; + const write = + opts.writeFile ?? + ((p: string, c: string) => { + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, c); + }); + write(path, html); + errorLog(`svelte-vitals: wrote report to ${path}`); + } + } else if (reporter === 'md') { + log(formatMarkdownReport(results, config, { version })); } else { - // `||` (not `??`) so an empty --out-file (mri yields '' for a value-less - // flag) falls back to the default instead of writing to an empty path. - const path = opts.outFile || 'svelte-vitals-report.html'; - const write = - opts.writeFile ?? - ((p: string, c: string) => { - mkdirSync(dirname(p), { recursive: true }); - writeFileSync(p, c); - }); - write(path, html); - errorLog(`svelte-vitals: wrote report to ${path}`); + const colorOn = colorEnabled({ + reporter, + isTTY: opts.stdoutIsTTY ?? !!process.stdout.isTTY, + env, + noColorFlag: opts.noColor + }); + log(formatConsoleReport(results, config, { byRoute: opts.byRoute ?? false, palette: paletteFor(colorOn) })); } - } else if (reporter === 'md') { - log(formatMarkdownReport(results, config, { version })); - } else { - const colorOn = colorEnabled({ - reporter, - isTTY: opts.stdoutIsTTY ?? !!process.stdout.isTTY, - env, - noColorFlag: opts.noColor - }); - log(formatConsoleReport(results, config, { byRoute: opts.byRoute ?? false, palette: paletteFor(colorOn) })); } const summary = summarize(results, config); const failBySeverity = hasFailureAtOrAbove(summary, config.failOn); diff --git a/packages/cli/src/resolve-args.ts b/packages/cli/src/resolve-args.ts index 5ae533301..e2ad12829 100644 --- a/packages/cli/src/resolve-args.ts +++ b/packages/cli/src/resolve-args.ts @@ -73,6 +73,42 @@ function parseWeights(raw: unknown, errors: string[]): Partial s.trim().toLowerCase()) + .filter(Boolean)) { + if (!CATEGORIES.includes(entry as Category)) { + unknownCategories.push(entry); + continue; + } + if (!categories.includes(entry as Category)) categories.push(entry as Category); + } + + if (unknownCategories.length > 0) { + errors.push(`svelte-vitals: unknown category(ies) in --category: ${unknownCategories.join(', ')}`); + errors.push(`Known categories: ${CATEGORIES.join(', ')}`); + } + + if (unknownCategories.length === 0 && categories.length === 0) { + errors.push('svelte-vitals: --category was passed but contains no categories.'); + } + + return categories; +} + /** Result of normalizing parsed argv: the `run` options, plus any diagnostics to print. */ export interface ResolvedArgs { /** Options to pass to `run`, or `null` when a fatal (exit-2) error was found. */ @@ -166,6 +202,12 @@ export function resolveArgs(argv: mri.Argv): ResolvedArgs { const failOn = argv['fail-on-warning'] ? 'warning' : failOnValid ? failOnRaw : undefined; const weights = parseWeights(argv.weights, errors); + const categories = parseCategories(argv.category, errors); + + const score = Boolean(argv.score); + if (score && (argv.json || typeof argv.reporter === 'string')) { + warnings.push('svelte-vitals: --score overrides --reporter; reporter output suppressed.'); + } // `buildRulesConfig` returns `{}` when neither --rules nor --ignore was passed; // normalize that to `undefined` so it doesn't clobber a config file's `rules` @@ -188,6 +230,8 @@ export function resolveArgs(argv: mri.Argv): ResolvedArgs { failOn, rules, ...(weights !== undefined ? { weights } : {}), + ...(categories !== undefined ? { categories } : {}), + ...(score ? { score } : {}), ...(diffBase !== undefined ? { diffBase } : {}), ...(staged ? { staged } : {}), ...(baselineRef !== undefined ? { baseline: baselineRef } : {}) diff --git a/packages/cli/test/resolve-args.test.ts b/packages/cli/test/resolve-args.test.ts index 1f3782f1d..fcd22739d 100644 --- a/packages/cli/test/resolve-args.test.ts +++ b/packages/cli/test/resolve-args.test.ts @@ -6,7 +6,7 @@ import { resolveArgs } from '../src/resolve-args.js'; function resolve(...args: string[]) { const argv = mri(args, { alias: { h: 'help', v: 'version' }, - boolean: ['by-route', 'json', 'fail-on-warning', 'staged'], + boolean: ['by-route', 'json', 'fail-on-warning', 'staged', 'score'], string: [ 'meta-components', 'treat-dynamic-as', @@ -17,7 +17,8 @@ function resolve(...args: string[]) { 'ignore', 'diff', 'baseline', - 'weights' + 'weights', + 'category' ] }); return resolveArgs(argv); @@ -148,4 +149,51 @@ describe('resolveArgs', () => { expect(options).toBeNull(); expect(errors.some((e) => e.includes('--weights was passed but contains no category=number pairs'))).toBe(true); }); + + it('parses --category into a normalized, de-duplicated list, mixing case', () => { + const { options, errors } = resolve('--category', 'seo,SECURITY,seo'); + expect(errors).toEqual([]); + expect(options?.categories).toEqual(['seo', 'security']); + }); + + it('omits categories when --category is not passed', () => { + const { options } = resolve('--json'); + expect(options?.categories).toBeUndefined(); + }); + + it('reports an unknown category in --category as a fatal error', () => { + const { options, errors } = resolve('--category', 'bogus'); + expect(options).toBeNull(); + expect(errors.some((e) => e.includes('unknown category(ies) in --category'))).toBe(true); + expect(errors.some((e) => e.includes('Known categories'))).toBe(true); + }); + + it('reports --category with no categories (e.g. a bare comma) as a fatal error', () => { + const { options, errors } = resolve('--category', ','); + expect(options).toBeNull(); + expect(errors.some((e) => e.includes('--category was passed but contains no categories'))).toBe(true); + }); + + it('sets score:true when --score is passed', () => { + const { options, warnings } = resolve('--score'); + expect(options?.score).toBe(true); + expect(warnings).toEqual([]); + }); + + it('omits score when --score is not passed', () => { + const { options } = resolve('--json'); + expect(options?.score).toBeUndefined(); + }); + + it('warns when --score is combined with --json', () => { + const { options, warnings } = resolve('--score', '--json'); + expect(options?.score).toBe(true); + expect(warnings.some((w) => w.includes('--score overrides --reporter'))).toBe(true); + }); + + it('warns when --score is combined with --reporter', () => { + const { options, warnings } = resolve('--score', '--reporter', 'md'); + expect(options?.score).toBe(true); + expect(warnings.some((w) => w.includes('--score overrides --reporter'))).toBe(true); + }); }); diff --git a/packages/cli/test/run.test.ts b/packages/cli/test/run.test.ts index c75d3fa9d..2dd574290 100644 --- a/packages/cli/test/run.test.ts +++ b/packages/cli/test/run.test.ts @@ -263,3 +263,50 @@ describe('run() config file (design doc 2026-07-05-config-file-design.md)', () = expect(errText).not.toContain('svelte-vitals: svelte-vitals:'); }); }); + +describe('run() --category', () => { + it('restricts findings to the given category, excluding other categories', async () => { + const cap = capture(); + await run({ + cwd: fixtureDir, + reporter: 'json', + log: cap.log, + errorLog: cap.errorLog, + categories: ['seo'], + env: CLEAN_ENV + }); + const json = JSON.parse(cap.out.join('\n')); + expect(Object.keys(json.categories)).toEqual(['seo']); + const allIds: string[] = []; + for (const r of json.routes) for (const i of r.issues) allIds.push(i.id); + expect(allIds.every((id: string) => id.startsWith('SEO'))).toBe(true); + // PERF001 (missing dimensions) is present without a category filter (see + // 'run() performance rules' above); it must not survive a seo-only filter. + expect(allIds).not.toContain('PERF001'); + }); +}); + +describe('run() --score', () => { + it('prints only the combined Health score as a single line', async () => { + const cap = capture(); + const code = await run({ cwd: fixtureDir, log: cap.log, errorLog: cap.errorLog, score: true, env: CLEAN_ENV }); + expect(cap.out).toHaveLength(1); + expect(cap.out[0]).toMatch(/^\d+$/); + expect(code).toBe(1); // the fixture still has a critical SEO finding, so exit stays 1 + }); + + it('gates on --min-health while suppressing reporter output (basic-project cannot reach 100)', async () => { + const cap = capture(); + const code = await run({ + cwd: fixtureDir, + log: cap.log, + errorLog: cap.errorLog, + score: true, + minHealth: 100, + env: CLEAN_ENV + }); + expect(code).toBe(1); + expect(cap.out).toHaveLength(1); + expect(cap.out[0]).toMatch(/^\d+$/); + }); +});