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
11 changes: 11 additions & 0 deletions .changeset/dashboard-failed-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@svelte-vitals/core': minor
'svelte-vitals': minor
'@svelte-vitals/vite': patch
---

`@svelte-vitals/core` now exports `formatFailedRuleWarning`, the "rule … failed and was skipped" message formatter shared by the CLI, build mode, and (now) the dev dashboard.

`svelte-vitals`'s `analyzeProject` now also returns `failedRuleIds`, the ids of rules that crashed during the run (already folded into its returned `config` via `withFailedRulesOff`, exposed separately so a caller with its own base config can apply the same correction without adopting `analyzeProject`'s config).

The dev dashboard now scores a crashed rule as not-run (matching the CLI and build mode) instead of silently inflating Health, without disturbing plugin-option `weights`/`overrides`; plugin warnings strip terminal escape sequences.
12 changes: 7 additions & 5 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
applyOverrides,
settingSeverity,
withFailedRulesOff,
formatFailedRuleWarning,
terminalSafe,
type Severity,
type RuleSetting,
Expand Down Expand Up @@ -195,6 +196,8 @@ export interface AnalyzeResult {
ruleIds: string[];
/** Per-rule, per-declaration counts of places examined, unfiltered by `--diff`/`--baseline`/suppressions. */
examined: Record<string, Record<string, number>>;
/** Ids of rules `runRules` caught throwing — already folded into `config` via `withFailedRulesOff`; exposed separately so a caller with its own base config (the vite dev dashboard) can apply the same correction without adopting this call's `config`. */
failedRuleIds: string[];
/** Non-fatal issues surfaced during analysis: config-file problems (unknown top-level keys, invalid enum values), version-floor notices, `--rules`/overrides conflicts, and skipped-file notices. Empty when none apply. */
warnings: string[];
/**
Expand Down Expand Up @@ -268,7 +271,7 @@ function skippedFileWarnings(facts: { file: string; parseFailed?: true }[]): str
* as clean. Message capped to its first line so a multi-line stack trace can't flood the terminal.
*/
function failedRuleWarnings(failedRules: { id: string; message: string }[]): string[] {
return failedRules.map((f) => `rule ${f.id} failed and was skipped: ${f.message.split('\n')[0]}`);
return failedRules.map(formatFailedRuleWarning);
}

/**
Expand Down Expand Up @@ -336,16 +339,15 @@ export async function analyzeProject(opts: AnalyzeOptions = {}): Promise<Analyze
// would score as if it had run clean. Returned as the config this function hands back (not just a
// local copy) so every downstream consumer — CLI health/exit-code checks and the reporters, which
// each recompute Health from `config` — agrees on the same score.
const scoringConfig = withFailedRulesOff(
config,
failedRules.map((f) => f.id)
);
const failedRuleIds = failedRules.map((f) => f.id);
const scoringConfig = withFailedRulesOff(config, failedRuleIds);
return {
results,
config: scoringConfig,
version: readPackageVersion(),
ruleIds: rules.map((r) => r.id),
examined,
failedRuleIds,
warnings: [...warnings, ...skippedFileWarnings([...components, ...kitModules]), ...failedRuleWarnings(failedRules)],
loadedConfig: loaded
};
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/test/rule-failure-isolation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ vi.mock('@svelte-vitals/core', async (importOriginal) => {
return { ...actual, allRules };
});

const { run } = await import('../src/index.js');
const { run, analyzeProject } = await import('../src/index.js');

function capture() {
const out: string[] = [];
Expand Down Expand Up @@ -56,4 +56,9 @@ describe('rule-failure isolation (audit 2608-CORE-06)', () => {
);
expect(cap.err.some((l) => l.includes('with a second line'))).toBe(false);
});

it('analyzeProject returns the crashed rule id in failedRuleIds', async () => {
const result = await analyzeProject({ cwd: fixtureDir, allowRules: ['seo/title-presence'] });
expect(result.failedRuleIds).toEqual(['seo/title-presence']);
});
});
5 changes: 5 additions & 0 deletions packages/core/src/config-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export function withFailedRulesOff(config: Config, failedRuleIds: readonly strin
};
}

/** One-line "rule failed and was skipped" warning; capped to the message's first line so a stack trace can't flood a terminal. */
export function formatFailedRuleWarning(f: { id: string; message: string }): string {
return `rule ${f.id} failed and was skipped: ${f.message.split('\n')[0]}`;
}

/** Apply per-rule severity overrides to results (design §6). */
export function applyRuleSeverities(results: Result[], config: Config): Result[] {
return results.map((result) => {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ export {
overrideMatches,
settingSeverity,
settingOptions,
withFailedRulesOff
withFailedRulesOff,
formatFailedRuleWarning
} from './config-apply.js';
export type { CompiledOverride } from './config-apply.js';

Expand Down
7 changes: 7 additions & 0 deletions packages/core/test/config-apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
overrideMatches,
defineConfig,
withFailedRulesOff,
formatFailedRuleWarning,
type Rule,
type Result
} from '../src/index.js';
Expand Down Expand Up @@ -186,6 +187,12 @@ describe('withFailedRulesOff', () => {
});
});

describe('formatFailedRuleWarning', () => {
it('caps the message to its first line', () => {
expect(formatFailedRuleWarning({ id: 'x', message: 'boom\nstack' })).toBe('rule x failed and was skipped: boom');
});
});

describe('override matching', () => {
const config = defineConfig({
overrides: [{ files: 'src/lib/**', rules: { 'architecture/prop-count': 'off' } }]
Expand Down
3 changes: 2 additions & 1 deletion packages/vite/src/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
applyOverrides,
runRules,
withFailedRulesOff,
formatFailedRuleWarning,
computeScore,
summarize,
hasFailureAtOrAbove,
Expand Down Expand Up @@ -118,7 +119,7 @@ export async function analyze(
const results = applyOverrides(applyRuleSeverities(rawResults, config), config);
// Surfaced through the same `warnings` channel as config-file issues (plugin.ts logs each with
// `console.warn`).
for (const f of failedRules) warnings.push(`rule ${f.id} failed and was skipped: ${f.message.split('\n')[0]}`);
for (const f of failedRules) warnings.push(formatFailedRuleWarning(f));
// A failed rule examined nothing, so its weight must not stay in the Health denominator — same
// correction the CLI's `analyzeProject` applies, used by every downstream consumer here so the
// score, reports, and fail decision agree.
Expand Down
25 changes: 17 additions & 8 deletions packages/vite/src/hooks/handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import {
applyRuleSeverities,
defineConfig,
effectiveSeverity,
formatFailedRuleWarning,
isPenalized,
runRules,
selectRules,
terminalSafe,
type Config,
type Project,
type ResolvedHead,
Expand Down Expand Up @@ -37,14 +39,16 @@ export function findingSignature(results: Result[], config: Config): string {
.join('|');
}

async function postIngest(origin: string, route: string, results: Result[]): Promise<void> {
const warn = (line: string): void => console.warn(terminalSafe(line));

async function postIngest(origin: string, route: string, results: Result[], failedRuleIds: string[]): Promise<void> {
// `origin` comes from the request (Host header), so a spoofed Host must not
// redirect this server-side POST to an arbitrary external host.
if (!isLoopbackOrigin(origin)) {
// Accessing the app over LAN/--host yields a non-loopback origin, so the live
// UI silently stops updating — surface why when debugging is enabled.
if (globalThis.process?.env?.SVELTE_VITALS_DEBUG) {
console.warn(
warn(
`[svelte-vitals] live UI ingest skipped for non-loopback origin ${origin} — open the dashboard via localhost`
);
}
Expand All @@ -54,7 +58,9 @@ async function postIngest(origin: string, route: string, results: Result[]): Pro
await fetch(`${origin}/__svelte-vitals/ingest`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ route, results })
// failedRuleIds is always sent, empty array included, so a route that recovers from
// a previously-crashing rule clears its stale entry on the receiving store.
body: JSON.stringify({ route, results, failedRuleIds })
});
} catch {
// dev tooling must never break a request — swallow ingest failures
Expand Down Expand Up @@ -90,25 +96,28 @@ async function analyzeAndIngest(
config
});
const results = applyRuleSeverities(ruleResults, config);
const failedRuleIds = failedRules.map((f) => f.id);

// Same debug-only channel as this function's own catch below — a failed rule is dropped
// silently otherwise, since this hot per-request path has no other diagnostics surface.
if (failedRules.length > 0 && globalThis.process?.env?.SVELTE_VITALS_DEBUG) {
for (const f of failedRules) console.warn(`[svelte-vitals] rule ${f.id} failed and was skipped: ${f.message}`);
for (const f of failedRules) warn(formatFailedRuleWarning(f));
}

// Skip a repeat POST (and the SSE churn it would cause) when a route re-renders
// with the exact same findings — e.g. an unrelated HMR pass.
const signature = findingSignature(results, config);
// with the exact same findings — e.g. an unrelated HMR pass. The failed-ids suffix
// means a route that stops crashing (same findings, no more failures) still counts
// as a change, so its recovery reaches the store instead of being signature-skipped.
const signature = `${findingSignature(results, config)}|failed:${[...failedRuleIds].sort().join(',')}`;
if (lastSignature.get(route) === signature) return;
lastSignature.set(route, signature);

if (globalThis.process?.env?.SVELTE_VITALS_UI) void postIngest(origin, route, results);
if (globalThis.process?.env?.SVELTE_VITALS_UI) void postIngest(origin, route, results, failedRuleIds);
} catch (err) {
// Dev tooling must never break the request: swallow any parse/rule error.
// Set SVELTE_VITALS_DEBUG to surface tool-internal errors while debugging.
if (globalThis.process?.env?.SVELTE_VITALS_DEBUG) {
console.warn('[svelte-vitals] dev analysis failed:', err);
warn(`[svelte-vitals] dev analysis failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
Expand Down
23 changes: 18 additions & 5 deletions packages/vite/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
defaultConfig,
resolveRuleOptions,
shouldSkipRangeCheck,
terminalSafe,
validateRuleSetting
} from '@svelte-vitals/core';
import { findUnknownRuleIds, knownRuleIds, ruleOptionsSpec } from 'svelte-vitals';
Expand All @@ -42,6 +43,9 @@ const CONFIG_BASENAMES = new Set([

const IGNORED_SEGMENTS = new Set(['node_modules', '.svelte-kit', 'build', 'dist']);

/** Analyzed-repo-derived strings (rule messages, config warnings) can carry raw terminal escapes — sanitize at this sink boundary, not per interpolation. */
const warn = (line: string): void => console.warn(terminalSafe(line));

/**
* Whether a `server.watcher` event on `file` should trigger a dev-dashboard re-analysis:
* anything under `src/` or `static/` (the default SvelteKit layout this dashboard assumes),
Expand Down Expand Up @@ -226,10 +230,10 @@ export function svelteVitals(options: SvelteVitalsOptions = {}): Plugin | Plugin
// gate instead of failing the whole build — distinct from `result.failed`.
// Config-file validation errors never reach here: resolved above, before
// the try, they propagate and fail the build instead.
console.warn(`svelte-vitals: skipped — analysis failed: ${err instanceof Error ? err.message : String(err)}`);
warn(`svelte-vitals: skipped — analysis failed: ${err instanceof Error ? err.message : String(err)}`);
return;
}
for (const w of result.warnings) console.warn(`svelte-vitals: ${w}`);
for (const w of result.warnings) warn(`svelte-vitals: ${w}`);
if (result.routeCount === 0) return;

if (options.report !== false) {
Expand Down Expand Up @@ -277,9 +281,15 @@ export function svelteVitals(options: SvelteVitalsOptions = {}): Plugin | Plugin
config = mergeConfig(options, undefined);
warnings = [];
}
for (const w of warnings) console.warn(`svelte-vitals: ${w}`);
for (const w of warnings) warn(`svelte-vitals: ${w}`);
const store = createStore();

// The whole-project runner's crashed-rule ids, read by installUiMiddleware on every
// request via the getter below — a plain variable would only ever see the value at
// configureServer time, not later re-runs. Ids only, not a config: `config` above
// (carrying plugin-option weights/overrides) must stay the scoring base always.
let staticFailedRuleIds: string[] = [];

// Whole-project static analysis: one run at startup (never blocking dev-server
// start) plus a debounced re-run on relevant source changes (design doc
// 2026-07-08-dev-dashboard-whole-project-design.md). Failures are warned and the
Expand All @@ -290,7 +300,10 @@ export function svelteVitals(options: SvelteVitalsOptions = {}): Plugin | Plugin
metaComponents: options.metaComponents,
rules: options.rules,
failOn: options.failOn,
onResults: (results) => store.setStatic(results),
onResults: (results, failedRuleIds) => {
store.setStatic(results);
staticFailedRuleIds = failedRuleIds ?? [];
},
onError: (err) => console.warn('[svelte-vitals] dev analysis failed:', err),
onStatusChange: (analyzing) => store.setAnalyzing(analyzing)
});
Expand All @@ -307,7 +320,7 @@ export function svelteVitals(options: SvelteVitalsOptions = {}): Plugin | Plugin
runner.stop();
});

installUiMiddleware(server, config, readPackageVersion(), store, readCoreVersion());
installUiMiddleware(server, config, readPackageVersion(), store, readCoreVersion(), () => staticFailedRuleIds);

// The dashboard has no separate CLI entry point (unlike `vitest --ui`) to signal
// it exists, so announce it the same way Vite announces its own dev server: as an
Expand Down
14 changes: 10 additions & 4 deletions packages/vite/src/ui/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export type AnalyzeFn = (opts: {
rules?: Record<string, RuleSetting>;
failOn?: Severity;
parseCache?: ParseCache;
}) => Promise<{ results: Result[] }>;
}) => Promise<{ results: Result[]; failedRuleIds?: string[] }>;

export interface AnalysisRunnerOptions {
/** Project root to analyze (passed as `cwd` to `analyzeProject`). */
Expand All @@ -21,7 +21,8 @@ export interface AnalysisRunnerOptions {
failOn?: Severity;
/** `analyzeProject`-compatible function, injectable for tests. Defaults to `analyzeProject`. */
analyze?: AnalyzeFn;
onResults(results: Result[]): void;
/** `failedRuleIds` is `analyzeProject`'s crashed-rule ids — omitted when the injected `analyze` doesn't return them. Ids only, not a config: the base config (plugin-option weights/overrides included) must stay the caller's, never swapped for `analyzeProject`'s own. */
onResults(results: Result[], failedRuleIds?: string[]): void;
onError(err: unknown): void;
/** Called `true` right before a run starts its `analyze()` call and `false` once that run settles — including right before a coalesced follow-up starts again, so a rapid burst of changes may emit false-then-true between runs rather than staying true throughout. */
onStatusChange?(analyzing: boolean): void;
Expand Down Expand Up @@ -57,15 +58,20 @@ export function createAnalysisRunner(opts: AnalysisRunnerOptions): AnalysisRunne
running = true;
opts.onStatusChange?.(true);
try {
const { results } = await analyze({
const { results, failedRuleIds } = await analyze({
cwd: opts.root,
treatDynamicAs: opts.treatDynamicAs,
metaComponents: opts.metaComponents,
rules: opts.rules,
failOn: opts.failOn,
parseCache
});
if (!stopped) opts.onResults(results);
// Passing a 2nd arg only when defined keeps callers that ignore it (and tests
// asserting exact call args) unaffected by this addition.
if (!stopped) {
if (failedRuleIds !== undefined) opts.onResults(results, failedRuleIds);
else opts.onResults(results);
}
} catch (err) {
if (!stopped) opts.onError(err);
} finally {
Expand Down
13 changes: 8 additions & 5 deletions packages/vite/src/ui/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ export function installUiMiddleware(
config: Config,
version: string,
store: FindingsStore,
coreVersion?: string
coreVersion?: string,
/** Reads the whole-project runner's current crashed-rule ids; called per request so a later re-analysis is reflected without re-mounting the middleware. Empty/undefined until the first run completes or when nothing has failed. */
getStaticFailedRuleIds?: () => string[] | undefined
): void {
const clients = new Set<ServerResponse>();

Expand Down Expand Up @@ -112,9 +114,10 @@ export function installUiMiddleware(
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', () => {
try {
const { route, results } = JSON.parse(Buffer.concat(chunks).toString('utf8'));
const { route, results, failedRuleIds } = JSON.parse(Buffer.concat(chunks).toString('utf8'));
if (typeof route === 'string' && Array.isArray(results)) {
store.set(route, results.filter(isResultLike));
const failedIds = Array.isArray(failedRuleIds) ? failedRuleIds.filter((id) => typeof id === 'string') : [];
store.set(route, results.filter(isResultLike), failedIds);
}
} catch {
// ignore malformed ingest payloads — dev tooling must not crash the dev server
Expand All @@ -139,7 +142,7 @@ export function installUiMiddleware(

if (url.startsWith('/data.json')) {
try {
const snapshot = buildSnapshot(store, config, { version, coreVersion });
const snapshot = buildSnapshot(store, config, { version, coreVersion }, getStaticFailedRuleIds?.());
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(snapshot));
} catch {
Expand All @@ -152,7 +155,7 @@ export function installUiMiddleware(
// Last line of defense that validated data should never reach: if the renderer
// throws anyway, return a plain-text 500 and never take down the dev server.
try {
const html = renderAppShell(buildSnapshot(store, config, { version, coreVersion }));
const html = renderAppShell(buildSnapshot(store, config, { version, coreVersion }, getStaticFailedRuleIds?.()));
res.setHeader('Content-Type', 'text/html');
res.end(html);
} catch {
Expand Down
Loading