svelte-vitals 0.49.0 → 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-4FOGEXGH.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-5YVVJMZM.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-ZQZGCJ5W.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") {
@@ -42,6 +42,26 @@ import {
42
42
  terminalSafe
43
43
  } from "@svelte-vitals/core/internal";
44
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
+
45
65
  // src/runtime/node.ts
46
66
  import { readFile, access } from "fs/promises";
47
67
  import { join } from "path";
@@ -79,6 +99,7 @@ import {
79
99
  VITE_CONFIG_FILES,
80
100
  collectSuppressions,
81
101
  findMinifyDisabled,
102
+ lineOf,
82
103
  resolveKitAliases,
83
104
  resolveKitPathsBase
84
105
  } from "@svelte-vitals/core/internal";
@@ -168,9 +189,15 @@ function detectAppHtmlBodyTags(html) {
168
189
  return [...new Set([...body.matchAll(/<([a-zA-Z][a-zA-Z0-9-]*)\b/g)].map((m) => m[1].toLowerCase()))];
169
190
  }
170
191
  function detectAppHtmlIds(html) {
171
- 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);
172
194
  const found = markup.matchAll(/(?<![\w-])id\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>{][^\s"'>]*))/gi);
173
- 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 }));
174
201
  }
175
202
  async function detectAppHtmlFacts(rt, cwd) {
176
203
  const appHtmlPath = rt.join(cwd, "src/app.html");
@@ -413,7 +440,7 @@ import {
413
440
  stripTextDirective,
414
441
  parseSvelte,
415
442
  CHILD_NODE_KEYS,
416
- lineOf,
443
+ lineOf as lineOf2,
417
444
  findAttr as findAttr3,
418
445
  valueFromNodes,
419
446
  textFromNodes,
@@ -591,7 +618,7 @@ function collectImages(node, source, acc) {
591
618
  // A literal loading="lazy" only — a spread or dynamic loading={…} must not be flagged.
592
619
  lazy: attrText(attrs, "loading") === "lazy",
593
620
  hasSrcset: hasSpread || Boolean(findAttr3(attrs, "srcset")),
594
- line: lineOf(source, node.start)
621
+ line: lineOf2(source, node.start)
595
622
  });
596
623
  }
597
624
  for (const key of CHILD_NODE_KEYS) {
@@ -606,7 +633,7 @@ function collectHeadings(node, source, acc) {
606
633
  if (!node || typeof node !== "object") return;
607
634
  if (node.type === "SvelteHead") return;
608
635
  if (node.type === "RegularElement" && /^h[1-6]$/.test(node.name)) {
609
- 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) });
610
637
  }
611
638
  for (const key of CHILD_NODE_KEYS) {
612
639
  if (key in node) collectHeadings(childOf(node, key), source, acc);
@@ -627,7 +654,7 @@ function collectA11y(fragment, source) {
627
654
  const nodes = [];
628
655
  let groups = 0;
629
656
  let slotInLandmark;
630
- let unknowableContent = false;
657
+ const unknowable = [];
631
658
  const elementTags = /* @__PURE__ */ new Set();
632
659
  let elementsUnknowable = false;
633
660
  const emit = (ctx, node) => {
@@ -636,9 +663,9 @@ function collectA11y(fragment, source) {
636
663
  };
637
664
  const noteSpread = (node) => {
638
665
  const attributes = node.attributes;
639
- if (Array.isArray(attributes) && attributes.some((a) => a.type === "SpreadAttribute")) {
640
- unknowableContent = true;
641
- }
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) });
642
669
  };
