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/cli-category-score-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte-vitals': minor
---

Add `--category <cats>` 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.
22 changes: 22 additions & 0 deletions docs/src/content/docs/guides/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <glob>`

Only analyze routes whose path matches the given glob pattern.
Expand Down Expand Up @@ -131,6 +142,17 @@ Disable the specified rules. Accepts a comma-separated list of rule IDs.
svelte-vitals --ignore PERF001
```

### `--category <cats>`

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 <pairs>`

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`.
Expand Down
22 changes: 22 additions & 0 deletions docs/src/content/docs/ja/guides/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>`

指定した glob パターンに一致するルートのみを分析します。
Expand Down Expand Up @@ -131,6 +142,17 @@ svelte-vitals --rules SEO001,SEO002
svelte-vitals --ignore PERF001
```

### `--category <cats>`

指定したカテゴリのルールのみに分析を限定します。カンマ区切りのリストを受け付け、大文字小文字は区別しません: `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 <pairs>`

組み合わせた [Health スコア](/svelte-vitals/ja/guides/health-report/) のカテゴリごとの重み上書きです。カンマ区切りの `category=number` ペアを受け付けます。カテゴリ名は大文字小文字を区別しません。指定しなかったカテゴリはデフォルトの重み `1` になります。
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ Options:
--min-health <0-100> Fail (exit 1) when the combined Health score is below this value
--rules <ids> Comma-separated rule ids to enable (all others disabled)
--ignore <ids> Comma-separated rule ids to disable
--category <cats> Comma-separated categories to analyze: seo | performance | correctness | security | architecture
--weights <pairs> 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
Expand Down Expand Up @@ -57,7 +59,7 @@ async function main(): Promise<void> {

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',
Expand All @@ -70,7 +72,8 @@ async function main(): Promise<void> {
'out-file',
'diff',
'baseline',
'weights'
'weights',
'category'
]
});

Expand Down
127 changes: 71 additions & 56 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,14 @@ export interface RunOptions {
rules?: Record<string, RuleSetting>;
/** Per-category weights for the combined Health score (flag > config file > default 1 each). */
weights?: Partial<Record<Category, number>>;
/** 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. */
Expand Down Expand Up @@ -117,6 +121,8 @@ export interface AnalyzeOptions {
rules?: Record<string, RuleSetting>;
/** Per-category weights for the combined Health score (flag > config file > default 1 each). */
weights?: Partial<Record<Category, number>>;
/** Restrict analysis to rules in these categories (applied after rules/ignore selection). */
categories?: Category[];
}

export interface AnalyzeResult {
Expand Down Expand Up @@ -166,7 +172,8 @@ export async function analyzeProject(opts: AnalyzeOptions = {}): Promise<Analyze
// Component (Correctness) facts are file-scoped with no route attribution yet, so a
// route-filtered run skips them rather than reporting unrelated components (#68 review).
const components = opts.route ? [] : await collectComponentFacts(rt, cwd);
const rules = selectRules(allRules, config);
const selected = selectRules(allRules, config);
const rules = opts.categories ? selected.filter((r) => opts.categories!.includes(r.category)) : selected;
const results = applyRuleSeverities(
await runRules(rules, { heads, images, headings, components, project, config }),
config
Expand All @@ -190,13 +197,15 @@ export async function run(opts: RunOptions = {}): Promise<number> {
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;
Expand All @@ -208,7 +217,8 @@ export async function run(opts: RunOptions = {}): Promise<number> {
route: opts.route,
failOn: opts.failOn,
rules: opts.rules,
weights: opts.weights
weights: opts.weights,
categories: opts.categories
});
} catch (err) {
spinner.stop();
Expand Down Expand Up @@ -258,7 +268,8 @@ export async function run(opts: RunOptions = {}): Promise<number> {
route: opts.route,
failOn: opts.failOn,
rules: opts.rules,
weights: opts.weights
weights: opts.weights,
categories: opts.categories
});
results = filterToNewFindings(results, base.results);
} catch {
Expand All @@ -269,54 +280,58 @@ export async function run(opts: RunOptions = {}): Promise<number> {
}
}

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);
Expand Down
44 changes: 44 additions & 0 deletions packages/cli/src/resolve-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,42 @@ function parseWeights(raw: unknown, errors: string[]): Partial<Record<Category,
return weights;
}

/**
* Parse `--category seo,SECURITY` into a de-duplicated list of categories
* (mirrors `parseWeights`'s validation shape). Categories are matched
* case-insensitively and normalized to lowercase; unknown categories push a
* fatal error. Returns `undefined` when `raw` is not a non-empty string (flag
* not passed).
*/
function parseCategories(raw: unknown, errors: string[]): Category[] | undefined {
if (typeof raw !== 'string' || raw.trim() === '') return undefined;

const categories: Category[] = [];
const unknownCategories: string[] = [];

for (const entry of raw
.split(',')
.map((s) => 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. */
Expand Down Expand Up @@ -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`
Expand All @@ -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 } : {})
Expand Down
Loading