@svelte-vitals/core 0.19.0 → 0.21.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
@@ -257,6 +257,11 @@ interface ComponentFacts {
257
257
  name: string;
258
258
  line: number;
259
259
  }[];
260
+ /** Mutations of a non-`$bindable` prop from `$props()` — member writes, `delete`, or a mutating method call (CORRECT005). */
261
+ mutatedProps: {
262
+ name: string;
263
+ line: number;
264
+ }[];
260
265
  /** Inline `svelte-vitals-disable-next-line` directives found in this file's source — component-rule escape hatch (issue #92). Optional: absent is equivalent to no directives, so existing external constructors of `ComponentFacts` are unaffected. */
261
266
  suppressions?: SuppressionDirective[];
262
267
  }
@@ -278,9 +283,28 @@ declare function parseComponentFacts(source: string, filename: string): {
278
283
  name: string;
279
284
  line: number;
280
285
  }[];
286
+ mutatedProps: {
287
+ name: string;
288
+ line: number;
289
+ }[];
281
290
  suppressions: SuppressionDirective[];
282
291
  };
283
292
 
293
+ /**
294
+ * Fallback facts for a file that fails to read or parse (dev tooling must never
295
+ * throw). This is the single source of truth for the empty-facts shape — add new
296
+ * `ComponentFacts` fields HERE so TypeScript catches every call site that still
297
+ * needs updating.
298
+ */
299
+ declare function emptyComponentFacts(file: string): ComponentFacts;
300
+ /**
301
+ * Scan every `.svelte` component under `src/` for Correctness/Security/Architecture/
302
+ * Bundle-Performance facts. Independent of route resolution — covers `$lib` and
303
+ * non-route components too. A file that fails to read or parse contributes empty
304
+ * facts instead of aborting the whole scan (dev tooling must never throw).
305
+ */
306
+ declare function collectComponentFacts(rt: Runtime, cwd: string): Promise<ComponentFacts[]>;
307
+
284
308
  type Node = any;
285
309
  /**
286
310
  * All keys that can bear child nodes in a Svelte AST node.
@@ -483,6 +507,8 @@ declare const correct003EffectAsOnMount: Rule;
483
507
 
484
508
  declare const correct004UnmutatedState: Rule;
485
509
 
510
+ declare const correct005PropMutation: Rule;
511
+
486
512
  declare const sec001Html: Rule;
487
513
  declare const sec002JavascriptUrl: Rule;
488
514
 
@@ -691,6 +717,16 @@ declare function formatSarifReport(results: Result[], config: Config, meta: {
691
717
  */
692
718
  declare function formatGithubReport(results: Result[], config: Config): string;
693
719
 
720
+ /**
721
+ * Render a compact Markdown summary — Health score, per-category table, severity counts, and
722
+ * a findings table — suitable for a GitHub Actions job summary or a sticky PR comment
723
+ * (`svelte-vitals ci install`). Delegates all aggregation to `buildJsonReport` so the numbers
724
+ * never drift from the JSON/console reporters.
725
+ */
726
+ declare function formatMarkdownReport(results: Result[], config: Config, meta: {
727
+ version: string;
728
+ }): string;
729
+
694
730
  type Band = 'good' | 'warn' | 'poor';
695
731
  declare const BAND_COLOR: Record<Band, string>;
696
732
  declare function scoreBand(score: number): Band;
@@ -717,4 +753,4 @@ declare function selectRules(rules: Rule[], config: Config): Rule[];
717
753
  /** Apply per-rule severity overrides to results (design §6). */
718
754
  declare function applyRuleSeverities(results: Result[], config: Config): Result[];
719
755
 
