@svelte-vitals/core 0.38.0 → 0.40.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/dist/index.d.ts CHANGED
@@ -309,6 +309,13 @@ interface HeadingInfo {
309
309
  interface ResolvedHeadings {
310
310
  route: string;
311
311
  headings: HeadingInfo[];
312
+ /**
313
+ * Headings found in child components rendered (transitively) by this route's
314
+ * chain files — source mode only; absent in rendered mode. Kept separate from
315
+ * `headings` because their position in document order is unknown: safe for
316
+ * counting (seo/single-h1), unusable for outline order (seo/heading-level-skip).
317
+ */
318
+ componentHeadings?: HeadingInfo[];
312
319
  }
313
320
 
314
321
  /**
@@ -971,7 +978,7 @@ interface Rule {
971
978
  category: Category;
972
979
  /** Default severity (overridable by config in later slices). */
973
980
  severity: Severity;
974
- /** 'route' = evaluated per route, 'project' = site-wide (design §10, §12). */
981
+ /** 'route' = evaluated per route, 'project' = site-wide, 'component' = evaluated per source file (design §10, §12). */
975
982
  scope: Scope;
976
983
  /** Why this rule matters — one or two sentences, surfaced by `svelte-vitals explain` (issue #24). */
977
984
  rationale: string;
@@ -1119,9 +1126,15 @@ declare const seoHreflang: Rule;
1119
1126
 
1120
1127
  /**
1121
1128
  * seo/single-h1 — Heading hierarchy (single H1). Reads the per-route page-body headings
1122
- * channel (collected by both providers). Zero <h1> (no primary heading) and two
1123
- * or more (diluted topic) are both flagged; exactly one passes. A route whose
1124
- * headings were not collected (channel unset) emits nothing.
1129
+ * channel (collected by both providers), counting `headings` plus `componentHeadings`
1130
+ * (static mode only headings found transitively in rendered child components) as one
1131
+ * combined list. Zero <h1> (no primary heading) is a `warning`: defensible, a page needs
1132
+ * a primary heading. Two or more is only `info`: a single <h1> is the conventional
1133
+ * signal, but no official source documents a ranking penalty for several (2026-08-09 v1
1134
+ * rule-validity review, P2 #11) — so it's flagged as a style nit, not a defect. Exactly
1135
+ * one passes. A route whose headings were not collected (channel unset) emits nothing. A
1136
+ * global `rules: { 'seo/single-h1': <severity> }` override flattens both arms to one
1137
+ * severity (design, `applyRuleSeverities`).
1125
1138
  */
1126
1139
  declare const seoSingleH1: Rule;
1127
1140
 
@@ -1179,8 +1192,9 @@ declare const correctnessNonreactiveBuiltinState: Rule;
1179
1192
 
1180
1193
  /**
1181
1194
  * correctness/checkable-bind-value — bind:value binds the DOM value property. A
1182
- * checkbox/radio's user interaction toggles checkedness, which bind:value never observes, so
1183
- * the bound state is frozen at its initial value and silently never updates in production.
1195
+ * checkbox/radio's user interaction toggles checkedness, which bind:value never observes. A
1196
+ * checkbox throws bind_invalid_checkbox_value in dev (silently tracks value instead of
1197
+ * checkedness in prod); a radio throws nothing and its bound state silently never updates.
1184
1198
  * bind:checked (single checkbox) / bind:group (checkbox list, radio group) are the correct
1185
1199
  * bindings.
1186
1200
  */
@@ -1268,11 +1282,19 @@ declare const architectureDirectoryNaming: Rule;
1268
1282
 
1269
1283
  /**
1270
1284
  * architecture/reserved-directory-names — a directory's immediate subdirectories may only take names
1271
- * the project declared for that position (design 2026-07-29). L3: inert until a scope is declared.
1285
+ * the project declared for that position (design 2026-07-29, extended 2026-08-08 for lowercase units
1286
+ * issue #386).
1272
1287
  *
1273
- * Two option maps, differing in what their key names. A `scopes` key names the parent directly. A
1288
+ * The option maps differ in what their keys name. A `scopes` key names the parent directly. A
1274
1289
  * `unitScopes` key names a root, and the rule governs the children of whichever directories beneath
1275
- * it are units — the shape a glob cannot reach, because units nest to arbitrary depth.
1290
+ * it are units whose name begins A–Z — the shape a glob cannot reach, because units nest to arbitrary
1291
+ * depth. An `anyCaseUnitScopes` key names a root the same way, but governs units of *either* case:
1292
+ * `isUnitDir`'s letter test — A–Z plus a same-stemmed entry file, whatever its extension — excludes a
1293
+ * lowercase unit, so without this map no generic unit-map declaration governed one's children (a
1294
+ * `scopes` key naming the parent directly could still reach one) — measured at 129 of 299 units (43%)
1295
+ * on a real tree. Neither map is named with the bare word "unit": the
1296
+ * sibling rule `architecture/reserved-name-placement` records why that word alone is ambiguous between
1297
+ * the two predicates once both exist.
1276
1298
  *
1277
1299
  * There are no pass results. `computeScore` seeds every distinct `route` at 100 and averages, and the
1278
1300
  * subject here is a directory with no pre-existing score key, so a pass per directory would add
package/dist/index.js CHANGED
@@ -94,7 +94,7 @@ function attrTextOf(attr) {
94
94
  // src/component-parse.ts
95
95
  function unwrapTs(expr) {
96
96
  let cur = expr;
97
- while (cur.type === "TSSatisfiesExpression" || cur.type === "TSAsExpression" || cur.type === "TSNonNullExpression")
97
+ while (cur !== void 0 && (cur.type === "TSSatisfiesExpression" || cur.type === "TSAsExpression" || cur.type === "TSNonNullExpression"))
98
98
  cur = cur.expression;
99
99
  return cur;
100
100
  }
@@ -623,6 +623,21 @@ function collectDirectiveEscapes(node, names, acc) {
623
623
  }
624
624
  }
625
625
  var RUNE_NAMES = /* @__PURE__ */ new Set(["$state", "$derived", "$effect", "$props", "$bindable", "$inspect", "$host"]);
626
+ function collectImportedLocalNames(program, acc) {
627
+ for (const stmt of program?.body ?? []) {
628
+ if (stmt?.type !== "ImportDeclaration" || stmt.importKind === "type") continue;
629
+ for (const s of stmt.specifiers ?? []) {
630
+ if (s?.importKind === "type" || s?.local?.type !== "Identifier") continue;
631
+ acc.add(s.local.name);
632
+ }
633
+ }
634
+ }
635
+ function collectNewExprLocalNames(program, acc) {
636
+ walkEstree(program, (n) => {
637
+ if (n.type !== "VariableDeclarator" || n.id?.type !== "Identifier" || !n.init) return;
638
+ if (unwrapTs(n.init).type === "NewExpression") acc.add(n.id.name);
639
+ });
640
+ }
626
641
  function bodyReadsReactive(fn, reactiveNames) {
627
642
  let reads = false;
628
643
  const visit = (n) => {
@@ -826,7 +841,7 @@ function countProps(program) {
826
841
  if (n.type !== "VariableDeclarator" || !n.init || !isPropsCall(unwrapTs(n.init))) return;
827
842
  seen++;
828
843
  const props = n.id?.type === "ObjectPattern" ? n.id.properties : void 0;
829
- if (!Array.isArray(props) || props.some((p) => p?.type === "RestElement")) {
844
+ if (!Array.isArray(props)) {
830
845
  uncountable = true;
831
846
  return;
832
847
  }
@@ -1398,6 +1413,12 @@ function parseComponentFacts(source, filename) {
1398
1413
  }
1399
1414
  const stateNames = /* @__PURE__ */ new Set();
1400
1415
  const reactiveNames = /* @__PURE__ */ new Set();
1416
+ collectImportedLocalNames(program, reactiveNames);
1417
+ collectNewExprLocalNames(program, reactiveNames);
1418
+ if (moduleProgram) {
1419
+ collectImportedLocalNames(moduleProgram, reactiveNames);
1420
+ collectNewExprLocalNames(moduleProgram, reactiveNames);
1421
+ }
1401
1422
  const stateDecls = [];
1402
1423
  walkEstree(program, (n) => {
1403
1424
  if (n.type !== "VariableDeclarator" || !n.init) return;
@@ -2536,7 +2557,7 @@ function headTagRule(opts) {
2536
2557
  var seoDescriptionPresence = headTagRule({
2537
2558
  id: "seo/description-presence",
2538
2559
  title: "Description presence",
2539
- severity: "critical",
2560
+ severity: "warning",
2540
2561
  match: (t) => t.kind === "meta" && t.name === "description",
2541
2562
  label: '<meta name="description">',
2542
2563
  recommendation: 'Add a <meta name="description"> in <svelte:head>, or set the description on your meta component.',
@@ -2556,7 +2577,7 @@ var seoCanonicalUrl = headTagRule({
2556
2577
  match: (t) => t.kind === "link" && t.rel === "canonical",
2557
2578
  label: '<link rel="canonical">',
2558
2579
  recommendation: 'Add <link rel="canonical"> in <svelte:head>, or set the canonical prop on your meta component.',
2559
- rationale: "A canonical URL tells search engines which URL is authoritative, preventing duplicate-content dilution across query strings and trailing-slash variants.",
2580
+ rationale: "A canonical URL tells search engines which URL is authoritative, preventing duplicate-content dilution across query-string variants of the same page.",
2560
2581
  fix: {
2561
2582
  description: 'Add <link rel="canonical"> inside <svelte:head>, or set the canonical prop on your meta component.',
2562
2583
  snippet: '<svelte:head>\n <link rel="canonical" href="https://example.com/this-page" />\n</svelte:head>',
@@ -2693,7 +2714,7 @@ var seoHtmlLang = {
2693
2714
  category: "seo",
2694
2715
  severity: "warning",
2695
2716
  scope: "project",
2696
- rationale: "The <html lang> attribute declares the page language for search engines, screen readers, and translation tools.",
2717
+ rationale: "The <html lang> attribute tells screen readers how to pronounce the page, browsers whether to offer translation, and other assistive tools how to handle the content \u2014 Google has said it does not use lang for ranking.",
2697
2718
  fix: FIX4,
2698
2719
  async check(ctx) {
2699
2720
  const detection = ctx.project.htmlLang;
@@ -2737,6 +2758,13 @@ function imageRule(opts) {
2737
2758
  severity: opts.severity,
2738
2759
  detection: { presence: "own", value: "static" },
2739
2760
  route: route.route,
2761
+ // No single route-level file exists here (unlike ResolvedHead.file) — the
2762
+ // route's first image stands in as its attributed file (design
2763
+ // 2026-08-08-pass-result-location-design.md; this uncaught inline PASS literal
2764
+ // was missed by the design spike's grep and added to its blast-radius table
2765
+ // afterward, maintainer ruling, same date). `route.images.length === 0` already
2766
+ // continued above, so `[0]` is always defined here.
2767
+ location: route.images[0].file,
2740
2768
  message: opts.label,
2741
2769
  recommendation: opts.recommendation,
2742
2770
  docsUrl: docsUrl12
@@ -2771,7 +2799,7 @@ var performanceImageDimensions = imageRule({
2771
2799
  severity: "warning",
2772
2800
  label: "<img> width/height",
2773
2801
  recommendation: "Set explicit width and height on <img> to reserve space and avoid layout shift (CLS).",
2774
- rationale: "An <img> without explicit width and height triggers layout shift (CLS) as it loads, hurting Core Web Vitals and visual stability.",
2802
+ rationale: "An <img> without explicit width and height can trigger layout shift (CLS) as it loads, hurting Core Web Vitals and visual stability \u2014 unless the box is reserved another way, e.g. CSS aspect-ratio.",
2775
2803
  fix: {
2776
2804
  description: "Add explicit width and height attributes to the <img>.",
2777
2805
  snippet: '<img src="/hero.jpg" width="1200" height="630" alt="\u2026" />',
@@ -2836,6 +2864,13 @@ function linkRule(opts) {
2836
2864
  severity: opts.severity,
2837
2865
  detection: { presence: "own", value: "static" },
2838
2866
  route: head.route,
2867
+ // The route's own attributed file (design 2026-08-08-pass-result-location-design.md)
2868
+ // — this uncaught inline PASS literal was missed by the design spike's grep and
2869
+ // added to its blast-radius table afterward (maintainer ruling, same date). No
2870
+ // single per-tag location applies here (many links can back one pass), so the
2871
+ // route's own head file is the uniform attribution; per-tag penalized locations
2872
+ // above remain per-tag.
2873
+ location: head.file,
2839
2874
  message: opts.label,
2840
2875
  recommendation: opts.recommendation,
2841
2876
  docsUrl: docsUrl12
@@ -2938,6 +2973,11 @@ var performanceLcpImage = {
2938
2973
  severity: "warning",
2939
2974
  detection: { presence: "own", value: "static" },
2940
2975
  route: route.route,
2976
+ // Same `location` the penalized branch above uses (design
2977
+ // 2026-08-08-pass-result-location-design.md) — this uncaught inline PASS literal
2978
+ // was missed by the design spike's grep and added to its blast-radius table
2979
+ // afterward (maintainer ruling, same date).
2980
+ location: first.file,
2941
2981
  message: "LCP image eager loading",
2942
2982
  recommendation,
2943
2983
  docsUrl
@@ -2992,6 +3032,10 @@ var performanceRenderBlockingScript = {
2992
3032
  severity: "warning",
2993
3033
  detection: { presence: "own", value: "static" },
2994
3034
  route: head.route,
3035
+ // The route's own attributed file (design 2026-08-08-pass-result-location-design.md)
3036
+ // — this uncaught inline PASS literal was missed by the design spike's grep and
3037
+ // added to its blast-radius table afterward (maintainer ruling, same date).
3038
+ location: head.file,
2995
3039
  message: "No render-blocking scripts",
2996
3040
  recommendation: recommendation2,
2997
3041
  docsUrl: docsUrl2
@@ -3210,7 +3254,7 @@ var performancePreconnect = {
3210
3254
  rationale: "Connecting to a third-party origin (DNS + TCP + TLS) is costly; a preconnect/dns-prefetch hint starts it early so the resource arrives sooner.",
3211
3255
  fix: {
3212
3256
  description: "Add a preconnect hint for the third-party origin.",
3213
- snippet: '<link rel="preconnect" href="https://fonts.googleapis.com" />',
3257
+ snippet: '<link rel="preconnect" href="https://fonts.googleapis.com" />\n<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />',
3214
3258
  lang: "html"
3215
3259
  },
3216
3260
  options: OPTIONS,
@@ -3247,6 +3291,10 @@ var performancePreconnect = {
3247
3291
  severity: "info",
3248
3292
  detection: { presence: "own", value: "static" },
3249
3293
  route: head.route,
3294
+ // head.file — the same target used to resolve options above (design
3295
+ // 2026-08-08-pass-result-location-design.md) — so a `files:`-scoped override can
3296
+ // also match this passing seed via `severity: 'off'`.
3297
+ location: head.file,
3250
3298
  message: "Third-party origins are preconnected",
3251
3299
  recommendation: recommendation3,
3252
3300
  docsUrl: docsUrl3
@@ -3330,11 +3378,11 @@ var seoTwitterCard = headTagRule({
3330
3378
  var seoOgDescription = headTagRule({
3331
3379
  id: "seo/og-description",
3332
3380
  title: "Open Graph description",
3333
- severity: "warning",
3381
+ severity: "info",
3334
3382
  match: (t) => t.kind === "meta" && t.property === "og:description",
3335
3383
  label: '<meta property="og:description">',
3336
3384
  recommendation: 'Add <meta property="og:description">, or set openGraph.description on your meta component.',
3337
- rationale: "og:description is the summary shown under the title in social previews; without it platforms guess or show nothing, lowering click-through.",
3385
+ rationale: "og:description is the summary shown under the title in social previews; without it platforms guess or show nothing, lowering click-through. The Open Graph protocol lists it as an optional property.",
3338
3386
  fix: {
3339
3387
  description: "Add an og:description meta tag in <svelte:head>.",
3340
3388
  snippet: '<svelte:head>\n <meta property="og:description" content="A concise page summary." />\n</svelte:head>',
@@ -3346,11 +3394,11 @@ var seoOgDescription = headTagRule({
3346
3394
  var seoOgUrl = headTagRule({
3347
3395
  id: "seo/og-url",
3348
3396
  title: "Open Graph URL",
3349
- severity: "info",
3397
+ severity: "warning",
3350
3398
  match: (t) => t.kind === "meta" && t.property === "og:url",
3351
3399
  label: '<meta property="og:url">',
3352
3400
  recommendation: 'Add <meta property="og:url"> with the canonical URL, or set openGraph.url on your meta component.',
3353
- rationale: "og:url tells social platforms the canonical address to attribute shares and likes to, consolidating engagement on one URL.",
3401
+ rationale: "og:url tells social platforms the canonical address to attribute shares and likes to, consolidating engagement on one URL. The Open Graph protocol lists it as a required property.",
3354
3402
  fix: {
3355
3403
  description: "Add an og:url meta tag in <svelte:head>.",
3356
3404
  snippet: '<svelte:head>\n <meta property="og:url" content="https://example.com/this-page" />\n</svelte:head>',
@@ -3370,7 +3418,7 @@ var seoViewport = headTagRule({
3370
3418
  // silent there instead of false-flagging "missing" on every route.
3371
3419
  appliesTo: (head) => head.source === "rendered",
3372
3420
  recommendation: 'Add <meta name="viewport" content="width=device-width, initial-scale=1"> (usually in app.html).',
3373
- rationale: "Without a viewport meta tag the page is not mobile-responsive, which Google penalizes under mobile-first indexing.",
3421
+ rationale: "Without a viewport meta tag mobile browsers render the page at a fixed ~980px layout viewport and scale it to fit, so text and controls end up too small to read or tap without pinch-zooming.",
3374
3422
  fix: {
3375
3423
  description: "Add the viewport meta tag (typically in src/app.html <head>).",
3376
3424
  snippet: '<meta name="viewport" content="width=device-width, initial-scale=1" />',
@@ -3535,19 +3583,25 @@ function hasNonEmpty(node, key) {
3535
3583
  return true;
3536
3584
  }
3537
3585
  var REQUIRED_PROPS = {
3538
- Article: ["headline"],
3539
- BlogPosting: ["headline"],
3540
- NewsArticle: ["headline"],
3541
- Product: ["name", "offers"],
3586
+ Product: { all: ["name"], oneOf: ["review", "aggregateRating", "offers"] },
3542
3587
  BreadcrumbList: ["itemListElement"],
3543
- Organization: ["name", "url"],
3544
3588
  WebSite: ["name", "url"],
3545
3589
  Event: ["name", "startDate", "location"],
3546
- Recipe: ["name", "image", "recipeIngredient", "recipeInstructions"],
3547
- Person: ["name"],
3548
- VideoObject: ["name", "description", "thumbnailUrl", "uploadDate"],
3590
+ Recipe: ["name", "image"],
3591
+ VideoObject: ["name", "thumbnailUrl", "uploadDate"],
3549
3592
  LocalBusiness: ["name", "address"]
3550
3593
  };
3594
+ function oneOfLabel(props) {
3595
+ const last = props.at(-1) ?? "";
3596
+ return props.length <= 1 ? last : `one of ${props.slice(0, -1).join(", ")} or ${last}`;
3597
+ }
3598
+ function missingRequiredProps(node, row) {
3599
+ const all = Array.isArray(row) ? row : row.all;
3600
+ const missing = all.filter((p) => !hasNonEmpty(node, p));
3601
+ const oneOf = Array.isArray(row) ? void 0 : row.oneOf;
3602
+ if (oneOf && !oneOf.some((p) => hasNonEmpty(node, p))) missing.push(oneOfLabel(oneOf));
3603
+ return missing;
3604
+ }
3551
3605
  function jsonldTags(head) {
3552
3606
  return head.tags.filter((t) => t.kind === "jsonld" && typeof t.jsonld === "string");
3553
3607
  }
@@ -3588,6 +3642,9 @@ function jsonldRule(opts) {
3588
3642
  severity: opts.severity,
3589
3643
  detection: PASS,
3590
3644
  route: head.route,
3645
+ // Same `location` the penalized branch above uses (design
3646
+ // 2026-08-08-pass-result-location-design.md).
3647
+ location: head.file,
3591
3648
  message: opts.label,
3592
3649
  recommendation: opts.recommendation,
3593
3650
  docsUrl: docsUrl12
@@ -3641,6 +3698,9 @@ var seoJsonLdValidity = {
3641
3698
  severity: "warning",
3642
3699
  detection: PASS,
3643
3700
  route: head.route,
3701
+ // Same `location` the penalized branch above uses (design
3702
+ // 2026-08-08-pass-result-location-design.md).
3703
+ location: head.file,
3644
3704
  message: "JSON-LD validity",
3645
3705
  recommendation: "Make the JSON-LD valid JSON with both @context and @type.",
3646
3706
  docsUrl: docsUrl12
@@ -3733,7 +3793,7 @@ var seoJsonLdRequiredProps = jsonldRule({
3733
3793
  const required = REQUIRED_PROPS[t];
3734
3794
  if (!required) continue;
3735
3795
  hasKnownType = true;
3736
- const missing = required.filter((p) => !hasNonEmpty(node, p));
3796
+ const missing = missingRequiredProps(node, required);
3737
3797
  if (missing.length > 0) return `${t} JSON-LD is missing required ${missing.join(", ")}`;
3738
3798
  }
3739
3799
  }
@@ -3775,7 +3835,7 @@ function lengthRule(opts) {
3775
3835
  const o = resolveRuleOptions(opts.id, spec, ctx.config, { route: head.route, file: location }, compiled);
3776
3836
  const min = intOption(o, "min", opts.min);
3777
3837
  const max = intOption(o, "max", opts.max);
3778
- const recommendation12 = typeof opts.recommendation === "function" ? opts.recommendation(o) : opts.recommendation;
3838
+ const recommendation10 = typeof opts.recommendation === "function" ? opts.recommendation(o) : opts.recommendation;
3779
3839
  const len = visibleLength(tag.text);
3780
3840
  let problem;
3781
3841
  if (len < min) problem = `${opts.noun} is too short (${len} chars; aim for ${min}\u2013${max})`;
@@ -3789,7 +3849,7 @@ function lengthRule(opts) {
3789
3849
  route: head.route,
3790
3850
  location,
3791
3851
  message: problem,
3792
- recommendation: recommendation12,
3852
+ recommendation: recommendation10,
3793
3853
  docsUrl: docsUrl12
3794
3854
  } : {
3795
3855
  id: opts.id,
@@ -3797,8 +3857,13 @@ function lengthRule(opts) {
3797
3857
  severity: "info",
3798
3858
  detection: PASS,
3799
3859
  route: head.route,
3860
+ // Same `location` the penalized branch above uses (design
3861
+ // 2026-08-08-pass-result-location-design.md) — without it, a `files:`-scoped
3862
+ // override can flip this result to PASS via its `options` but can never match
3863
+ // it to also apply `severity: 'off'`.
3864
+ location,
3800
3865
  message: opts.label,
3801
- recommendation: recommendation12,
3866
+ recommendation: recommendation10,
3802
3867
  docsUrl: docsUrl12
3803
3868
  }
3804
3869
  );
@@ -3866,7 +3931,7 @@ var seoImageAlt = imageRule({
3866
3931
  rationale: "An <img> with no alt attribute is invisible to image search and assistive technology; a descriptive alt is an image-SEO signal.",
3867
3932
  fix: {
3868
3933
  description: 'Add a descriptive alt attribute to the <img> (or alt="" if purely decorative).',
3869
- snippet: '<img src="/photo.jpg" width="800" height="600" alt="Description of the image" />',
3934
+ snippet: '<img src="/photo.jpg" width="800" height="600" alt="Golden retriever catching a frisbee in a park" />',
3870
3935
  lang: "svelte"
3871
3936
  },
3872
3937
  ok: (img) => img.hasAlt
@@ -3874,7 +3939,9 @@ var seoImageAlt = imageRule({
3874
3939
 
3875
3940
  // src/rules/seo/hreflang.ts
3876
3941
  var docsUrl4 = docsUrlFor("seo/hreflang");
3877
- var recommendation4 = 'Use valid hreflang codes (e.g. "en", "en-US", "x-default") and include an x-default when you have multiple language alternates.';
3942
+ var malformedRecommendation = 'Use valid hreflang codes, e.g. "en", "en-US", or the literal "x-default".';
3943
+ var noDefaultRecommendation = "x-default is a Google recommendation, not a requirement \u2014 most useful for a language-selector or auto-redirecting page. Add one if this page behaves that way; otherwise search engines fall back to matching the individual alternates.";
3944
+ var passRecommendation = 'Use valid hreflang codes (e.g. "en", "en-US", "x-default") for your language alternates.';
3878
3945
  var HREFLANG_RE = /^[a-z]{2,3}(-[a-z]{4})?(-([a-z]{2}|\d{3}))?$/i;
3879
3946
  function isValidHreflang(v) {
3880
3947
  return v.toLowerCase() === "x-default" || HREFLANG_RE.test(v);
@@ -3885,7 +3952,7 @@ var seoHreflang = {
3885
3952
  category: "seo",
3886
3953
  severity: "warning",
3887
3954
  scope: "route",
3888
- rationale: "A malformed hreflang code or a missing x-default breaks international targeting, so search engines may serve the wrong language version.",
3955
+ rationale: "A malformed hreflang code breaks international targeting outright. A missing x-default is a Google recommendation for language-selector or auto-redirecting pages, not a defect on every multilingual site.",
3889
3956
  async check(ctx) {
3890
3957
  const out = [];
3891
3958
  for (const head of ctx.heads) {
@@ -3898,10 +3965,13 @@ var seoHreflang = {
3898
3965
  let problem;
3899
3966
  let location = head.file;
3900
3967
  if (badTag) {
3901
- problem = `Invalid hreflang value "${badTag.hreflang}"`;
3968
+ problem = { message: `Invalid hreflang value "${badTag.hreflang}"`, recommendation: malformedRecommendation };
3902
3969
  location = badTag.file ?? head.file;
3903
3970
  } else if (values.length >= 2 && !values.some((v) => v.toLowerCase() === "x-default")) {
3904
- problem = "Multiple hreflang alternates without an x-default";
3971
+ problem = {
3972
+ message: "Multiple hreflang alternates with no x-default declared",
3973
+ recommendation: noDefaultRecommendation
3974
+ };
3905
3975
  }
3906
3976
  out.push(
3907
3977
  problem ? {
@@ -3911,8 +3981,8 @@ var seoHreflang = {
3911
3981
  detection: PENALIZED,
3912
3982
  route: head.route,
3913
3983
  location,
3914
- message: problem,
3915
- recommendation: recommendation4,
3984
+ message: problem.message,
3985
+ recommendation: problem.recommendation,
3916
3986
  docsUrl: docsUrl4
3917
3987
  } : {
3918
3988
  id: "seo/hreflang",
@@ -3920,8 +3990,11 @@ var seoHreflang = {
3920
3990
  severity: "warning",
3921
3991
  detection: PASS,
3922
3992
  route: head.route,
3993
+ // Same `location` the penalized branch above uses (design
3994
+ // 2026-08-08-pass-result-location-design.md).
3995
+ location,
3923
3996
  message: "hreflang",
3924
- recommendation: recommendation4,
3997
+ recommendation: passRecommendation,
3925
3998
  docsUrl: docsUrl4
3926
3999
  }
3927
4000
  );
@@ -3932,26 +4005,33 @@ var seoHreflang = {
3932
4005
 
3933
4006
  // src/rules/seo/single-h1.ts
3934
4007
  var docsUrl5 = docsUrlFor("seo/single-h1");
3935
- var recommendation5 = "Use exactly one <h1> per page for its main topic; demote extra top-level headings to <h2>+.";
4008
+ var passRecommendation2 = "Use exactly one <h1> per page for its main topic; demote extra top-level headings to <h2>+.";
4009
+ var missingRecommendation = "Add a single, descriptive <h1> naming the page's main topic.";
4010
+ var multipleRecommendation = "A single, clear <h1> is the conventional signal for a page \u2014 consider demoting extra top-level headings to <h2>+.";
3936
4011
  var seoSingleH1 = {
3937
4012
  id: "seo/single-h1",
3938
4013
  title: "Heading hierarchy",
3939
4014
  category: "seo",
3940
4015
  severity: "warning",
3941
4016
  scope: "route",
3942
- rationale: "Each page should have exactly one <h1> naming its main topic; none leaves the page without a primary heading, and several dilute the topic signal.",
4017
+ rationale: "A page should have a primary heading naming its main topic. Zero <h1> leaves the page without one; a single, clear <h1> is the conventional signal, though multiple <h1>s are tolerated by modern heading algorithms.",
3943
4018
  async check(ctx) {
3944
4019
  const out = [];
3945
4020
  for (const route of ctx.headings ?? []) {
3946
- const h1 = route.headings.filter((h) => h.level === 1);
4021
+ const combined = [...route.headings, ...route.componentHeadings ?? []];
4022
+ const h1 = combined.filter((h) => h.level === 1);
3947
4023
  let problem;
3948
4024
  let where = {};
3949
4025
  if (h1.length === 0) {
3950
- problem = "Missing <h1>";
4026
+ problem = { message: "Missing <h1>", severity: "warning", recommendation: missingRecommendation };
3951
4027
  const first = route.headings[0];
3952
4028
  if (first) where = { location: first.file, ...first.line > 0 ? { line: first.line } : {} };
3953
4029
  } else if (h1.length > 1) {
3954
- problem = `Multiple <h1> (${h1.length}); use exactly one`;
4030
+ problem = {
4031
+ message: `Multiple <h1> (${h1.length}); a single <h1> is the conventional signal`,
4032
+ severity: "info",
4033
+ recommendation: multipleRecommendation
4034
+ };
3955
4035
  const extra = h1[1];
3956
4036
  where = { location: extra.file, ...extra.line > 0 ? { line: extra.line } : {} };
3957
4037
  }
@@ -3959,12 +4039,12 @@ var seoSingleH1 = {
3959
4039
  problem ? {
3960
4040
  id: "seo/single-h1",
3961
4041
  category: "seo",
3962
- severity: "warning",
4042
+ severity: problem.severity,
3963
4043
  detection: PENALIZED,
3964
4044
  route: route.route,
3965
4045
  ...where,
3966
- message: problem,
3967
- recommendation: recommendation5,
4046
+ message: problem.message,
4047
+ recommendation: problem.recommendation,
3968
4048
  docsUrl: docsUrl5
3969
4049
  } : {
3970
4050
  id: "seo/single-h1",
@@ -3972,8 +4052,12 @@ var seoSingleH1 = {
3972
4052
  severity: "warning",
3973
4053
  detection: PASS,
3974
4054
  route: route.route,
4055
+ // No single route-level file exists here (unlike ResolvedHead.file) — the
4056
+ // passing route's own <h1> stands in as its attributed file (design
4057
+ // 2026-08-08-pass-result-location-design.md). Only reached when h1.length === 1.
4058
+ location: h1[0].file,
3975
4059
  message: "Heading hierarchy",
3976
- recommendation: recommendation5,
4060
+ recommendation: passRecommendation2,
3977
4061
  docsUrl: docsUrl5
3978
4062
  }
3979
4063
  );
@@ -4021,6 +4105,9 @@ function uniquenessRule(opts) {
4021
4105
  severity: "warning",
4022
4106
  detection: PASS,
4023
4107
  route: e.route,
4108
+ // Same `location` the penalized branch above uses (design
4109
+ // 2026-08-08-pass-result-location-design.md).
4110
+ location: e.file,
4024
4111
  message: opts.label,
4025
4112
  recommendation: opts.recommendation,
4026
4113
  docsUrl: docsUrl12
@@ -4054,14 +4141,14 @@ var seoDuplicateDescription = uniquenessRule({
4054
4141
 
4055
4142
  // src/rules/seo/heading-level-skip.ts
4056
4143
  var docsUrl6 = docsUrlFor("seo/heading-level-skip");
4057
- var recommendation6 = "Increase heading levels one step at a time (do not jump, e.g. from <h2> straight to <h4>).";
4144
+ var recommendation4 = "Increase heading levels one step at a time (do not jump, e.g. from <h2> straight to <h4>).";
4058
4145
  var seoHeadingLevelSkip = {
4059
4146
  id: "seo/heading-level-skip",
4060
4147
  title: "Heading order",
4061
4148
  category: "seo",
4062
4149
  severity: "info",
4063
4150
  scope: "route",
4064
- rationale: "Skipping a heading level breaks the document outline that search engines and assistive tech rely on to understand page structure.",
4151
+ rationale: "Skipping a heading level breaks the document outline that assistive tech relies on to navigate page structure, and that search engines use as a structural signal.",
4065
4152
  async check(ctx) {
4066
4153
  const out = [];
4067
4154
  for (const route of ctx.headings ?? []) {
@@ -4086,7 +4173,7 @@ var seoHeadingLevelSkip = {
4086
4173
  location: skip.file,
4087
4174
  ...skip.line > 0 ? { line: skip.line } : {},
4088
4175
  message: `Heading level skipped (<h${skip.prev}> to <h${skip.level}>)`,
4089
- recommendation: recommendation6,
4176
+ recommendation: recommendation4,
4090
4177
  docsUrl: docsUrl6
4091
4178
  } : {
4092
4179
  id: "seo/heading-level-skip",
@@ -4094,8 +4181,13 @@ var seoHeadingLevelSkip = {
4094
4181
  severity: "info",
4095
4182
  detection: PASS,
4096
4183
  route: route.route,
4184
+ // No single route-level file exists here (unlike ResolvedHead.file) — the
4185
+ // route's first heading stands in as its attributed file (design
4186
+ // 2026-08-08-pass-result-location-design.md). `route.headings.length === 0`
4187
+ // already continued above, so `[0]` is always defined here.
4188
+ location: route.headings[0].file,
4097
4189
  message: "Heading order",
4098
- recommendation: recommendation6,
4190
+ recommendation: recommendation4,
4099
4191
  docsUrl: docsUrl6
4100
4192
  }
4101
4193
  );
@@ -4133,6 +4225,9 @@ function kitModuleRule(opts) {
4133
4225
  severity,
4134
4226
  detection: PASS2,
4135
4227
  route: m.file,
4228
+ // Uniform PASS-result attribution (design 2026-08-08-pass-result-location-design.md):
4229
+ // same location a penalized result for this file would carry.
4230
+ location: m.file,
4136
4231
  message: opts.label,
4137
4232
  recommendation: opts.recommendation,
4138
4233
  docsUrl: docsUrl12
@@ -4202,7 +4297,7 @@ function componentRule(opts) {
4202
4297
  const compiled = compileOverrides(ctx.config);
4203
4298
  for (const c of ctx.components ?? []) {
4204
4299
  const o = resolveRuleOptions(opts.id, opts.options, ctx.config, { route: c.file, file: c.file }, compiled);
4205
- const recommendation12 = typeof opts.recommendation === "function" ? opts.recommendation(o) : opts.recommendation;
4300
+ const recommendation10 = typeof opts.recommendation === "function" ? opts.recommendation(o) : opts.recommendation;
4206
4301
  if (!opts.applies(c, o, ctx)) continue;
4207
4302
  const bad = opts.bad(c, o, ctx).filter((b) => !(b.line > 0 && isSuppressed2(c, opts.id, b.line)));
4208
4303
  if (bad.length === 0) {
@@ -4212,8 +4307,11 @@ function componentRule(opts) {
4212
4307
  severity,
4213
4308
  detection: PASS3,
4214
4309
  route: c.file,
4310
+ // Uniform PASS-result attribution (design 2026-08-08-pass-result-location-design.md):
4311
+ // same location a penalized result for this file would carry.
4312
+ location: c.file,
4215
4313
  message: opts.label,
4216
- recommendation: recommendation12,
4314
+ recommendation: recommendation10,
4217
4315
  docsUrl: docsUrl12
4218
4316
  });
4219
4317
  continue;
@@ -4228,7 +4326,7 @@ function componentRule(opts) {
4228
4326
  location: c.file,
4229
4327
  ...b.line > 0 ? { line: b.line } : {},
4230
4328
  message: b.message,
4231
- recommendation: recommendation12,
4329
+ recommendation: recommendation10,
4232
4330
  docsUrl: docsUrl12,
4233
4331
  ...opts.fix ? { fix: { ...opts.fix } } : {}
4234
4332
  });
@@ -4284,10 +4382,13 @@ var correctnessEffectAsOnMount = componentRule({
4284
4382
  title: "Effect used as onMount",
4285
4383
  category: "correctness",
4286
4384
  label: "$effect usage",
4287
- recommendation: "Move mount-time side effects to onMount (import { onMount } from 'svelte'); reserve $effect for logic that reacts to $state/$derived/$props.",
4288
- rationale: "An $effect that reads no reactive value runs once after mount and never re-runs \u2014 it is an onMount in disguise, which obscures intent and misuses the reactivity system.",
4385
+ recommendation: "If this runs in response to a user interaction, use an event handler; to sync an element with an external library, use {@attach} instead. For genuine one-time mount work, use onMount (import { onMount } from 'svelte'). Reserve $effect for logic that reacts to $state/$derived/$props.",
4386
+ rationale: "An $effect whose body reads no reactive value visible to this analysis runs once after mount and never re-runs on the paths it can see \u2014 usually a sign the code belongs in an event handler, {@attach}, or onMount instead of $effect. This can't see a reactive value reached only through a plain function's return value, so a genuinely reactive effect built that way can still be flagged.",
4289
4387
  applies: (c) => c.effects.length > 0,
4290
- bad: (c) => c.effects.filter((e) => e.mountOnly).map((e) => ({ line: e.line, message: "$effect reads no reactive value \u2014 use onMount instead" }))
4388
+ bad: (c) => c.effects.filter((e) => e.mountOnly).map((e) => ({
4389
+ line: e.line,
4390
+ message: "$effect reads no reactive value this analysis can see \u2014 consider an event handler, {@attach}, or onMount instead"
4391
+ }))
4291
4392
  });
4292
4393
 
4293
4394
  // src/rules/correctness/unmutated-state.ts
@@ -4367,14 +4468,14 @@ var correctnessCheckableBindValue = componentRule({
4367
4468
  severity: "warning",
4368
4469
  label: "bind:checked / bind:group on checkable inputs",
4369
4470
  recommendation: "Replace bind:value with bind:checked (single checkbox) or bind:group (checkbox list / radio group).",
4370
- rationale: "bind:value binds the DOM value property. A checkbox/radio's user interaction toggles checkedness, which bind:value never observes \u2014 the bound state is frozen at its initial value. Svelte's checked/grouped bindings (bind:checked, bind:group) are built for exactly this.",
4471
+ rationale: "bind:value binds the DOM value property. A checkbox/radio's user interaction toggles checkedness, which bind:value never observes. On a checkbox this throws bind_invalid_checkbox_value in a development build; in production the check is skipped and the binding silently tracks the value attribute instead of checkedness. On a radio it throws nothing in either build \u2014 it renders once with the initial value, then silently never updates. Svelte's checked/grouped bindings (bind:checked, bind:group) are built for exactly this.",
4371
4472
  fix: {
4372
4473
  description: "For a single checkbox, replace bind:value={x} with bind:checked={x} (x becomes a boolean). For a checkbox list or radio group, replace bind:value={x} with bind:group={x} on every input sharing the group, keeping each input's static value attribute to identify the option."
4373
4474
  },
4374
4475
  applies: (c) => c.checkableBindValues.length > 0,
4375
4476
  bad: (c) => c.checkableBindValues.map((v) => ({
4376
4477
  line: v.line,
4377
- message: v.kind === "checkbox" ? "bind:value on a checkbox does not track its checked state \u2014 the bound value silently never updates when the user toggles it. Use bind:checked (single checkbox) or bind:group (checkbox list) instead." : "bind:value on a radio input does not track which option is selected \u2014 the bound value silently never updates when the user picks one. Use bind:group with a shared group variable across the radio inputs instead."
4478
+ message: v.kind === "checkbox" ? "bind:value on a checkbox does not track its checked state \u2014 it throws bind_invalid_checkbox_value in development; in a production build it silently tracks the value attribute instead of checkedness. Use bind:checked (single checkbox) or bind:group (checkbox list) instead." : "bind:value on a radio input does not track which option is selected \u2014 the bound value silently never updates when the user picks one. Use bind:group with a shared group variable across the radio inputs instead."
4378
4479
  }))
4379
4480
  });
4380
4481
 
@@ -4386,7 +4487,7 @@ var correctnessOrphanEffect = componentRule({
4386
4487
  severity: "critical",
4387
4488
  label: "$effect context",
4388
4489
  recommendation: "Wrap the effect in $effect.root (and own the returned cleanup), or restructure so the effect is created during component initialisation (e.g. call a setup method from a component).",
4389
- rationale: "An $effect created outside component initialisation throws effect_orphan at runtime \u2014 the compiler does not catch it, and it typically surfaces as a production 500.",
4490
+ rationale: "An $effect created outside component initialisation throws effect_orphan at runtime. The compiler does not catch it \u2014 the server compiler deletes $effect calls entirely, so SSR renders without error \u2014 and the crash happens client-side, when the module evaluates in the browser, breaking hydration rather than producing a server error.",
4390
4491
  // `orphanEffects` is typed required, but a facts object built by an older/external
4391
4492
  // constructor may omit it — default to empty rather than let `applies` throw and
4392
4493
  // take the whole `runRules` Promise.all down with it.
@@ -4405,6 +4506,14 @@ var DOCS_URL = docsUrlFor(ID);
4405
4506
  var LABEL = "Lifecycle-call context";
4406
4507
  var RECOMMENDATION = "Call lifecycle/context functions during component initialisation (the top level of a component's <script>). In load, return the data and call setContext in a layout/page component; in shared modules, expose a setup function that components call during init.";
4407
4508
  var topLevelMessage = (name) => `${name}() runs at module evaluation, outside component initialisation \u2014 it throws lifecycle_outside_component at runtime`;
4509
+ var ALWAYS_THROWS = /* @__PURE__ */ new Set(["getContext", "setContext", "hasContext", "getAllContexts"]);
4510
+ function kitLifecycleMessage(name, kind, inHandler) {
4511
+ if (kind === "server" && !ALWAYS_THROWS.has(name)) {
4512
+ const where = inHandler ? `${name}() is called in a load/handler, outside component initialisation` : `${name}() runs outside component initialisation (module evaluation or the init hook)`;
4513
+ return name === "onDestroy" ? `${where} \u2014 on the server it still crashes, but with a plain TypeError, not lifecycle_outside_component (onDestroy has no component-context guard there)` : `${where} \u2014 on the server this is a silent no-op (it throws lifecycle_outside_component only if this module also runs in the browser)`;
4514
+ }
4515
+ return inHandler ? `${name}() is called in a load/handler \u2014 it runs on every request, outside component initialisation, and throws lifecycle_outside_component at runtime` : `${name}() runs outside component initialisation (module evaluation or the init hook) \u2014 it throws lifecycle_outside_component at runtime`;
4516
+ }
4408
4517
  function isSuppressed3(suppressions, line) {
4409
4518
  return (suppressions ?? []).some((s) => s.line === line && (!s.ruleIds || s.ruleIds.includes(ID)));
4410
4519
  }
@@ -4417,6 +4526,9 @@ function emitFile(out, file, issues, suppressions) {
4417
4526
  severity: "critical",
4418
4527
  detection: PASS4,
4419
4528
  route: file,
4529
+ // Uniform PASS-result attribution (design 2026-08-08-pass-result-location-design.md):
4530
+ // same location a penalized result for this file would carry.
4531
+ location: file,
4420
4532
  message: LABEL,
4421
4533
  recommendation: RECOMMENDATION,
4422
4534
  docsUrl: DOCS_URL
@@ -4444,7 +4556,7 @@ var correctnessOrphanLifecycle = {
4444
4556
  category: "correctness",
4445
4557
  severity: "critical",
4446
4558
  scope: "component",
4447
- rationale: "Svelte lifecycle and context functions require an active component context; called at module scope, in a shared-state class constructor, or in a load/handler they throw lifecycle_outside_component at runtime \u2014 the compiler does not catch it, and it surfaces as a production crash.",
4559
+ rationale: "Svelte lifecycle and context functions require an active component context; called at module scope, in a shared-state class constructor, or in a load/handler they throw lifecycle_outside_component at runtime \u2014 the compiler does not catch it, and it surfaces as a production crash. Exception: in a Kit module that only ever runs on the server (+page.server.ts, +server.ts, hooks.server.ts), onMount/beforeUpdate/afterUpdate/createEventDispatcher are silent no-ops there instead, and onDestroy throws a plain TypeError rather than lifecycle_outside_component \u2014 only getContext/setContext/hasContext/getAllContexts still throw in that channel.",
4448
4560
  async check(ctx) {
4449
4561
  const out = [];
4450
4562
  for (const c of ctx.components ?? []) {
@@ -4468,7 +4580,7 @@ var correctnessOrphanLifecycle = {
4468
4580
  m.file,
4469
4581
  calls.map((l) => ({
4470
4582
  line: l.line,
4471
- message: l.inHandler ? `${l.name}() is called in a load/handler \u2014 it runs on every request, outside component initialisation, and throws lifecycle_outside_component at runtime` : `${l.name}() runs outside component initialisation (module evaluation or the init hook) \u2014 it throws lifecycle_outside_component at runtime`
4583
+ message: kitLifecycleMessage(l.name, m.kind, l.inHandler)
4472
4584
  })),
4473
4585
  m.suppressions
4474
4586
  );
@@ -4508,6 +4620,9 @@ function emitFile2(out, file, links, suppressions) {
4508
4620
  severity: "warning",
4509
4621
  detection: PASS5,
4510
4622
  route: file,
4623
+ // Uniform PASS-result attribution (design 2026-08-08-pass-result-location-design.md):
4624
+ // same location a penalized result for this file would carry.
4625
+ location: file,
4511
4626
  message: LABEL2,
4512
4627
  recommendation: RECOMMENDATION2,
4513
4628
  docsUrl: DOCS_URL2
@@ -4575,6 +4690,9 @@ function emitFile3(out, file, issues, suppressions) {
4575
4690
  severity: "critical",
4576
4691
  detection: PASS6,
4577
4692
  route: file,
4693
+ // Uniform PASS-result attribution (design 2026-08-08-pass-result-location-design.md):
4694
+ // same location a penalized result for this file would carry.
4695
+ location: file,
4578
4696
  message: LABEL3,
4579
4697
  recommendation: RECOMMENDATION3,
4580
4698
  docsUrl: DOCS_URL3
@@ -4653,7 +4771,7 @@ var securityRawHtml = componentRule({
4653
4771
  title: "Raw HTML render",
4654
4772
  category: "security",
4655
4773
  label: "{@html} usage",
4656
- recommendation: "Sanitize the value before {@html} (e.g. DOMPurify), or render it as text/markup instead.",
4774
+ recommendation: "Sanitize the value before {@html} (e.g. DOMPurify), or render it as text/markup instead. A sanitizer keeps {@html} in the source, so the finding persists by design \u2014 once reviewed, suppress it with the inline directive.",
4657
4775
  rationale: "{@html} renders its value as unescaped HTML; if the value can contain user input and is not sanitized, it is a cross-site-scripting (XSS) vector.",
4658
4776
  applies: (c) => c.htmlTags.length > 0,
4659
4777
  bad: (c) => c.htmlTags.map((h) => ({ line: h.line, message: "{@html} renders unescaped HTML \u2014 ensure it is sanitized" }))
@@ -4666,7 +4784,7 @@ var securityJavascriptUrl = componentRule({
4666
4784
  category: "security",
4667
4785
  label: "No javascript: URLs",
4668
4786
  recommendation: "Use an event handler or a real URL instead of a javascript: URL.",
4669
- rationale: "A javascript: URL in href/src/action executes arbitrary script on activation \u2014 an XSS / unsafe-navigation vector that also breaks under a strict Content-Security-Policy.",
4787
+ rationale: "A javascript: URL in href/src/action/formaction breaks under a strict Content-Security-Policy and turns what should be a real navigation into inline script execution on activation \u2014 use an event handler on a <button> instead (the same shape is also a classic XSS vector, though detection here is literal-only, so every flagged URL is author-written, not injected).",
4670
4788
  applies: (c) => c.javascriptUrls.length > 0,
4671
4789
  bad: (c) => c.javascriptUrls.map((u) => ({ line: u.line, message: "javascript: URL in an attribute" }))
4672
4790
  });
@@ -4678,12 +4796,12 @@ var securityHandlerStateWrite = kitModuleRule({
4678
4796
  category: "security",
4679
4797
  severity: "critical",
4680
4798
  label: "Load/handler purity",
4681
- recommendation: "Return the data from load (or the action) and pass it via page data instead of writing it to module state; per-user data belongs in cookies/locals plus a database.",
4682
- rationale: "SvelteKit's docs mark this NEVER-DO-THIS: the server is one long-lived process shared by every user, so module state written during a request is visible to ALL later requests \u2014 one user's data can be served to another.",
4683
- applies: (m) => m.importedStateWrites.length > 0,
4799
+ recommendation: "Return the data from load (or the action) and pass it via page data instead of writing it to module state; per-user data belongs in cookies/locals plus a database. Caches and rate limiters keyed by non-personal data are the benign shape \u2014 if that describes this write, add an inline suppression.",
4800
+ rationale: "SvelteKit's docs mark this NEVER-DO-THIS: the server is one long-lived process shared by every user, so module state written during a request is visible to ALL later requests.",
4801
+ applies: (m) => m.importedStateWrites.length > 0 && !(m.kind === "universal" && m.ssrDisabled),
4684
4802
  bad: (m) => m.importedStateWrites.map((w) => ({
4685
4803
  line: w.line,
4686
- message: `a server-executed handler writes imported module state "${w.name}" \u2014 shared across all requests on the server, one user's data can leak to another`
4804
+ message: `a server-executed handler writes imported module state "${w.name}" \u2014 module state is shared across all requests on the server; if this holds per-request or per-user data, one user's data can leak to another. Caches and rate limiters keyed by non-personal data are the benign shape \u2014 verify which this is`
4687
4805
  }))
4688
4806
  });
4689
4807
 
@@ -4713,7 +4831,7 @@ var securitySharedStateImport = kitModuleRule({
4713
4831
  label: "Server state imports",
4714
4832
  recommendation: "Keep module-scope $state out of server-executed code: return data from load and share it via page data or the context API. If the module is genuinely client-only, restructure so server files do not import it, or add an inline suppression.",
4715
4833
  rationale: "A .svelte.ts module with module-scope $state is one shared instance on the server: mutated, it leaks data between users; read-only, every request sees the same boot-time value instead of per-user data.",
4716
- applies: (m) => m.runesModuleImports.length > 0,
4834
+ applies: (m) => m.runesModuleImports.length > 0 && !(m.kind === "universal" && m.ssrDisabled),
4717
4835
  bad: (m, ctx) => {
4718
4836
  const stateFiles = new Set((ctx.components ?? []).filter((c) => c.moduleStateDecls.length > 0).map((c) => c.file));
4719
4837
  const writtenOutside = new Set(m.importedStateWritesOutsideHandlers.map((w) => w.name));
@@ -4773,7 +4891,7 @@ var architecturePropCount = componentRule({
4773
4891
 
4774
4892
  // src/rules/architecture/private-scope-import.ts
4775
4893
  var docsUrl7 = docsUrlFor("architecture/private-scope-import");
4776
- var recommendation7 = "Move the unit to the directory shared by all of its importers, or import it only from inside its own scope.";
4894
+ var recommendation5 = "Move the unit to the directory shared by all of its importers, or import it only from inside its own scope.";
4777
4895
  var OPTIONS2 = { scopes: { kind: "string-list", default: [] } };
4778
4896
  function ancestorDirs(file) {
4779
4897
  const segments = file.split("/");
@@ -4853,8 +4971,13 @@ var architecturePrivateScopeImport = {
4853
4971
  severity: "info",
4854
4972
  detection: { presence: "own", value: "static" },
4855
4973
  route: c.file,
4974
+ // Same location the penalized branch below uses (design
4975
+ // 2026-08-08-pass-result-location-design.md) — this uncaught inline PASS literal
4976
+ // was missed by the design spike's grep and added to its blast-radius table
4977
+ // afterward (maintainer ruling, same date).
4978
+ location: c.file,
4856
4979
  message: "No private-scope imports",
4857
- recommendation: recommendation7,
4980
+ recommendation: recommendation5,
4858
4981
  docsUrl: docsUrl7
4859
4982
  });
4860
4983
  continue;
@@ -4869,7 +4992,7 @@ var architecturePrivateScopeImport = {
4869
4992
  location: c.file,
4870
4993
  ...v.line > 0 ? { line: v.line } : {},
4871
4994
  message: v.message,
4872
- recommendation: recommendation7,
4995
+ recommendation: recommendation5,
4873
4996
  docsUrl: docsUrl7,
4874
4997
  fix: { ...architecturePrivateScopeImport.fix }
4875
4998
  });
@@ -4994,7 +5117,7 @@ function classifyUnusedKeys(unused, excludedDirs, compile) {
4994
5117
  // src/rules/architecture/unit-entry-file.ts
4995
5118
  var ID4 = "architecture/unit-entry-file";
4996
5119
  var docsUrl8 = docsUrlFor(ID4);
4997
- var recommendation8 = "Give every declared unit directory a file named after it, or stop declaring that directory a unit.";
5120
+ var recommendation6 = "Give every declared unit directory a file named after it, or stop declaring that directory a unit.";
4998
5121
  var OPTIONS3 = {
4999
5122
  units: { kind: "string-map", default: {} },
5000
5123
  pascalCaseUnits: { kind: "string-map", default: {} },
@@ -5031,6 +5154,8 @@ var architectureUnitEntryFile = {
5031
5154
  ...Object.keys(mapOption(globalOptions, "pascalCaseUnits"))
5032
5155
  ]);
5033
5156
  const usedKeys = /* @__PURE__ */ new Set();
5157
+ const examinedCounts = {};
5158
+ for (const key of globalKeys) examinedCounts[key] = 0;
5034
5159
  const excludedDirs = [];
5035
5160
  const matchedSurviving = /* @__PURE__ */ new Set();
5036
5161
  for (const dir of [...dirs].sort()) {
@@ -5058,6 +5183,10 @@ var architectureUnitEntryFile = {
5058
5183
  ext = byCasing.best === void 0 ? void 0 : pascalUnits[byCasing.best];
5059
5184
  }
5060
5185
  if (ext === void 0) continue;
5186
+ const winningKey = viaUnits ? byPath.best : byCasing.best;
5187
+ if (winningKey !== void 0 && globalKeys.has(winningKey)) {
5188
+ examinedCounts[winningKey] = (examinedCounts[winningKey] ?? 0) + 1;
5189
+ }
5061
5190
  const expected = `${dir}/${baseName(dir)}${ext}`;
5062
5191
  if (fileSet.has(expected)) {
5063
5192
  out.push({
@@ -5067,7 +5196,7 @@ var architectureUnitEntryFile = {
5067
5196
  detection: { presence: "own", value: "static" },
5068
5197
  location: expected,
5069
5198
  message: "Unit entry file",
5070
- recommendation: recommendation8,
5199
+ recommendation: recommendation6,
5071
5200
  docsUrl: docsUrl8
5072
5201
  });
5073
5202
  continue;
@@ -5082,7 +5211,7 @@ var architectureUnitEntryFile = {
5082
5211
  route: dir,
5083
5212
  location: at,
5084
5213
  message: `${dir} declares a unit but has no ${expected}`,
5085
- recommendation: recommendation8,
5214
+ recommendation: recommendation6,
5086
5215
  docsUrl: docsUrl8,
5087
5216
  // Which declaration matched decides the wording: a `units` match like functions/getFoo/
5088
5217
  // is already camelCase, so telling its author to rename it would be nonsense.
@@ -5107,6 +5236,7 @@ var architectureUnitEntryFile = {
5107
5236
  docsUrl: docsUrl8
5108
5237
  });
5109
5238
  }
5239
+ ctx.recordExamined?.(examinedCounts);
5110
5240
  return out;
5111
5241
  }
5112
5242
  };
@@ -5146,7 +5276,7 @@ function satisfiesCasing(name, allowed) {
5146
5276
  // src/rules/architecture/directory-naming.ts
5147
5277
  var ID5 = "architecture/directory-naming";
5148
5278
  var docsUrl9 = docsUrlFor(ID5);
5149
- var recommendation9 = "Name each directory in the casing its location declares, or narrow the declaration.";
5279
+ var recommendation7 = "Name each directory in the casing its location declares, or narrow the declaration.";
5150
5280
  var OPTIONS4 = {
5151
5281
  directories: { kind: "string-map", default: {} },
5152
5282
  exclude: { kind: "string-list", default: [] }
@@ -5180,6 +5310,8 @@ var architectureDirectoryNaming = {
5180
5310
  const globalOptions = resolveRuleOptions(ID5, OPTIONS4, ctx.config);
5181
5311
  const globalMap = mapOption(globalOptions, "directories");
5182
5312
  const globalKeys = new Set(Object.keys(globalMap));
5313
+ const examinedCounts = {};
5314
+ for (const key of globalKeys) examinedCounts[key] = 0;
5183
5315
  const usedKeys = /* @__PURE__ */ new Set();
5184
5316
  const excludedDirs = [];
5185
5317
  for (const dir of [...dirs].sort()) {
@@ -5197,6 +5329,7 @@ var architectureDirectoryNaming = {
5197
5329
  if (m.best === void 0) continue;
5198
5330
  const decoded = decodeSegment(baseName(dir));
5199
5331
  if (decoded === void 0) continue;
5332
+ if (globalKeys.has(m.best)) examinedCounts[m.best] = (examinedCounts[m.best] ?? 0) + 1;
5200
5333
  const allowed = casingsOf(declared[m.best]).known;
5201
5334
  if (satisfiesCasing(decoded, allowed)) continue;
5202
5335
  const at = reportAt(dir, files);
@@ -5209,7 +5342,7 @@ var architectureDirectoryNaming = {
5209
5342
  route: dir,
5210
5343
  location: at,
5211
5344
  message: `${dir} must be ${allowed.join(" or ")}.`,
5212
- recommendation: recommendation9,
5345
+ recommendation: recommendation7,
5213
5346
  docsUrl: docsUrl9,
5214
5347
  fix: { description: "Rename the directory, or narrow the declaration that governs it." }
5215
5348
  });
@@ -5244,6 +5377,7 @@ var architectureDirectoryNaming = {
5244
5377
  docsUrl: docsUrl9
5245
5378
  });
5246
5379
  }
5380
+ ctx.recordExamined?.(examinedCounts);
5247
5381
  return out;
5248
5382
  }
5249
5383
  };
@@ -5251,10 +5385,11 @@ var architectureDirectoryNaming = {
5251
5385
  // src/rules/architecture/reserved-directory-names.ts
5252
5386
  var ID6 = "architecture/reserved-directory-names";
5253
5387
  var docsUrl10 = docsUrlFor(ID6);
5254
- var recommendation10 = "Use one of the names this location declares, or add the new name to the declaration.";
5388
+ var recommendation8 = "Use one of the names this location declares, or add the new name to the declaration.";
5255
5389
  var OPTIONS5 = {
5256
5390
  scopes: { kind: "string-map", default: {} },
5257
5391
  unitScopes: { kind: "string-map", default: {} },
5392
+ anyCaseUnitScopes: { kind: "string-map", default: {} },
5258
5393
  exclude: { kind: "string-list", default: [] }
5259
5394
  };
5260
5395
  function stem(file) {
@@ -5270,6 +5405,7 @@ function isAnyCaseUnitDir(dir, filesIn) {
5270
5405
  const own = filesIn.get(dir);
5271
5406
  return own !== void 0 && own.some((f) => stem(f) === name);
5272
5407
  }
5408
+ var PRIORITY = { scopes: 0, unitScopes: 1, anyCaseUnitScopes: 2 };
5273
5409
  var architectureReservedDirectoryNames = {
5274
5410
  id: ID6,
5275
5411
  title: "Reserved directory names",
@@ -5301,26 +5437,45 @@ var architectureReservedDirectoryNames = {
5301
5437
  const globalOptions = resolveRuleOptions(ID6, OPTIONS5, ctx.config);
5302
5438
  const globalScopes = mapOption(globalOptions, "scopes");
5303
5439
  const globalUnits = mapOption(globalOptions, "unitScopes");
5304
- const globalKeys = /* @__PURE__ */ new Set([...Object.keys(globalScopes), ...Object.keys(globalUnits)]);
5440
+ const globalAnyUnits = mapOption(globalOptions, "anyCaseUnitScopes");
5441
+ const globalKeys = /* @__PURE__ */ new Set([
5442
+ ...Object.keys(globalScopes),
5443
+ ...Object.keys(globalUnits),
5444
+ ...Object.keys(globalAnyUnits)
5445
+ ]);
5305
5446
  const usedKeys = /* @__PURE__ */ new Set();
5306
5447
  const excludedDirs = [];
5307
5448
  const nonUnitDirs = [];
5308
- const collisions = /* @__PURE__ */ new Set();
5309
- const noteCollisions = (scopesMap, unitMap) => {
5449
+ const nonAnyUnitDirs = [];
5450
+ const collisions = /* @__PURE__ */ new Map();
5451
+ const collisionMessage = (losers) => {
5452
+ const maps = ["scopes", ...losers];
5453
+ const list = maps.length === 2 ? `both ${maps[0]} and ${maps[1]}` : maps.join(", ");
5454
+ return `declared in ${list}, so the scopes entry wins wherever ${losers.length > 1 ? "they" : "both"} apply`;
5455
+ };
5456
+ const examinedCounts = {};
5457
+ for (const key of globalKeys) examinedCounts[key] = 0;
5458
+ const noteCollisions = (scopesMap, unitMap, anyUnitMap) => {
5310
5459
  for (const key of Object.keys(scopesMap)) {
5311
- if (!Object.hasOwn(unitMap, key)) continue;
5312
5460
  if (namesOf(scopesMap[key]).length === 0) continue;
5313
- if (namesOf(unitMap[key]).length === 0) continue;
5314
- collisions.add(key);
5461
+ const losers = [];
5462
+ if (Object.hasOwn(unitMap, key) && namesOf(unitMap[key]).length > 0) losers.push("unitScopes");
5463
+ if (Object.hasOwn(anyUnitMap, key) && namesOf(anyUnitMap[key]).length > 0) {
5464
+ losers.push("anyCaseUnitScopes");
5465
+ }
5466
+ if (losers.length > 0) collisions.set(key, collisionMessage(losers));
5315
5467
  }
5316
5468
  };
5317
- noteCollisions(globalScopes, globalUnits);
5469
+ noteCollisions(globalScopes, globalUnits, globalAnyUnits);
5318
5470
  for (const dir of [...dirs].sort()) {
5319
5471
  const o = resolveRuleOptions(ID6, OPTIONS5, ctx.config, { route: dir, file: dir }, compiledOverrides);
5320
5472
  const scopes = mapOption(o, "scopes");
5321
5473
  const unitScopes = mapOption(o, "unitScopes");
5322
- if (Object.keys(scopes).length === 0 && Object.keys(unitScopes).length === 0) continue;
5323
- noteCollisions(scopes, unitScopes);
5474
+ const anyCaseUnitScopes = mapOption(o, "anyCaseUnitScopes");
5475
+ if (Object.keys(scopes).length === 0 && Object.keys(unitScopes).length === 0 && Object.keys(anyCaseUnitScopes).length === 0) {
5476
+ continue;
5477
+ }
5478
+ noteCollisions(scopes, unitScopes, anyCaseUnitScopes);
5324
5479
  const excluded = compile(listOption(o, "exclude"));
5325
5480
  if (isExcluded(dir, ancestorDirs2(dir), excluded)) {
5326
5481
  excludedDirs.push(dir);
@@ -5328,22 +5483,46 @@ var architectureReservedDirectoryNames = {
5328
5483
  }
5329
5484
  const liveScopes = Object.keys(scopes).filter((k) => namesOf(scopes[k]).length > 0);
5330
5485
  const isUnit = isUnitDir(dir, filesIn);
5486
+ const isAnyUnit = isAnyCaseUnitDir(dir, filesIn);
5331
5487
  const liveUnits = isUnit ? Object.keys(unitScopes).filter((k) => namesOf(unitScopes[k]).length > 0) : [];
5488
+ const liveAnyUnits = isAnyUnit ? Object.keys(anyCaseUnitScopes).filter((k) => namesOf(anyCaseUnitScopes[k]).length > 0) : [];
5332
5489
  if (!isUnit) nonUnitDirs.push(dir);
5490
+ if (!isAnyUnit) nonAnyUnitDirs.push(dir);
5333
5491
  const byPosition = matchKeys(dir, compile(liveScopes, true));
5334
5492
  const byUnit = matchKeys(dir, compile(liveUnits, true));
5493
+ const byAnyUnit = matchKeys(dir, compile(liveAnyUnits, true));
5335
5494
  for (const k of byPosition.matched) if (globalKeys.has(k)) usedKeys.add(k);
5336
5495
  for (const k of byUnit.matched) if (globalKeys.has(k)) usedKeys.add(k);
5337
- let governing;
5338
- if (byPosition.best !== void 0 && byUnit.best !== void 0) {
5339
- governing = moreSpecificGlob(byUnit.best, byPosition.best) ? namesOf(unitScopes[byUnit.best]) : namesOf(scopes[byPosition.best]);
5340
- } else if (byPosition.best !== void 0) {
5341
- governing = namesOf(scopes[byPosition.best]);
5342
- } else if (byUnit.best !== void 0) {
5343
- governing = namesOf(unitScopes[byUnit.best]);
5496
+ for (const k of byAnyUnit.matched) if (globalKeys.has(k)) usedKeys.add(k);
5497
+ const candidates = [];
5498
+ if (byPosition.best !== void 0) {
5499
+ candidates.push({ kind: "scopes", best: byPosition.best, names: namesOf(scopes[byPosition.best]) });
5500
+ }
5501
+ if (byUnit.best !== void 0) {
5502
+ candidates.push({
5503
+ kind: "unitScopes",
5504
+ best: byUnit.best,
5505
+ names: namesOf(unitScopes[byUnit.best])
5506
+ });
5344
5507
  }
5345
- if (governing === void 0) continue;
5346
- const allowed = new Set(governing);
5508
+ if (byAnyUnit.best !== void 0) {
5509
+ candidates.push({
5510
+ kind: "anyCaseUnitScopes",
5511
+ best: byAnyUnit.best,
5512
+ names: namesOf(anyCaseUnitScopes[byAnyUnit.best])
5513
+ });
5514
+ }
5515
+ let winner;
5516
+ for (const c of candidates) {
5517
+ if (winner === void 0 || moreSpecificGlob(c.best, winner.best)) {
5518
+ winner = c;
5519
+ } else if (!moreSpecificGlob(winner.best, c.best) && PRIORITY[c.kind] < PRIORITY[winner.kind]) {
5520
+ winner = c;
5521
+ }
5522
+ }
5523
+ if (winner === void 0) continue;
5524
+ if (globalKeys.has(winner.best)) examinedCounts[winner.best] = (examinedCounts[winner.best] ?? 0) + 1;
5525
+ const allowed = new Set(winner.names);
5347
5526
  for (const child of kids.get(dir) ?? []) {
5348
5527
  if (allowed.has(baseName(child))) continue;
5349
5528
  if (isExcluded(child, ancestorDirs2(child), excluded)) continue;
@@ -5364,8 +5543,8 @@ var architectureReservedDirectoryNames = {
5364
5543
  detection: { presence: "none", value: "absent" },
5365
5544
  route: child,
5366
5545
  location: at,
5367
- message: `${child} is not one of the names declared here: ${governing.join(", ")}.`,
5368
- recommendation: recommendation10,
5546
+ message: `${child} is not one of the names declared here: ${winner.names.join(", ")}.`,
5547
+ recommendation: recommendation8,
5369
5548
  docsUrl: docsUrl10,
5370
5549
  fix: {
5371
5550
  description: "Rename it to a declared name, move it under one of them, or add its name to the declaration."
@@ -5374,19 +5553,24 @@ var architectureReservedDirectoryNames = {
5374
5553
  }
5375
5554
  }
5376
5555
  const notes = /* @__PURE__ */ new Map();
5377
- for (const key of collisions) {
5378
- notes.set(key, "declared in both scopes and unitScopes, so the scopes entry wins wherever both apply");
5379
- }
5556
+ for (const [key, message] of collisions) notes.set(key, message);
5380
5557
  for (const key of globalKeys) {
5381
5558
  if (notes.has(key)) continue;
5382
5559
  const scopesEmpty = Object.hasOwn(globalScopes, key) && namesOf(globalScopes[key]).length === 0;
5383
5560
  const unitsEmpty = Object.hasOwn(globalUnits, key) && namesOf(globalUnits[key]).length === 0;
5384
- if (scopesEmpty || unitsEmpty) {
5561
+ const anyUnitsEmpty = Object.hasOwn(globalAnyUnits, key) && namesOf(globalAnyUnits[key]).length === 0;
5562
+ if (scopesEmpty || unitsEmpty || anyUnitsEmpty) {
5385
5563
  notes.set(key, "names no directory name at all");
5386
5564
  }
5387
5565
  }
5388
5566
  const unused = [...globalKeys].filter((k) => !notes.has(k) && !usedKeys.has(k));
5389
- const unitOnly = unused.filter((k) => Object.hasOwn(globalUnits, k) && !Object.hasOwn(globalScopes, k));
5567
+ const anyUnitOnly = unused.filter((k) => Object.hasOwn(globalAnyUnits, k) && !Object.hasOwn(globalScopes, k));
5568
+ for (const key of keysMatchingAny(anyUnitOnly, nonAnyUnitDirs, compile)) {
5569
+ notes.set(key, "matched directories but never a unit of either case");
5570
+ }
5571
+ const unitOnly = unused.filter(
5572
+ (k) => Object.hasOwn(globalUnits, k) && !Object.hasOwn(globalScopes, k) && !notes.has(k)
5573
+ );
5390
5574
  for (const key of keysMatchingAny(unitOnly, nonUnitDirs, compile)) {
5391
5575
  notes.set(key, "matched directories but never a unit");
5392
5576
  }
@@ -5410,6 +5594,7 @@ var architectureReservedDirectoryNames = {
5410
5594
  docsUrl: docsUrl10
5411
5595
  });
5412
5596
  }
5597
+ ctx.recordExamined?.(examinedCounts);
5413
5598
  return out;
5414
5599
  }
5415
5600
  };
@@ -5417,7 +5602,7 @@ var architectureReservedDirectoryNames = {
5417
5602
  // src/rules/architecture/reserved-name-placement.ts
5418
5603
  var ID7 = "architecture/reserved-name-placement";
5419
5604
  var docsUrl11 = docsUrlFor(ID7);
5420
- var recommendation11 = "Move it to one of the places declared for this name, or declare this place for it.";
5605
+ var recommendation9 = "Move it to one of the places declared for this name, or declare this place for it.";
5421
5606
  var fixDescription = "Move the directory to one of the places declared for its name, rename it, or declare this place for the name.";
5422
5607
  var OPTIONS6 = {
5423
5608
  placements: { kind: "string-map", default: {} },
@@ -5535,7 +5720,7 @@ var architectureReservedNamePlacement = {
5535
5720
  route: dir,
5536
5721
  location: at,
5537
5722
  message: `${dir} is not one of the places declared for '${name}'.`,
5538
- recommendation: recommendation11,
5723
+ recommendation: recommendation9,
5539
5724
  docsUrl: docsUrl11,
5540
5725
  fix: {
5541
5726
  description: fixDescription
@@ -5749,8 +5934,8 @@ var performanceNamespaceImport = componentRule({
5749
5934
  // src/rules/perf/minify-disabled.ts
5750
5935
  var PENALIZED7 = { presence: "none", value: "absent" };
5751
5936
  var MINIFY_DISABLED_FIX = {
5752
- description: "Remove the minify: false override from vite.config (Vite minifies with esbuild by default), or scope it to non-production builds.",
5753
- snippet: "export default defineConfig({\n build: {\n minify: 'esbuild'\n }\n});",
5937
+ description: "Remove the minify: false override from vite.config (Vite minifies by default), or scope it to non-production builds.",
5938
+ snippet: "export default defineConfig({\n build: {\n // minify: false \u2014 removed; Vite minifies production builds by default\n }\n});",
5754
5939
  lang: "ts"
5755
5940
  };
5756
5941
  var RECOMMENDATION4 = "Remove build.minify: false from vite.config, or scope it to non-production builds if it is intentional.";
@@ -6330,7 +6515,7 @@ function formatAgentReport(results, config) {
6330
6515
  return lines.join("\n").replace(/\n+$/, "\n");
6331
6516
  }
6332
6517
  lines.push(
6333
- `${failing.length} issue(s) to fix, ordered most-severe first. Fix critical issues first; warning and info items improve SEO but do not fail the default build. Apply each fix below, then re-run \`svelte-vitals\` (or the build) to confirm each rule passes.`,
6518
+ `${failing.length} issue(s) to fix, ordered most-severe first. Fix critical issues first; warning and info items improve SEO but do not fail the default build. Apply each fix below, then re-run \`svelte-vitals\` (or the build) to confirm each rule passes. Run \`svelte-vitals explain <rule-id>\` for any rule's rationale and options (works offline).`,
6334
6519
  ""
6335
6520
  );
6336
6521
  const groups = /* @__PURE__ */ new Map();
@@ -6612,6 +6797,7 @@ body{background:var(--ground);color:var(--ink);font-family:var(--sans);line-heig
6612
6797
  .dv-cat-top{display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px}
6613
6798
  .dv-bar{height:7px;border-radius:999px;background:var(--line);overflow:hidden}
6614
6799
  .dv-bar>i{display:block;height:100%;border-radius:999px}
6800
+ .dv-cat-reach{font-size:11.5px;color:var(--muted);margin-top:5px}
6615
6801
  .dv-filters{display:flex;gap:8px;flex-wrap:wrap;margin:16px 0}
6616
6802
  .dv-chip{font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;background:var(--panel);border:1px solid var(--line-strong);color:var(--muted);padding:5px 12px;border-radius:999px}
6617
6803
  .dv-chip[aria-pressed="true"]{background:var(--active-bg);border-color:var(--active-bg);color:var(--active-ink)}
@@ -7084,12 +7270,21 @@ var APP_SCRIPT = `
7084
7270
  var band = scoreBand(c.score);
7085
7271
  var weight = s.report.weights[cat];
7086
7272
  var name = cat === 'seo' ? 'SEO' : cat.charAt(0).toUpperCase() + cat.slice(1);
7273
+ // keys/affectedKeys are absent on hand-built snapshots (older fixtures, tests) \u2014
7274
+ // render nothing rather than "undefined of undefined". 0 affected of N keys is still
7275
+ // rendered: on a real project that's the signal a thin score can't give, that the
7276
+ // category is clean project-wide and not just on the one key it happened to look at
7277
+ // (design: 2026-08-05-score-floor-and-reach-design.md).
7278
+ var reach = typeof c.keys === 'number' && c.keys > 0
7279
+ ? h('div', { class: 'dv-cat-reach', text: c.affectedKeys + ' of ' + c.keys + ' keys affected' }, [])
7280
+ : null;
7087
7281
  return h('div', { class: 'dv-cat' }, [
7088
7282
  h('div', { class: 'dv-cat-top' }, [
7089
7283
  h('span', { text: name + (weight !== undefined ? ' (weight ' + weight + ')' : '') }, []),
7090
7284
  h('span', { style: 'color:' + BAND_COLOR[band], text: String(c.score) }, [])
7091
7285
  ]),
7092
- h('div', { class: 'dv-bar' }, [h('i', { style: 'width:' + c.score + '%;background:' + BAND_COLOR[band] }, [])])
7286
+ h('div', { class: 'dv-bar' }, [h('i', { style: 'width:' + c.score + '%;background:' + BAND_COLOR[band] }, [])]),
7287
+ reach
7093
7288
  ]);
7094
7289
  });
7095
7290
  var chips = renderFilterChips(s.report.categories);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-vitals/core",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "description": "Shared, runtime-agnostic core for svelte-vitals (types, rule engine, scorer, reporter).",
5
5
  "type": "module",
6
6
  "license": "MIT",