svelte-vitals 0.48.1 → 0.50.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/bin.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runAnalyzeCliGunshi
4
- } from "./chunk-4LSRQXJ6.js";
4
+ } from "./chunk-QKULNWQ5.js";
5
5
  import "./chunk-DZVXCUHG.js";
6
6
  import {
7
7
  realIO
8
8
  } from "./chunk-RSSMIHIM.js";
9
- import "./chunk-LJPABOWU.js";
9
+ import "./chunk-PNHWRKRD.js";
10
10
  import "./chunk-WGFXFKBB.js";
11
11
  import {
12
12
  consoleIO
@@ -21,7 +21,7 @@ async function runCli(argv, io = consoleIO, env = process.env) {
21
21
  try {
22
22
  const locale = resolveLocale(env);
23
23
  if (argv[0] === "complete") {
24
- const { runCompleteCliGunshi } = await import("./complete-IOAMJRBY.js");
24
+ const { runCompleteCliGunshi } = await import("./complete-5VANQTDN.js");
25
25
  return { code: await runCompleteCliGunshi(argv, io), exit: "natural" };
26
26
  }
27
27
  if (argv[0] === "docs") {
@@ -29,7 +29,7 @@ async function runCli(argv, io = consoleIO, env = process.env) {
29
29
  return { code: await runDocsCliGunshi(argv.slice(1), io, locale), exit: "natural" };
30
30
  }
31
31
  if (argv[0] === "explain") {
32
- const { runExplainCliGunshi } = await import("./explain-WOKUZJVY.js");
32
+ const { runExplainCliGunshi } = await import("./explain-PRCEATL4.js");
33
33
  return { code: await runExplainCliGunshi(argv.slice(1), io, locale), exit: "natural" };
34
34
  }
35
35
  if (argv[0] === "install") {
@@ -26,7 +26,11 @@ function describeOptions(id, options) {
26
26
  "string-map": "merged over the default entries \u2014 a new key is added, a built-in key has its value overridden"
27
27
  };
28
28
  const lines = options.map((o) => {
29
- const bounds = [o.min !== void 0 ? `>= ${o.min}` : "", o.max !== void 0 ? `<= ${o.max}` : ""].filter(Boolean).join(", ");
29
+ const bounds = [
30
+ o.min !== void 0 ? `>= ${o.min}` : "",
31
+ o.max !== void 0 ? `<= ${o.max}` : "",
32
+ o.pattern ? `each entry ${o.pattern}` : ""
33
+ ].filter(Boolean).join(", ");
30
34
  return `- ${o.name} (${o.kind}, default ${JSON.stringify(o.default)}${bounds ? `, ${bounds}` : ""}) \u2014 ${MERGE[o.kind]}`;
31
35
  });
32
36
  return `set in svelte-vitals.config.* as \`rules: { '${id}': { options: { \u2026 } } }\`, or per path in \`overrides\`:
@@ -34,12 +34,34 @@ import {
34
34
  selectRules,
35
35
  applyRuleSeverities,
36
36
  applyOverrides,
37
+ applyInlineDirectives,
38
+ unknownDirectiveIds,
37
39
  settingSeverity,
38
40
  withFailedRulesOff,
39
41
  formatFailedRuleWarning,
40
42
  terminalSafe
41
43
  } from "@svelte-vitals/core/internal";
42
44
 
45
+ // src/a11y-skips.ts
46
+ var ID_REF_RULE = "a11y/no-missing-id-ref";
47
+ function buildIdRefSkips(a11y) {
48
+ return a11y.filter((r) => !r.fullyResolved).map((r) => ({ route: r.route, refs: r.idRefs.length, causes: r.unresolvedCauses ?? [] })).sort((a, b) => a.route.localeCompare(b.route));
49
+ }
50
+ var KIND_LABELS = [
51
+ ["component", "unresolved component"],
52
+ ["spread", "spread"],
53
+ ["html", "{@html}"],
54
+ ["dynamic-id", "dynamic id"]
55
+ ];
56
+ function idRefSkipWarning(entries, analyzedRoutes) {
57
+ const parts = [];
58
+ for (const [kind, label] of KIND_LABELS) {
59
+ const n = entries.filter((e) => e.causes.some((c) => c.kind === kind)).length;
60
+ if (n > 0) parts.push(`${label} ${n}`);
61
+ }
62
+ return `${ID_REF_RULE} skipped ${entries.length} of ${analyzedRoutes} analyzed route(s) (${parts.join(", ")} \u2014 per-route detail in the JSON report's "skipped").`;
63
+ }
64
+
43
65
  // src/runtime/node.ts
44
66
  import { readFile, access } from "fs/promises";
45
67
  import { join } from "path";
@@ -75,7 +97,9 @@ import {
75
97
  SITEMAP_SOURCE_PATHS,
76
98
  SVELTE_CONFIG_FILES,
77
99
  VITE_CONFIG_FILES,
100
+ collectSuppressions,
78
101
  findMinifyDisabled,
102
+ lineOf,
79
103
  resolveKitAliases,
80
104
  resolveKitPathsBase
81
105
  } from "@svelte-vitals/core/internal";
@@ -152,10 +176,28 @@ function detectHtmlLang(html) {
152
176
  const value = match[1] ?? match[2] ?? match[3] ?? "";
153
177
  return { presence: "own", value: value.trim().length > 0 ? "static" : "absent" };
154
178
  }
179
+ function detectAppHtmlBodyTags(html) {
180
+ let markup = html.replace(/<!--[\s\S]*?-->/g, "").replace(/<script[\s\S]*?<\/script\s*>/gi, "").replace(/<style[\s\S]*?<\/style\s*>/gi, "");
181
+ const innermost = /<template\b[^>]*>(?:(?!<template\b)[\s\S])*?<\/template\s*>/gi;
182
+ for (let prev = ""; prev !== markup; ) {
183
+ prev = markup;
184
+ markup = markup.replace(innermost, "\0");
185
+ }
186
+ markup = markup.replaceAll("\0", "<template></template>");
187
+ const body = /<body\b[^>]*>([\s\S]*?)(?:<\/body\s*>|$)/i.exec(markup)?.[1];
188
+ if (body === void 0) return [];
189
+ return [...new Set([...body.matchAll(/<([a-zA-Z][a-zA-Z0-9-]*)\b/g)].map((m) => m[1].toLowerCase()))];
190
+ }
155
191
  function detectAppHtmlIds(html) {
156
- const markup = html.replace(/<!--[\s\S]*?-->/g, "").replace(/<script[\s\S]*?<\/script\s*>/gi, "").replace(/<style[\s\S]*?<\/style\s*>/gi, "");
192
+ const keepNewlines = (m) => m.replace(/[^\n]/g, "");
193
+ const markup = html.replace(/<!--[\s\S]*?-->/g, keepNewlines).replace(/<script[\s\S]*?<\/script\s*>/gi, keepNewlines).replace(/<style[\s\S]*?<\/style\s*>/gi, keepNewlines);
157
194
  const found = markup.matchAll(/(?<![\w-])id\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>{][^\s"'>]*))/gi);
158
- return [...new Set([...found].map((m) => m[1] ?? m[2] ?? m[3] ?? "").filter(Boolean))];
195
+ const out = /* @__PURE__ */ new Map();
196
+ for (const m of found) {
197
+ const id = m[1] ?? m[2] ?? m[3] ?? "";
198
+ if (id && !out.has(id)) out.set(id, lineOf(markup, m.index));
199
+ }
200
+ return [...out].map(([id, line]) => ({ id, line }));
159
201
  }
160
202
  async function detectAppHtmlFacts(rt, cwd) {
161
203
  const appHtmlPath = rt.join(cwd, "src/app.html");
@@ -172,7 +214,8 @@ async function detectAppHtmlFacts(rt, cwd) {
172
214
  // [\s\S]*? is ambiguous across iterations and backtracks exponentially on a comment run
173
215
  // with no doctype (measured: ~45 leading comments hang the process).
174
216
  appHtmlDoctype: /^\s*<!doctype\s+html/i.test(content.replace(/<!--[\s\S]*?-->/g, "")),
175
- appHtmlIds: detectAppHtmlIds(content)
217
+ appHtmlIds: detectAppHtmlIds(content),
218
+ appHtmlBodyTags: detectAppHtmlBodyTags(content)
176
219
  };
177
220
  }
178
221
  async function robotsRefsSitemap(rt, cwd) {
@@ -189,8 +232,9 @@ async function detectViteMinifyDisabled(rt, cwd) {
189
232
  const file = VITE_CONFIG_FILES[exists.indexOf(true)];
190
233
  if (!file) return void 0;
191
234
  try {
192
- const hit = findMinifyDisabled(await rt.readFile(rt.join(cwd, file)));
193
- return hit ? { file, line: hit.line } : void 0;
235
+ const source = await rt.readFile(rt.join(cwd, file));
236
+ const hit = findMinifyDisabled(source);
237
+ return hit ? { file, line: hit.line, suppressions: collectSuppressions(source) } : void 0;
194
238
  } catch {
195
239
  return void 0;
196
240
  }
@@ -243,7 +287,12 @@ import "@svelte-vitals/core";
243
287
  import {
244
288
  collectComponentFacts,
245
289
  collectKitModuleFacts,
246
- collectSourceFiles
290
+ collectSourceFiles,
291
+ compileOverrides,
292
+ ROBOTS_SOURCE_PATHS as ROBOTS_SOURCE_PATHS2,
293
+ SITEMAP_SOURCE_PATHS as SITEMAP_SOURCE_PATHS2,
294
+ SVELTE_CONFIG_FILES as SVELTE_CONFIG_FILES2,
295
+ VITE_CONFIG_FILES as VITE_CONFIG_FILES2
247
296
  } from "@svelte-vitals/core/internal";
248
297
 
249
298
  // src/providers/source/routes.ts
@@ -387,10 +436,11 @@ function findAdapter(info) {
387
436
 
388
437
  // src/providers/source/parse.ts
389
438
  import {
439
+ collectSuppressions as collectSuppressions2,
390
440
  stripTextDirective,
391
441
  parseSvelte,
392
442
  CHILD_NODE_KEYS,
393
- lineOf,
443
+ lineOf as lineOf2,
394
444
  findAttr as findAttr3,
395
445
  valueFromNodes,
396
446
  textFromNodes,
@@ -568,7 +618,7 @@ function collectImages(node, source, acc) {
568
618
  // A literal loading="lazy" only — a spread or dynamic loading={…} must not be flagged.
569
619
  lazy: attrText(attrs, "loading") === "lazy",
570
620
  hasSrcset: hasSpread || Boolean(findAttr3(attrs, "srcset")),
571
- line: lineOf(source, node.start)
621
+ line: lineOf2(source, node.start)
572
622
  });
573
623
  }
574
624
  for (const key of CHILD_NODE_KEYS) {
@@ -583,7 +633,7 @@ function collectHeadings(node, source, acc) {
583
633
  if (!node || typeof node !== "object") return;
584
634
  if (node.type === "SvelteHead") return;
585
635
  if (node.type === "RegularElement" && /^h[1-6]$/.test(node.name)) {
586
- acc.push({ level: Number(node.name[1]), line: lineOf(source, node.start) });
636
+ acc.push({ level: Number(node.name[1]), line: lineOf2(source, node.start) });
587
637
  }
588
638
  for (const key of CHILD_NODE_KEYS) {
589
639
  if (key in node) collectHeadings(childOf(node, key), source, acc);
@@ -604,16 +654,18 @@ function collectA11y(fragment, source) {
604
654
  const nodes = [];
605
655
  let groups = 0;
606
656
  let slotInLandmark;
607
- let unknowableContent = false;
657
+ const unknowable = [];
658
+ const elementTags = /* @__PURE__ */ new Set();
659
+ let elementsUnknowable = false;
608
660
  const emit = (ctx, node) => {
609
661
  const inLandmark = ctx.landmarks.at(-1);
610
662
  nodes.push({ ...node, repeatable: ctx.repeatable, path: ctx.path, ...inLandmark ? { inLandmark } : {} });
611
663
  };
612
664
  const noteSpread = (node) => {
613
665
  const attributes = node.attributes;
614
- if (Array.isArray(attributes) && attributes.some((a) => a.type === "SpreadAttribute")) {
615
- unknowableContent = true;
616
- }
666
+ if (!Array.isArray(attributes)) return;
667
+ const spread = attributes.find((a) => a.type === "SpreadAttribute");
668
+ if (spread) unknowable.push({ kind: "spread", line: lineOf2(source, spread.start) });
617
669
  };
618
670
  const walk = (node, ctx) => {
619
671
  if (Array.isArray(node)) {
@@ -626,7 +678,8 @@ function collectA11y(fragment, source) {
626
678
  return;
627
679
  // head content never renders into the body
628
680
  case "HtmlTag":
629
- unknowableContent = true;
681
+ unknowable.push({ kind: "html", line: lineOf2(source, node.start) });
682
+ elementsUnknowable = true;
630
683
  return;
631
684
  case "IfBlock":
632
685
  walkIfChain(node, ctx, groups++, 0);
@@ -649,13 +702,15 @@ function collectA11y(fragment, source) {
649
702
  // attributes are real — dropping them would make no-missing-id-ref report phantom misses.
650
703
  case "RegularElement":
651
704
  case "SvelteElement":
705
+ if (node.type === "SvelteElement") elementsUnknowable = true;
706
+ else elementTags.add(node.name.toLowerCase());
652
707
  walkElement(node, ctx);
653
708
  return;
654
709
  case "Component":
655
710
  case "SvelteComponent":
656
711
  case "SvelteSelf":
657
712
  noteSpread(node);
658
- emit(ctx, { kind: "component", key: node.name, line: lineOf(source, node.start) });
713
+ emit(ctx, { kind: "component", key: node.name, line: lineOf2(source, node.start) });
659
714
  walk(node.fragment, { ...ctx, elementDepth: ctx.elementDepth + 1 });
660
715
  return;
661
716
  case "SlotElement":
@@ -683,7 +738,7 @@ function collectA11y(fragment, source) {
683
738
  };
684
739
  const walkElement = (node, ctx) => {
685
740
  noteSpread(node);
686
- const line = lineOf(source, node.start);
741
+ const line = lineOf2(source, node.start);
687
742
  const attrs = node.attributes;
688
743
  const roleAttr = findAttr3(attrs, "role");
689
744
  const role = roleAttr ? splitTokens(attrTextOf3(roleAttr))[0] : void 0;
@@ -728,7 +783,13 @@ function collectA11y(fragment, source) {
728
783
  });
729
784
  };
730
785
  walk(fragment, { path: [], repeatable: false, landmarks: [], elementDepth: 0, asideDemoting: 0 });
731
- return { nodes, ...slotInLandmark ? { slotInLandmark } : {}, unknowableContent };
786
+ return {
787
+ nodes,
788
+ ...slotInLandmark ? { slotInLandmark } : {},
789
+ unknowable,
790
+ elementTags: [...elementTags],
791
+ elementsUnknowable
792
+ };
732
793
  }
733
794
  function isChildrenRender(node) {
734
795
  const call = node.expression.type === "ChainExpression" ? node.expression.expression : node.expression;
@@ -750,7 +811,8 @@ function parseFile(source, filename) {
750
811
  imports: collectImports(ast),
751
812
  images,
752
813
  headings,
753
- a11y: collectA11y(ast.fragment, source)
814
+ a11y: collectA11y(ast.fragment, source),
815
+ suppressions: collectSuppressions2(source)
754
816
  };
755
817
  }
756
818
 
@@ -915,12 +977,25 @@ function groupSpan(nodes) {
915
977
  }
916
978
  return max + 1;
917
979
  }
980
+ function dedupeCauses(causes) {
981
+ const seen = /* @__PURE__ */ new Map();
982
+ for (const c of causes) {
983
+ const key = `${c.kind}::${c.file}::${c.detail ?? ""}`;
984
+ if (!seen.has(key)) seen.set(key, c);
985
+ }
986
+ return [...seen.values()];
987
+ }
918
988
  function offsetPath(path, base) {
919
989
  return base === 0 ? path : path.map((step) => ({ group: step.group + base, branch: step.branch }));
920
990
  }
921
991
  async function composeA11y(ctx, fileRel, parsed, depth, visited, chain) {
922
992
  const { rt, cwd, state } = ctx;
923
- if (parsed.a11y.unknowableContent) state.fullyResolved = false;
993
+ if (parsed.a11y.unknowable.length > 0) {
994
+ state.fullyResolved = false;
995
+ for (const u of parsed.a11y.unknowable) state.causes.push({ ...u, file: fileRel });
996
+ }
997
+ for (const t of parsed.a11y.elementTags) state.elementTags.add(t);
998
+ if (parsed.a11y.elementsUnknowable) state.elementsClosed = false;
924
999
  const base = state.nextGroup;
925
1000
  state.nextGroup += groupSpan(parsed.a11y.nodes);
926
1001
  const composed = [];
@@ -934,6 +1009,8 @@ async function composeA11y(ctx, fileRel, parsed, depth, visited, chain) {
934
1009
  const childRel = info ? resolveComponentPath(info.source, fileRel, ctx.aliases) : void 0;
935
1010
  if (!childRel || depth <= 0 || visited.has(childRel) || !await rt.exists(rt.join(cwd, childRel))) {
936
1011
  state.fullyResolved = false;
1012
+ state.causes.push({ kind: "component", detail: node.key, file: fileRel, line: node.line });
1013
+ state.elementsClosed = false;
937
1014
  continue;
938
1015
  }
939
1016
  const childParsed = await readAndParse(rt, cwd, childRel, ctx.cache);
@@ -963,7 +1040,7 @@ function representatives(nodes, chainOrder) {
963
1040
  [...folded].map(([key, list]) => [key, list.sort(order).map(({ file, line }) => ({ file, line }))])
964
1041
  );
965
1042
  }
966
- async function resolveRoute(rt, cwd, pageRel, config, layouts, cache, aliases, appHtmlIds) {
1043
+ async function resolveRoute(rt, cwd, pageRel, config, layouts, cache, aliases, appHtmlIds, appHtmlBodyTags) {
967
1044
  const files = chainFiles(pageRel, layouts);
968
1045
  const chainOrder = new Map(files.map((f, i) => [f.rel, i]));
969
1046
  const composed = /* @__PURE__ */ new Map();
@@ -973,7 +1050,20 @@ async function resolveRoute(rt, cwd, pageRel, config, layouts, cache, aliases, a
973
1050
  const images = [];
974
1051
  const headings = [];
975
1052
  const componentHeadings = [];
976
- const a11yCtx = { rt, cwd, config, cache, aliases, state: { nextGroup: 0, fullyResolved: true } };
1053
+ const a11yCtx = {
1054
+ rt,
1055
+ cwd,
1056
+ config,
1057
+ cache,
1058
+ aliases,
1059
+ state: {
1060
+ nextGroup: 0,
1061
+ fullyResolved: true,
1062
+ causes: [],
1063
+ elementTags: new Set(appHtmlBodyTags ?? []),
1064
+ elementsClosed: true
1065
+ }
1066
+ };
977
1067
  const a11yNodes = [];
978
1068
  const nestedLandmarks = [];
979
1069
  let slotLandmark;
@@ -1014,8 +1104,20 @@ async function resolveRoute(rt, cwd, pageRel, config, layouts, cache, aliases, a
1014
1104
  }
1015
1105
  }
1016
1106
  const idNodes = a11yNodes.filter((n) => n.kind === "id");
1017
- if (idNodes.some((n) => n.key === "")) a11yCtx.state.fullyResolved = false;
1107
+ for (const n of idNodes) {
1108
+ if (n.key !== "") continue;
1109
+ a11yCtx.state.fullyResolved = false;
1110
+ a11yCtx.state.causes.push({ kind: "dynamic-id", file: n.file, line: n.line });
1111
+ }
1018
1112
  const literalIds = idNodes.filter((n) => n.key !== "");
1113
+ const ids = representatives(literalIds, chainOrder);
1114
+ if (appHtmlIds) {
1115
+ const shell = new Map(appHtmlIds.map((s) => [s.id, s.line]));
1116
+ for (const key of Object.keys(ids)) {
1117
+ const line = shell.get(key);
1118
+ if (line !== void 0) ids[key] = [{ file: "src/app.html", line }, ...ids[key]];
1119
+ }
1120
+ }
1019
1121
  const route = deriveRoute(pageRel);
1020
1122
  return {
1021
1123
  head: { route, source: "static", tags: [...composed.values(), ...additiveTags], file: pageRel },
@@ -1028,19 +1130,23 @@ async function resolveRoute(rt, cwd, pageRel, config, layouts, cache, aliases, a
1028
1130
  chainOrder
1029
1131
  ),
1030
1132
  nestedLandmarks,
1031
- ids: representatives(literalIds, chainOrder),
1133
+ ids,
1032
1134
  // `href="#top"` scrolls to the document top with no element of that id, so it is
1033
1135
  // never a missing reference (HTML's "top of the document" fragment).
1034
1136
  idRefs: a11yNodes.filter((n) => n.kind === "idref" && !(n.attr === "href" && isTopFragment(n.key))).map((n) => ({ id: n.key, attr: n.attr ?? "", file: n.file, line: n.line })),
1035
- idCandidates: [.../* @__PURE__ */ new Set([...literalIds.map((n) => n.key), ...appHtmlIds ?? []])],
1036
- fullyResolved: a11yCtx.state.fullyResolved
1137
+ idCandidates: [.../* @__PURE__ */ new Set([...literalIds.map((n) => n.key), ...(appHtmlIds ?? []).map((s) => s.id)])],
1138
+ fullyResolved: a11yCtx.state.fullyResolved,
1139
+ ...a11yCtx.state.causes.length > 0 ? { unresolvedCauses: dedupeCauses(a11yCtx.state.causes) } : {},
1140
+ elementTags: [...a11yCtx.state.elementTags],
1141
+ elementsClosed: a11yCtx.state.elementsClosed,
1142
+ file: pageRel
1037
1143
  }
1038
1144
  };
1039
1145
  }
1040
- async function collectRoutes(rt, cwd, config = defaultConfig, cache = /* @__PURE__ */ new Map(), aliases, appHtmlIds) {
1146
+ async function collectRoutes(rt, cwd, config = defaultConfig, cache = /* @__PURE__ */ new Map(), aliases, appHtmlIds, appHtmlBodyTags) {
1041
1147
  const [pages, layouts] = await Promise.all([enumerateRoutePages(rt, cwd), collectLayouts(rt, cwd)]);
1042
1148
  const facts = await Promise.all(
1043
- pages.map((page) => resolveRoute(rt, cwd, page, config, layouts, cache, aliases, appHtmlIds))
1149
+ pages.map((page) => resolveRoute(rt, cwd, page, config, layouts, cache, aliases, appHtmlIds, appHtmlBodyTags))
1044
1150
  );
1045
1151
  return {
1046
1152
  heads: facts.map((f) => f.head),
@@ -1059,11 +1165,15 @@ function routeMatcher(glob) {
1059
1165
  }
1060
1166
 
1061
1167
  // src/collect-all.ts
1168
+ function globList(globs) {
1169
+ return globs === void 0 ? [] : Array.isArray(globs) ? globs : [globs];
1170
+ }
1062
1171
  async function collectAll(rt, cwd, config, opts = {}) {
1063
1172
  const matches = routeMatcher(opts.route);
1173
+ const parseCache = opts.parseCache ?? /* @__PURE__ */ new Map();
1064
1174
  const project = await collectProjectFacts(rt, cwd);
1065
1175
  const [collected, components, kitModules, sourceFiles] = await Promise.all([
1066
- collectRoutes(rt, cwd, config, opts.parseCache, project.kitAliases, project.appHtmlIds),
1176
+ collectRoutes(rt, cwd, config, parseCache, project.kitAliases, project.appHtmlIds, project.appHtmlBodyTags),
1067
1177
  // Component (Correctness) facts are file-scoped with no route attribution yet, so a
1068
1178
  // route-filtered run skips them rather than reporting unrelated components (#68 review);
1069
1179
  // kitModules is skipped for the same reason.
@@ -1079,7 +1189,45 @@ async function collectAll(rt, cwd, config, opts = {}) {
1079
1189
  const images = collected.images.filter((i) => matches(i.route));
1080
1190
  const headings = collected.headings.filter((h) => matches(h.route));
1081
1191
  const a11y = collected.a11y.filter((a) => matches(a.route));
1082
- return { heads, images, headings, a11y, project, components, kitModules, sourceFiles };
1192
+ const directives = /* @__PURE__ */ new Map();
1193
+ for (const [file, parsed] of parseCache) {
1194
+ const suppressions = await parsed.then(
1195
+ (parsedFile) => parsedFile.suppressions,
1196
+ () => void 0
1197
+ );
1198
+ if (suppressions) directives.set(file, suppressions);
1199
+ }
1200
+ for (const c of components) directives.set(c.file, c.suppressions ?? []);
1201
+ for (const m of kitModules) directives.set(m.file, m.suppressions ?? []);
1202
+ const viteConfig = project.viteMinifyDisabled;
1203
+ if (viteConfig?.file) directives.set(viteConfig.file, viteConfig.suppressions ?? []);
1204
+ const routes = collected.heads.map((h) => h.route);
1205
+ const emptySelections = [];
1206
+ if (opts.route !== void 0 && routes.length > 0 && !routes.some(matches))
1207
+ emptySelections.push(`--route '${opts.route}' matched none of the ${routes.length} route(s) found.`);
1208
+ if (opts.route === void 0 && routes.length > 0) {
1209
+ const attributable = [
1210
+ ...directives.keys(),
1211
+ ...ROBOTS_SOURCE_PATHS2,
1212
+ ...SITEMAP_SOURCE_PATHS2,
1213
+ ...VITE_CONFIG_FILES2,
1214
+ ...SVELTE_CONFIG_FILES2
1215
+ ];
1216
+ const entries = config.overrides ?? [];
1217
+ const compiled = compileOverrides(config);
1218
+ entries.forEach((entry, i) => {
1219
+ globList(entry.route).forEach((glob) => {
1220
+ if (!routes.some(routeMatcher(glob)))
1221
+ emptySelections.push(`overrides entry for route '${glob}' matched no route.`);
1222
+ });
1223
+ globList(entry.files).forEach((glob, j) => {
1224
+ const pattern = compiled[i]?.files[j];
1225
+ if (pattern && !attributable.some((f) => pattern.test(f)))
1226
+ emptySelections.push(`overrides entry for files '${glob}' matched no file.`);
1227
+ });
1228
+ });
1229
+ }
1230
+ return { heads, images, headings, a11y, project, components, kitModules, sourceFiles, directives, emptySelections };
1083
1231
  }
1084
1232
 
1085
1233
  // src/changed-files.ts
@@ -1520,6 +1668,11 @@ function resolveRuleSelection(input) {
1520
1668
  else out[id] = rest;
1521
1669
  }
1522
1670
  }
1671
+ for (const id of allowed) {
1672
+ if (out[id] !== void 0) continue;
1673
+ const rule = allRules.find((r) => r.id === id);
1674
+ if (rule?.defaultOff) out[id] = rule.severity;
1675
+ }
1523
1676
  }
1524
1677
  for (const id of input.ignoreRules ?? []) out[id] = "off";
1525
1678
  return out;
@@ -1580,17 +1733,23 @@ async function analyzeProject(opts = {}) {
1580
1733
  ...await checkVersionFloor(rt, cwd),
1581
1734
  ...overridesOffWarnings(opts.allowRules, config.overrides)
1582
1735
  ];
1583
- const { heads, images, headings, a11y, project, components, kitModules, sourceFiles } = await collectAll(
1584
- rt,
1585
- cwd,
1586
- config,
1587
- {
1588
- route: opts.route,
1589
- parseCache: opts.parseCache
1590
- }
1591
- );
1736
+ const { heads, images, headings, a11y, project, components, kitModules, sourceFiles, directives, emptySelections } = await collectAll(rt, cwd, config, {
1737
+ route: opts.route,
1738
+ parseCache: opts.parseCache
1739
+ });
1740
+ warnings.push(...emptySelections);
1741
+ if (opts.route === void 0) warnings.push(...unknownDirectiveIds(directives, allRules2));
1592
1742
  const selected = selectRules(allRules2, config);
1593
1743
  const rules = opts.categories ? selected.filter((r) => opts.categories.includes(r.category)) : selected;
1744
+ const idRefSkips = rules.some((r) => r.id === ID_REF_RULE) ? buildIdRefSkips(a11y) : [];
1745
+ if (idRefSkips.length > 0) warnings.push(idRefSkipWarning(idRefSkips, a11y.length));
1746
+ if (opts.route !== void 0 && opts.allowRules?.length) {
1747
+ const starved = rules.filter((r) => opts.allowRules.includes(r.id) && r.scope !== "route").map((r) => r.id);
1748
+ if (starved.length > 0)
1749
+ warnings.push(
1750
+ `--rules ${starved.map((id) => `'${id}'`).join(", ")} examined nothing: --route collects route facts only.`
1751
+ );
1752
+ }
1594
1753
  const {
1595
1754
  results: rawResults,
1596
1755
  examined,
@@ -1606,7 +1765,12 @@ async function analyzeProject(opts = {}) {
1606
1765
  kitModules,
1607
1766
  sourceFiles
1608
1767
  });
1609
- const results = applyOverrides(applyRuleSeverities(rawResults, config), config);
1768
+ const results = applyInlineDirectives(
1769
+ applyOverrides(applyRuleSeverities(rawResults, config), config),
1770
+ directives,
1771
+ rules,
1772
+ config
1773
+ );
1610
1774
  const failedRuleIds = failedRules.map((f) => f.id);
1611
1775
  const scoringConfig = withFailedRulesOff(config, failedRuleIds);
1612
1776
  return {
@@ -1615,6 +1779,7 @@ async function analyzeProject(opts = {}) {
1615
1779
  version: readPackageVersion(),
1616
1780
  ruleIds: rules.map((r) => r.id),
1617
1781
  examined,
1782
+ ...idRefSkips.length > 0 ? { skipped: { [ID_REF_RULE]: idRefSkips } } : {},
1618
1783
  failedRuleIds,
1619
1784
  warnings: [...warnings, ...skippedFileWarnings([...components, ...kitModules]), ...failedRuleWarnings(failedRules)],
1620
1785
  loadedConfig: loaded
@@ -1805,7 +1970,7 @@ async function run(opts = {}) {
1805
1970
  );
1806
1971
  }
1807
1972
  if (reporter === "json") {
1808
- log(formatJsonReport(results, config, { version }, analysis.ruleIds, analysis.examined));
1973
+ log(formatJsonReport(results, config, { version }, analysis.ruleIds, analysis.examined, analysis.skipped));
1809
1974
  } else if (reporter === "agent") {
1810
1975
  log(formatAgentReport(results, config));
1811
1976
  } else if (reporter === "sarif") {
@@ -8,7 +8,7 @@ import {
8
8
  } from "./chunk-RSSMIHIM.js";
9
9
  import {
10
10
  run
11
- } from "./chunk-LJPABOWU.js";
11
+ } from "./chunk-PNHWRKRD.js";
12
12
  import {
13
13
  readCoreVersion,
14
14
  readPackageVersion
@@ -8,14 +8,14 @@ import {
8
8
  import "./chunk-CTMKTFST.js";
9
9
  import {
10
10
  ROOT_ARGS
11
- } from "./chunk-4LSRQXJ6.js";
11
+ } from "./chunk-QKULNWQ5.js";
12
12
  import {
13
13
  CATEGORIES,
14
14
  FAIL_ON_VALUES,
15
15
  TREAT_DYNAMIC_AS_VALUES
16
16
  } from "./chunk-DZVXCUHG.js";
17
17
  import "./chunk-RSSMIHIM.js";
18
- import "./chunk-LJPABOWU.js";
18
+ import "./chunk-PNHWRKRD.js";
19
19
  import {
20
20
  REPORTER_NAMES
21
21
  } from "./chunk-WGFXFKBB.js";
@@ -27,7 +27,7 @@ import {
27
27
  } from "./chunk-72ZLERW3.js";
28
28
  import {
29
29
  EXPLAIN_ARGS
30
- } from "./chunk-ZAHTKEJ5.js";
30
+ } from "./chunk-K363W33X.js";
31
31
  import {
32
32
  consoleIO
33
33
  } from "./chunk-SLUMRYUD.js";
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  EXPLAIN_ARGS,
3
3
  runExplainCliGunshi
4
- } from "./chunk-ZAHTKEJ5.js";
4
+ } from "./chunk-K363W33X.js";
5
5
  import "./chunk-SLUMRYUD.js";
6
6
  import "./chunk-NMVBVKLX.js";
7
7
  import "./chunk-AI24FJ5Q.js";
@@ -4,10 +4,10 @@ import {
4
4
  import "./chunk-CTMKTFST.js";
5
5
  import {
6
6
  ROOT_ARGS
7
- } from "./chunk-4LSRQXJ6.js";
7
+ } from "./chunk-QKULNWQ5.js";
8
8
  import "./chunk-DZVXCUHG.js";
9
9
  import "./chunk-RSSMIHIM.js";
10
- import "./chunk-LJPABOWU.js";
10
+ import "./chunk-PNHWRKRD.js";
11
11
  import "./chunk-WGFXFKBB.js";
12
12
  import "./chunk-MLIODPHZ.js";
13
13
  import {
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { Config, Severity, RuleSetting, Category, Result } from '@svelte-vitals/core';
1
+ import { Config, Severity, RuleSetting, Category, Result, JsonReport } from '@svelte-vitals/core';
2
2
  export { defineConfig } from '@svelte-vitals/core';
3
3
  import { AST } from 'svelte/compiler';
4
- import { HeadTag, BranchStep, RuleOptionsSpec } from '@svelte-vitals/core/internal';
4
+ import { HeadTag, BranchStep, SuppressionDirective, RuleOptionsSpec } from '@svelte-vitals/core/internal';
5
5
 
6
6
  /** A resolved import binding: which module, and which export ('default' for default imports). */
7
7
  interface ImportInfo {
@@ -56,8 +56,15 @@ interface ParsedA11y {
56
56
  nodes: A11yNode[];
57
57
  /** landmark ancestor of this file's <slot>/{@render children()} position, if any */
58
58
  slotInLandmark?: string;
59
- /** file contains {@html} or a spread attribute — poisons the closed world for no-missing-id-ref */
60
- unknowableContent: boolean;
59
+ /** {@html} tags and spread attributes, located each poisons the closed world for no-missing-id-ref */
60
+ unknowable: {
61
+ kind: 'spread' | 'html';
62
+ line: number;
63
+ }[];
64
+ /** Distinct lowercased tag names of the body's `RegularElement`s (a11y/required-element's presence set). */
65
+ elementTags: string[];
66
+ /** file contains `{@html}` or a `<svelte:element>` — either can render an element the walk cannot see */
67
+ elementsUnknowable: boolean;
61
68
  }
62
69
  interface ParsedFile {
63
70
  headTags: ParsedTag[];
@@ -66,6 +73,10 @@ interface ParsedFile {
66
73
  images: ParsedImage[];
67
74
  headings: ParsedHeading[];
68
75
  a11y: ParsedA11y;
76
+ /** Inline `svelte-vitals-disable-next-line` directives in this file, for the central
77
+ * suppression pass. Collected here because a route-scoped finding can be located in any file
78
+ * the composition reads, including ones no component-fact collection visited (`--route`). */
79
+ suppressions: SuppressionDirective[];
69
80
  }
70
81
 
71
82
  /**
@@ -268,9 +279,11 @@ interface AnalyzeResult {
268
279
  ruleIds: string[];
269
280
  /** Per-rule, per-declaration counts of places examined, unfiltered by `--diff`/`--baseline`/suppressions. */
270
281
  examined: Record<string, Record<string, number>>;
282
+ /** Routes a closed-world rule skipped, keyed by rule id — the analysis-side companion to `examined`; unfiltered by `--diff`/`--baseline`/suppressions. Absent when no analyzed route was skipped or the rule was not selected. */
283
+ skipped?: JsonReport['skipped'];
271
284
  /** 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`. */
272
285
  failedRuleIds: string[];
273
- /** 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. */
286
+ /** Non-fatal issues surfaced during analysis: config-file problems (unknown top-level keys, invalid enum values), version-floor notices, `--rules`/overrides conflicts, closed-world skip notices (`a11y/no-missing-id-ref`), and skipped-file notices. Empty when none apply. */
274
287
  warnings: string[];
275
288
  /**
276
289
  * This analysis's config-file load result (`undefined` when no config file exists at its
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  routeMatcher,
7
7
  run,
8
8
  spinnerEnabled
9
- } from "./chunk-LJPABOWU.js";
9
+ } from "./chunk-PNHWRKRD.js";
10
10
  import {
11
11
  CONFIG_FILENAMES,
12
12
  loadConfigFile
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-vitals",
3
- "version": "0.48.1",
3
+ "version": "0.50.0",
4
4
  "description": "A deterministic SvelteKit code-health scanner (SEO, performance, correctness, security, architecture, accessibility).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -52,7 +52,7 @@
52
52
  "magicast": "^0.5.4",
53
53
  "svelte": "^5.56.9",
54
54
  "tinyglobby": "^0.2.17",
55
- "@svelte-vitals/core": "0.45.0"
55
+ "@svelte-vitals/core": "0.47.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@gunshi/docs": "0.37.1",