720
- export { BAND_COLOR, CHILD_NODE_KEYS, type Category, type Classification, type ComponentFacts, type Config, type ConsoleReportOptions, type Detection, type EachBlockFact, type EffectFact, type Fix, type HeadProvider, type HeadTag, type HeadingInfo, type HealthResult, type ImageInfo, type JsonReport, type Palette, type Presence, type Project, ROBOTS_SOURCE_PATHS, type ResolvedHead, type ResolvedHeadings, type ResolvedImages, type Result, type Rule, type RuleContext, type RuleInfo, type RuleSetting, type Runtime, SITEMAP_SOURCE_PATHS, type Scope, type ScoreModel, type ScoreOptions, type ScoreResult, type Severity, type SourceSpan, type Summary, type SuppressionDirective, type TreatDynamicAs, type Value, allRules, applyRuleSeverities, arch001ComponentSize, arch002PropCount, attrText, attrTextOf, attrValue, attrValueOf, buildHtmlDocument, buildJsonReport, classify, computeHealth, computeScore, correct001EachKey, correct002EffectDerived, correct003EffectAsOnMount, correct004UnmutatedState, defaultConfig, defaultProject, defineConfig, docsUrlFor, effectiveSeverity, escapeHtml, explainRule, findAttr, formatAgentReport, formatConsoleReport, formatGithubReport, formatHtmlReport, formatJsonReport, formatSarifReport, hasFailureAtOrAbove, headTagRule, imageRule, isPenalized, lineOf, linkRule, noColorPalette, parseComponentFacts, perf001ImageDimensions, perf002ImageLoading, perf003PreloadAs, perf004FontPreloadCrossorigin, perf005LcpImage, perf006ResponsiveImage, perf007RenderBlockingScript, perf008Preconnect, perf009HeavyImport, perf010NamespaceImport, runRules, safeHref, scoreBand, scoreColor, scoresByCategory, sec001Html, sec002JavascriptUrl, selectRules, seo001Title, seo002Description, seo003Canonical, seo004OgImage, seo005OgTitle, seo006Robots, seo007Sitemap, seo008JsonLd, seo009HtmlLang, seo010Indexability, seo011TwitterCard, seo012OgDescription, seo013OgUrl, seo014Viewport, seo015SitemapInRobots, seo016JsonLdValidity, seo017DeprecatedType, seo018RelativeUrl, seo019DateFormat, seo020Placeholder, seo021RequiredProps, seo022TitleLength, seo023DescriptionLength, seo024Charset, seo025ImageAlt, seo026Hreflang, seo027Heading, seo028TitleUnique, seo029DescriptionUnique, seo030HeadingOrder, summarize, textFromNodes, valueFromNodes };
756
+ export { BAND_COLOR, CHILD_NODE_KEYS, type Category, type Classification, type ComponentFacts, type Config, type ConsoleReportOptions, type Detection, type EachBlockFact, type EffectFact, type Fix, type HeadProvider, type HeadTag, type HeadingInfo, type HealthResult, type ImageInfo, type JsonReport, type Palette, type Presence, type Project, ROBOTS_SOURCE_PATHS, type ResolvedHead, type ResolvedHeadings, type ResolvedImages, type Result, type Rule, type RuleContext, type RuleInfo, type RuleSetting, type Runtime, SITEMAP_SOURCE_PATHS, type Scope, type ScoreModel, type ScoreOptions, type ScoreResult, type Severity, type SourceSpan, type Summary, type SuppressionDirective, type TreatDynamicAs, type Value, allRules, applyRuleSeverities, arch001ComponentSize, arch002PropCount, attrText, attrTextOf, attrValue, attrValueOf, buildHtmlDocument, buildJsonReport, classify, collectComponentFacts, computeHealth, computeScore, correct001EachKey, correct002EffectDerived, correct003EffectAsOnMount, correct004UnmutatedState, correct005PropMutation, defaultConfig, defaultProject, defineConfig, docsUrlFor, effectiveSeverity, emptyComponentFacts, escapeHtml, explainRule, findAttr, formatAgentReport, formatConsoleReport, formatGithubReport, formatHtmlReport, formatJsonReport, formatMarkdownReport, formatSarifReport, hasFailureAtOrAbove, headTagRule, imageRule, isPenalized, lineOf, linkRule, noColorPalette, parseComponentFacts, perf001ImageDimensions, perf002ImageLoading, perf003PreloadAs, perf004FontPreloadCrossorigin, perf005LcpImage, perf006ResponsiveImage, perf007RenderBlockingScript, perf008Preconnect, perf009HeavyImport, perf010NamespaceImport, runRules, safeHref, scoreBand, scoreColor, scoresByCategory, sec001Html, sec002JavascriptUrl, selectRules, seo001Title, seo002Description, seo003Canonical, seo004OgImage, seo005OgTitle, seo006Robots, seo007Sitemap, seo008JsonLd, seo009HtmlLang, seo010Indexability, seo011TwitterCard, seo012OgDescription, seo013OgUrl, seo014Viewport, seo015SitemapInRobots, seo016JsonLdValidity, seo017DeprecatedType, seo018RelativeUrl, seo019DateFormat, seo020Placeholder, seo021RequiredProps, seo022TitleLength, seo023DescriptionLength, seo024Charset, seo025ImageAlt, seo026Hreflang, seo027Heading, seo028TitleUnique, seo029DescriptionUnique, seo030HeadingOrder, summarize, textFromNodes, valueFromNodes };
package/dist/index.js CHANGED
@@ -96,7 +96,7 @@ function collectEachBlocks(node, source, acc) {
96
96
  return;
97
97
  }