643
670
  const walk = (node, ctx) => {
644
671
  if (Array.isArray(node)) {
@@ -651,7 +678,7 @@ function collectA11y(fragment, source) {
651
678
  return;
652
679
  // head content never renders into the body
653
680
  case "HtmlTag":
654
- unknowableContent = true;
681
+ unknowable.push({ kind: "html", line: lineOf2(source, node.start) });
655
682
  elementsUnknowable = true;
656
683
  return;
657
684
  case "IfBlock":
@@ -683,7 +710,7 @@ function collectA11y(fragment, source) {
683
710
  case "SvelteComponent":
684
711
  case "SvelteSelf":
685
712
  noteSpread(node);
686
- 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) });
687
714
  walk(node.fragment, { ...ctx, elementDepth: ctx.elementDepth + 1 });
688
715
  return;
689
716
  case "SlotElement":
@@ -711,7 +738,7 @@ function collectA11y(fragment, source) {
711
738
  };
712
739
  const walkElement = (node, ctx) => {
713
740
  noteSpread(node);
714
- const line = lineOf(source, node.start);
741
+ const line = lineOf2(source, node.start);
715
742
  const attrs = node.attributes;
716
743
  const roleAttr = findAttr3(attrs, "role");
717
744
  const role = roleAttr ? splitTokens(attrTextOf3(roleAttr))[0] : void 0;
@@ -759,7 +786,7 @@ function collectA11y(fragment, source) {
759
786
  return {
760
787
  nodes,
761
788
  ...slotInLandmark ? { slotInLandmark } : {},
762
- unknowableContent,
789
+ unknowable,
763
790
  elementTags: [...elementTags],
764
791
  elementsUnknowable
765
792
  };
@@ -950,12 +977,23 @@ function groupSpan(nodes) {
950
977
  }
951
978
  return max + 1;
952
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
+ }
953
988
  function offsetPath(path, base) {
954
989
  return base === 0 ? path : path.map((step) => ({ group: step.group + base, branch: step.branch }));
955
990
  }
956
991
  async function composeA11y(ctx, fileRel, parsed, depth, visited, chain) {
957
992
  const { rt, cwd, state } = ctx;
958
- 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
+ }
959
997
  for (const t of parsed.a11y.elementTags) state.elementTags.add(t);
960
998
  if (parsed.a11y.elementsUnknowable) state.elementsClosed = false;
961
999
  const base = state.nextGroup;
@@ -971,6 +1009,7 @@ async function composeA11y(ctx, fileRel, parsed, depth, visited, chain) {
971
1009
  const childRel = info ? resolveComponentPath(info.source, fileRel, ctx.aliases) : void 0;
972
1010
  if (!childRel || depth <= 0 || visited.has(childRel) || !await rt.exists(rt.join(cwd, childRel))) {
973
1011
  state.fullyResolved = false;
1012
+ state.causes.push({ kind: "component", detail: node.key, file: fileRel, line: node.line });
974
1013
  state.elementsClosed = false;
975
1014
  continue;
976
1015
  }
@@ -1017,7 +1056,13 @@ async function resolveRoute(rt, cwd, pageRel, config, layouts, cache, aliases, a
1017
1056
  config,
1018
1057
  cache,
1019
1058
  aliases,
1020
- state: { nextGroup: 0, fullyResolved: true, elementTags: new Set(appHtmlBodyTags ?? []), elementsClosed: true }
1059
+ state: {
1060
+ nextGroup: 0,
1061
+ fullyResolved: true,
1062
+ causes: [],
1063
+ elementTags: new Set(appHtmlBodyTags ?? []),
1064
+ elementsClosed: true
1065
+ }
1021
1066
  };
1022
1067
  const a11yNodes = [];
1023
1068
  const nestedLandmarks = [];
@@ -1059,8 +1104,20 @@ async function resolveRoute(rt, cwd, pageRel, config, layouts, cache, aliases, a
1059
1104
  }
1060
1105
  }
1061
1106
  const idNodes = a11yNodes.filter((n) => n.kind === "id");
1062
- 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
+ }
1063
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
+ }
1064
1121
  const route = deriveRoute(pageRel);
1065
1122
  return {
1066
1123
  head: { route, source: "static", tags: [...composed.values(), ...additiveTags], file: pageRel },
@@ -1073,12 +1130,13 @@ async function resolveRoute(rt, cwd, pageRel, config, layouts, cache, aliases, a
1073
1130
  chainOrder
1074
1131
  ),
1075
1132
  nestedLandmarks,
1076
- ids: representatives(literalIds, chainOrder),
1133
+ ids,
1077
1134
  // `href="#top"` scrolls to the document top with no element of that id, so it is
1078
1135
  // never a missing reference (HTML's "top of the document" fragment).
1079
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 })),
1080
- idCandidates: [.../* @__PURE__ */ new Set([...literalIds.map((n) => n.key), ...appHtmlIds ?? []])],
1137
+ idCandidates: [.../* @__PURE__ */ new Set([...literalIds.map((n) => n.key), ...(appHtmlIds ?? []).map((s) => s.id)])],
1081
1138
  fullyResolved: a11yCtx.state.fullyResolved,
1139
+ ...a11yCtx.state.causes.length > 0 ? { unresolvedCauses: dedupeCauses(a11yCtx.state.causes) } : {},
1082
1140
  elementTags: [...a11yCtx.state.elementTags],
1083
1141
  elementsClosed: a11yCtx.state.elementsClosed,
1084
1142
  file: pageRel
@@ -1610,6 +1668,11 @@ function resolveRuleSelection(input) {
1610
1668
  else out[id] = rest;
1611
1669
  }
1612
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
+ }
1613
1676
  }
1614
1677
  for (const id of input.ignoreRules ?? []) out[id] = "off";
1615
1678
  return out;
@@ -1678,6 +1741,8 @@ async function analyzeProject(opts = {}) {
1678
1741
  if (opts.route === void 0) warnings.push(...unknownDirectiveIds(directives, allRules2));
1679
1742
  const selected = selectRules(allRules2, config);
1680
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));
1681
1746
  if (opts.route !== void 0 && opts.allowRules?.length) {
1682
1747
  const starved = rules.filter((r) => opts.allowRules.includes(r.id) && r.scope !== "route").map((r) => r.id);
1683
1748
  if (starved.length > 0)
@@ -1714,6 +1779,7 @@ async function analyzeProject(opts = {}) {
1714
1779
  version: readPackageVersion(),
1715
1780
  ruleIds: rules.map((r) => r.id),
1716
1781
  examined,
1782
+ ...idRefSkips.length > 0 ? { skipped: { [ID_REF_RULE]: idRefSkips } } : {},
1717
1783
  failedRuleIds,
1718
1784
  warnings: [...warnings, ...skippedFileWarnings([...components, ...kitModules]), ...failedRuleWarnings(failedRules)],
1719
1785
  loadedConfig: loaded
@@ -1904,7 +1970,7 @@ async function run(opts = {}) {
1904
1970
  );
1905
1971
  }
1906
1972
  if (reporter === "json") {
1907
- log(formatJsonReport(results, config, { version }, analysis.ruleIds, analysis.examined));
1973
+ log(formatJsonReport(results, config, { version }, analysis.ruleIds, analysis.examined, analysis.skipped));
1908
1974
  } else if (reporter === "agent") {
1909
1975
  log(formatAgentReport(results, config));
1910
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-5YVVJMZM.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-4FOGEXGH.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-5YVVJMZM.js";
18
+ import "./chunk-PNHWRKRD.js";
19
19
  import {
20
20
  REPORTER_NAMES
21
21
  } from "./chunk-WGFXFKBB.js";
@@ -4,10 +4,10 @@ import {
4
4
  import "./chunk-CTMKTFST.js";
5
5
  import {
6
6
  ROOT_ARGS
7
- } from "./chunk-4FOGEXGH.js";
7
+ } from "./chunk-QKULNWQ5.js";
8
8
  import "./chunk-DZVXCUHG.js";
9
9
  import "./chunk-RSSMIHIM.js";
10
- import "./chunk-5YVVJMZM.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,4 +1,4 @@
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
4
  import { HeadTag, BranchStep, SuppressionDirective, RuleOptionsSpec } from '@svelte-vitals/core/internal';
@@ -56,8 +56,11 @@ 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
+ }[];
61
64
  /** Distinct lowercased tag names of the body's `RegularElement`s (a11y/required-element's presence set). */
62
65
  elementTags: string[];
63
66
  /** file contains `{@html}` or a `<svelte:element>` — either can render an element the walk cannot see */
@@ -276,9 +279,11 @@ interface AnalyzeResult {
276
279
  ruleIds: string[];
277
280
  /** Per-rule, per-declaration counts of places examined, unfiltered by `--diff`/`--baseline`/suppressions. */
278
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'];
279
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`. */
280
285
  failedRuleIds: string[];
281
- /** 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. */
282
287
  warnings: string[];
283
288
  /**
284
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-5YVVJMZM.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.49.0",
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.46.0"
55
+ "@svelte-vitals/core": "0.47.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@gunshi/docs": "0.37.1",