98
98
  if (!node || typeof node !== "object") return;
99
- if (node.type === "EachBlock" && !isConstantListEach(node)) {
99
+ if (node.type === "EachBlock" && node.context != null && !isConstantListEach(node)) {
100
100
  acc.push({ hasKey: node.key != null, line: lineOf(source, node.start) });
101
101
  }
102
102
  for (const key of CHILD_NODE_KEYS) {
@@ -175,33 +175,71 @@ function rootObjectName(node) {
175
175
  while (cur?.type === "MemberExpression") cur = cur.object;
176
176
  return cur?.type === "Identifier" ? cur.name : void 0;
177
177
  }
178
+ function scopeIntroducedNames(node) {
179
+ const introduced = /* @__PURE__ */ new Set();
180
+ if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") {
181
+ for (const p of node.params ?? []) addBoundNames(p, introduced);
182
+ } else if (node.type === "CatchClause") {
183
+ addBoundNames(node.param, introduced);
184
+ } else if (node.type === "BlockStatement") {
185
+ for (const stmt of node.body ?? []) {
186
+ if (stmt?.type === "VariableDeclaration" && stmt.kind !== "var") {
187
+ for (const d of stmt.declarations ?? []) addBoundNames(d.id, introduced);
188
+ }
189
+ }
190
+ } else if (node.type === "ForStatement" || node.type === "ForOfStatement" || node.type === "ForInStatement") {
191
+ const decl = node.type === "ForStatement" ? node.init : node.left;
192
+ if (decl?.type === "VariableDeclaration") {
193
+ for (const d of decl.declarations ?? []) addBoundNames(d.id, introduced);
194
+ }
195
+ } else if (node.type === "EachBlock" && node.context) {
196
+ addBoundNames(node.context, introduced);
197
+ }
198
+ return introduced;
199
+ }
200
+ function walkScoped(node, visit, shadowed = /* @__PURE__ */ new Set()) {
201
+ if (Array.isArray(node)) {
202
+ for (const child of node) walkScoped(child, visit, shadowed);
203
+ return;
204
+ }
205
+ if (!node || typeof node !== "object" || typeof node.type !== "string") return;
206
+ const introduced = scopeIntroducedNames(node);
207
+ const scope = introduced.size > 0 ? /* @__PURE__ */ new Set([...shadowed, ...introduced]) : shadowed;
208
+ visit(node, scope);
209
+ for (const key of Object.keys(node)) {
210
+ if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
211
+ walkScoped(node[key], visit, scope);
212
+ }
213
+ }
178
214
  function collectStateWrites(root, stateNames, acc) {
179
- walkEstree(root, (n) => {
215
+ walkScoped(root, (n, scope) => {
216
+ const shadowed = (name) => name === void 0 || scope.has(name);
180
217
  if (n?.type === "AssignmentExpression") {
181
- if (n.left?.type === "Identifier" && stateNames.has(n.left.name)) acc.add(n.left.name);
182
- else if (n.left?.type === "MemberExpression") {
218
+ if (n.left?.type === "Identifier" && stateNames.has(n.left.name) && !shadowed(n.left.name)) {
219
+ acc.add(n.left.name);
220
+ } else if (n.left?.type === "MemberExpression") {
183
221
  const r = rootObjectName(n.left);
184
- if (r && stateNames.has(r)) acc.add(r);
222
+ if (r && stateNames.has(r) && !shadowed(r)) acc.add(r);
185
223
  } else if (n.left?.type === "ObjectPattern" || n.left?.type === "ArrayPattern") {
186
224
  const bound = /* @__PURE__ */ new Set();
187
225
  addBoundNames(n.left, bound);
188
- for (const name of bound) if (stateNames.has(name)) acc.add(name);
226
+ for (const name of bound) if (stateNames.has(name) && !shadowed(name)) acc.add(name);
189
227
  }
190
228
  } else if (n?.type === "UpdateExpression") {
191
229
  const r = rootObjectName(n.argument);
192
- if (r && stateNames.has(r)) acc.add(r);
230
+ if (r && stateNames.has(r) && !shadowed(r)) acc.add(r);
193
231
  } else if (n?.type === "UnaryExpression" && n.operator === "delete") {
194
232
  const r = rootObjectName(n.argument);
195
- if (r && stateNames.has(r)) acc.add(r);
233
+ if (r && stateNames.has(r) && !shadowed(r)) acc.add(r);
196
234
  } else if (n?.type === "CallExpression") {
197
235
  if (n.callee?.type === "MemberExpression") {
198
236
  const r = rootObjectName(n.callee);
199
- if (r && stateNames.has(r)) acc.add(r);
237
+ if (r && stateNames.has(r) && !shadowed(r)) acc.add(r);
200
238
  }
201
239
  for (const a of n.arguments ?? []) {
202
240
  const arg = a?.type === "SpreadElement" ? a.argument : a;
203
241
  const r = rootObjectName(arg);
204
- if (r && stateNames.has(r)) acc.add(r);
242
+ if (r && stateNames.has(r) && !shadowed(r)) acc.add(r);
205
243
  }
206
244
  }
207
245
  });
@@ -296,6 +334,71 @@ function collectSecurityFacts(node, source, htmlTags, jsUrls) {
296
334
  function isPropsCall(node) {
297
335
  return node?.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "$props";
298
336
  }
337
+ function isBindableCall(node) {
338
+ return node?.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "$bindable";
339
+ }
340
+ function collectNonBindableProps(program) {
341
+ const names = /* @__PURE__ */ new Set();
342
+ let seen = 0;
343
+ let ambiguous = false;
344
+ walkEstree(program, (n) => {
345
+ if (n.type !== "VariableDeclarator" || !n.init || !isPropsCall(n.init)) return;
346
+ seen++;
347
+ if (n.id?.type === "Identifier") {
348
+ names.add(n.id.name);
349
+ return;
350
+ }
351
+ if (n.id?.type !== "ObjectPattern" || !Array.isArray(n.id.properties)) {
352
+ ambiguous = true;
353
+ return;
354
+ }
355
+ for (const p of n.id.properties) {
356
+ if (p?.type === "RestElement") {
357
+ addBoundNames(p.argument, names);
358
+ } else if (p?.type === "Property") {
359
+ if (p.value?.type === "AssignmentPattern") {
360
+ if (!isBindableCall(p.value.right) && p.value.left?.type === "Identifier") names.add(p.value.left.name);
361
+ } else if (p.value?.type === "Identifier") {
362
+ names.add(p.value.name);
363
+ }
364
+ }
365
+ }
366
+ });
367
+ return ambiguous || seen > 1 ? /* @__PURE__ */ new Set() : names;
368
+ }
369
+ var MUTATING_METHODS = /* @__PURE__ */ new Set([
370
+ "push",
371
+ "pop",
372
+ "shift",
373
+ "unshift",
374
+ "splice",
375
+ "sort",
376
+ "reverse",
377
+ "copyWithin",
378
+ "fill",
379
+ "set",
380
+ "add",
381
+ "delete",
382
+ "clear"
383
+ ]);
384
+ function collectPropMutations(root, propNames, source, acc) {
385
+ if (propNames.size === 0) return;
386
+ walkScoped(root, (n, scope) => {
387
+ const flag = (r) => {
388
+ if (r && propNames.has(r) && !scope.has(r)) acc.push({ name: r, line: lineOf(source, n.start) });
389
+ };
390
+ if (n.type === "AssignmentExpression" && n.left?.type === "MemberExpression") {
391
+ flag(rootObjectName(n.left));
392
+ } else if (n.type === "UpdateExpression" && n.argument?.type === "MemberExpression") {
393
+ flag(rootObjectName(n.argument));
394
+ } else if (n.type === "UnaryExpression" && n.operator === "delete") {
395
+ flag(rootObjectName(n.argument));
396
+ } else if (n.type === "CallExpression" && n.callee?.type === "MemberExpression") {
397
+ const method = n.callee.property?.type === "Identifier" ? n.callee.property.name : void 0;
398
+ if (method && MUTATING_METHODS.has(method)) flag(rootObjectName(n.callee.object));
399
+ }
400
+ });
401
+ }
299
402
  function countProps(program) {
300
403
  let count = 0;
301
404
  let seen = 0;
@@ -364,12 +467,16 @@ function parseComponentFacts(source, filename) {
364
467
  }
365
468
  const effects = [];
366
469
  const constableStates = [];
470
+ const mutatedProps = [];
367
471
  let propCount = 0;
368
472
  const program = ast.instance?.content;
369
473
  if (program) {
370
474
  collectImportSources(program, imports);
371
475
  collectNamespaceImports(program, source, namespaceImports);
372
476
  propCount = countProps(program);
477
+ const nonBindableProps = collectNonBindableProps(program);
478
+ collectPropMutations(program, nonBindableProps, source, mutatedProps);
479
+ if (ast.fragment) collectPropMutations(ast.fragment, nonBindableProps, source, mutatedProps);
373
480
  const stateNames = /* @__PURE__ */ new Set();
374
481
  const reactiveNames = /* @__PURE__ */ new Set();
375
482
  const stateDecls = [];
@@ -412,10 +519,42 @@ function parseComponentFacts(source, filename) {
412
519
  imports,
413
520
  namespaceImports,
414
521
  constableStates,
522
+ mutatedProps,
415
523
  suppressions
416
524
  };
417
525
  }
418
526
 
527
+ // src/component-collect.ts
528
+ function emptyComponentFacts(file) {
529
+ return {
530
+ file,
531
+ eachBlocks: [],
532
+ effects: [],
533
+ htmlTags: [],
534
+ javascriptUrls: [],
535
+ loc: 0,
536
+ propCount: 0,
537
+ imports: [],
538
+ namespaceImports: [],
539
+ constableStates: [],
540
+ mutatedProps: [],
541
+ suppressions: []
542
+ };
543
+ }
544
+ async function collectComponentFacts(rt, cwd) {
545
+ const files = await rt.glob("src/**/*.svelte", cwd);
546
+ return Promise.all(
547
+ files.sort().map(async (rel) => {
548
+ try {
549
+ const source = await rt.readFile(rt.join(cwd, rel));
550
+ return { file: rel, ...parseComponentFacts(source, rel) };
551
+ } catch {
552
+ return emptyComponentFacts(rel);
553
+ }
554
+ })
555
+ );
556
+ }
557
+
419
558
  // src/project-paths.ts
420
559
  var ROBOTS_SOURCE_PATHS = [
421
560
  "static/robots.txt",
@@ -1898,7 +2037,7 @@ var correct001EachKey = componentRule({
1898
2037
  category: "correctness",
1899
2038
  label: "Keyed {#each}",
1900
2039
  recommendation: "Add a key to the {#each} block, e.g. {#each items as item (item.id)}.",
1901
- rationale: "An unkeyed {#each} destroys and recreates DOM nodes when the list reorders, losing element state/focus and wasting work; a key lets Svelte move nodes instead.",
2040
+ rationale: "An unkeyed {#each} adds/removes nodes at the end and rewrites the data of the DOM nodes in between when the list reorders, so element state/focus sticks to positions instead of items; a key lets Svelte insert, move, and delete the right nodes instead.",
1902
2041
  applies: (c) => c.eachBlocks.length > 0,
1903
2042
  bad: (c) => c.eachBlocks.filter((e) => !e.hasKey).map((e) => ({ line: e.line, message: "{#each} block has no key" }))
1904
2043
  });
@@ -1939,6 +2078,21 @@ var correct004UnmutatedState = componentRule({
1939
2078
  }))
1940
2079
  });
1941
2080
 
2081
+ // src/rules/correctness/correct005-prop-mutation.ts
2082
+ var correct005PropMutation = componentRule({
2083
+ id: "CORRECT005",
2084
+ title: "Mutated non-bindable prop",
2085
+ category: "correctness",
2086
+ label: "Prop mutation",
2087
+ recommendation: "Clone the value before mutating it, communicate the change via a callback prop, or declare the prop $bindable if the parent and child should share it.",
2088
+ rationale: "Svelte's docs say plainly: don't mutate props unless they are $bindable. A plain-object prop mutation is a silent no-op (the object isn't a state proxy); a reactive-state-proxy prop mutation works but triggers the ownership_invalid_mutation dev warning only when that code path actually runs. Neither is caught by the compiler, so this rule catches both statically.",
2089
+ applies: (c) => c.mutatedProps.length > 0,
2090
+ bad: (c) => c.mutatedProps.map((m) => ({
2091
+ line: m.line,
2092
+ message: `Prop "${m.name}" is mutated, but it is not declared $bindable`
2093
+ }))
2094
+ });
2095
+
1942
2096
  // src/rules/security/sec001-002.ts
1943
2097
  var sec001Html = componentRule({
1944
2098
  id: "SEC001",
@@ -2022,8 +2176,8 @@ var perf010NamespaceImport = componentRule({
2022
2176
  category: "performance",
2023
2177
  severity: "info",
2024
2178
  label: "No namespace imports",
2025
- recommendation: "Use named imports (import { x } from 'pkg') instead of import * as \u2014 a namespace import keeps the whole module in the bundle.",
2026
- rationale: "A namespace import (import * as X) forces the bundler to retain the entire module, so unused exports cannot be tree-shaken out.",
2179
+ recommendation: "Use named imports (import { x } from 'pkg') instead of import * as X from 'pkg' \u2014 so the bundle reliably tree-shakes.",
2180
+ rationale: "A namespace import (import * as X) is only tree-shakeable while every access to X stays static; passing X around or indexing it dynamically forces the bundler to keep the whole module. Named imports are reliably shakeable and make the dependency surface explicit.",
2027
2181
  applies: (c) => c.namespaceImports.length > 0,
2028
2182
  bad: (c) => {
2029
2183
  const minLine = /* @__PURE__ */ new Map();
@@ -2033,7 +2187,7 @@ var perf010NamespaceImport = componentRule({
2033
2187
  }
2034
2188
  return [...minLine.entries()].sort((a, b) => a[1] - b[1]).map(([source, line]) => ({
2035
2189
  line,
2036
- message: `Namespace import "* as \u2026 from '${source}'" \u2014 prefer named imports so the bundler can tree-shake`
2190
+ message: `Namespace import "import * as \u2026 from '${source}'" \u2014 prefer named imports so the bundle reliably tree-shakes`
2037
2191
  }));
2038
2192
  }
2039
2193
  });
@@ -2082,6 +2236,7 @@ var allRules = [
2082
2236
  correct002EffectDerived,
2083
2237
  correct003EffectAsOnMount,
2084
2238
  correct004UnmutatedState,
2239
+ correct005PropMutation,
2085
2240
  sec001Html,
2086
2241
  sec002JavascriptUrl,
2087
2242
  arch001ComponentSize,
@@ -2499,6 +2654,82 @@ function formatGithubReport(results, config) {
2499
2654
  return lines.join("\n");
2500
2655
  }
2501
2656
 
2657
+ // src/reporter/markdown.ts
2658
+ var MAX_FINDINGS = 50;
2659
+ var SEVERITY_EMOJI = { critical: "\u{1F534}", warning: "\u{1F7E1}", info: "\u{1F535}" };
2660
+ var SEVERITY_RANK2 = { critical: 0, warning: 1, info: 2 };
2661
+ function escapeCell(s) {
2662
+ return s.replace(/\|/g, "\\|").replace(/\r\n|\r|\n/g, " ");
2663
+ }
2664
+ function locationOf(issue, route) {
2665
+ if (issue.location) return issue.line !== void 0 ? `${issue.location}:${issue.line}` : issue.location;
2666
+ return route ?? "-";
2667
+ }
2668
+ function flattenFindings(report) {
2669
+ const findings = [];
2670
+ for (const r of report.routes) {
2671
+ for (const issue of r.issues) {
2672
+ findings.push({
2673
+ severity: issue.severity,
2674
+ id: issue.id,
2675
+ location: locationOf(issue, r.route),
2676
+ message: issue.title
2677
+ });
2678
+ }
2679
+ }
2680
+ for (const issue of report.siteIssues) {
2681
+ findings.push({
2682
+ severity: issue.severity,
2683
+ id: issue.id,
2684
+ location: locationOf(issue, void 0),
2685
+ message: issue.title
2686
+ });
2687
+ }
2688
+ return findings.map((f, index) => ({ f, index })).sort((a, b) => SEVERITY_RANK2[a.f.severity] - SEVERITY_RANK2[b.f.severity] || a.index - b.index).map(({ f }) => f);
2689
+ }
2690
+ function categoryRows(categories) {
2691
+ const names = Object.keys(categories).sort();
2692
+ return names.map((cat) => `| ${cat} | ${categories[cat].score} |`);
2693
+ }
2694
+ function formatMarkdownReport(results, config, meta) {
2695
+ const report = buildJsonReport(results, config, meta);
2696
+ const lines = [];
2697
+ lines.push(`<!-- svelte-vitals v${meta.version} -->`);
2698
+ lines.push(`## svelte-vitals \u2014 Health ${report.score}/100`);
2699
+ lines.push("");
2700
+ const catRows = categoryRows(report.categories);
2701
+ if (catRows.length > 0) {
2702
+ lines.push("| Category | Score |");
2703
+ lines.push("| --- | --- |");
2704
+ lines.push(...catRows);
2705
+ lines.push("");
2706
+ }
2707
+ const { critical, warning, info, passed } = report.summary;
2708
+ lines.push(`**${critical} critical \xB7 ${warning} warning \xB7 ${info} info** (${passed} checks passed)`);
2709
+ lines.push("");
2710
+ const findings = flattenFindings(report);
2711
+ if (findings.length === 0) {
2712
+ lines.push("\u2705 No issues found.");
2713
+ } else {
2714
+ lines.push("### Findings");
2715
+ lines.push("");
2716
+ lines.push("| Severity | Rule | Location | Message |");
2717
+ lines.push("| --- | --- | --- | --- |");
2718
+ const shown = findings.slice(0, MAX_FINDINGS);
2719
+ for (const f of shown) {
2720
+ const rule = `[${f.id}](${docsUrlFor(f.id)})`;
2721
+ lines.push(
2722
+ `| ${SEVERITY_EMOJI[f.severity]} ${f.severity} | ${rule} | ${escapeCell(f.location)} | ${escapeCell(f.message)} |`
2723
+ );
2724
+ }
2725
+ if (findings.length > MAX_FINDINGS) {
2726
+ lines.push("");
2727
+ lines.push(`\u2026and ${findings.length - MAX_FINDINGS} more (run \`npx svelte-vitals\` locally for the full report)`);
2728
+ }
2729
+ }
2730
+ return lines.join("\n");
2731
+ }
2732
+
2502
2733
  // src/reporter/html.ts
2503
2734
  var BAND_COLOR = {
2504
2735
  good: "#2FA968",
@@ -2711,17 +2942,20 @@ export {
2711
2942
  buildHtmlDocument,
2712
2943
  buildJsonReport,
2713
2944
  classify,
2945
+ collectComponentFacts,
2714
2946
  computeHealth,
2715
2947
  computeScore,
2716
2948
  correct001EachKey,
2717
2949
  correct002EffectDerived,
2718
2950
  correct003EffectAsOnMount,
2719
2951
  correct004UnmutatedState,
2952
+ correct005PropMutation,
2720
2953
  defaultConfig,
2721
2954
  defaultProject,
2722
2955
  defineConfig,
2723
2956
  docsUrlFor,
2724
2957
  effectiveSeverity,
2958
+ emptyComponentFacts,
2725
2959
  escapeHtml,
2726
2960
  explainRule,
2727
2961
  findAttr,
@@ -2730,6 +2964,7 @@ export {
2730
2964
  formatGithubReport,
2731
2965
  formatHtmlReport,
2732
2966
  formatJsonReport,
2967
+ formatMarkdownReport,
2733
2968
  formatSarifReport,
2734
2969
  hasFailureAtOrAbove,
2735
2970
  headTagRule,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-vitals/core",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Shared, runtime-agnostic core for svelte-vitals (types, rule engine, scorer, reporter).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "homepage": "https://github.com/oekazuma/svelte-vitals#readme",
23
23
  "engines": {
24
- "node": ">=18.20.8"
24
+ "node": ">=22.13.0"
25
25
  },
26
26
  "sideEffects": false,
27
27
  "dependencies": {