@svelte-vitals/core 0.22.1 → 0.24.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
@@ -247,6 +247,11 @@ interface ComponentFacts {
247
247
  propCount: number;
248
248
  /** Module specifiers of every `import` in the instance + module scripts (Bundle PERF009). */
249
249
  imports: string[];
250
+ /** Module specifiers of every `import`, each with its source line (Bundle PERF009). */
251
+ importSpans: {
252
+ source: string;
253
+ line: number;
254
+ }[];
250
255
  /** Value `import * as X from '<bare pkg>'` namespace imports (type-only excluded) — Bundle PERF010. */
251
256
  namespaceImports: {
252
257
  source: string;
@@ -275,6 +280,10 @@ declare function parseComponentFacts(source: string, filename: string): {
275
280
  loc: number;
276
281
  propCount: number;
277
282
  imports: string[];
283
+ importSpans: {
284
+ source: string;
285
+ line: number;
286
+ }[];
278
287
  namespaceImports: {
279
288
  source: string;
280
289
  line: number;
@@ -629,6 +638,10 @@ interface ConsoleReportOptions {
629
638
  mode?: string;
630
639
  /** Color decorators; defaults to no color. */
631
640
  palette?: Palette;
641
+ /** Show every failing/passed/route entry uncapped and ungrouped, exactly as before this option existed. Default false (capped, grouped by rule). */
642
+ verbose?: boolean;
643
+ /** Internal: set by the CLI when it has already animated the Health header itself — skips the brand/Health lines (category score lines still print). Default false. */
644
+ omitHeader?: boolean;
632
645
  }
633
646
  /**
634
647
  * Render results as a console report string (design §7). Pure: returns a string,
package/dist/index.js CHANGED
@@ -419,9 +419,11 @@ function countLines(source) {
419
419
  if (source.length === 0) return 0;
420
420
  return source.split("\n").length - (source.endsWith("\n") ? 1 : 0);
421
421
  }
422
- function collectImportSources(program, acc) {
422
+ function collectImportSources(program, source, acc) {
423
423
  walkEstree(program, (n) => {
424
- if (n.type === "ImportDeclaration" && typeof n.source?.value === "string") acc.push(n.source.value);
424
+ if (n.type === "ImportDeclaration" && typeof n.source?.value === "string") {
425
+ acc.push({ source: n.source.value, line: lineOf(source, n.start) });
426
+ }
425
427
  });
426
428
  }
427
429
  function isBareSpecifier(s) {
@@ -459,10 +461,10 @@ function parseComponentFacts(source, filename) {
459
461
  collectSecurityFacts(ast.fragment ?? ast, source, htmlTags, javascriptUrls);
460
462
  const loc = countLines(source);
461
463
  const suppressions = collectSuppressions(source);
462
- const imports = [];
464
+ const importSpans = [];
463
465
  const namespaceImports = [];
464
466
  if (ast.module?.content) {
465
- collectImportSources(ast.module.content, imports);
467
+ collectImportSources(ast.module.content, source, importSpans);
466
468
  collectNamespaceImports(ast.module.content, source, namespaceImports);
467
469
  }
468
470
  const effects = [];
@@ -471,7 +473,7 @@ function parseComponentFacts(source, filename) {
471
473
  let propCount = 0;
472
474
  const program = ast.instance?.content;
473
475
  if (program) {
474
- collectImportSources(program, imports);
476
+ collectImportSources(program, source, importSpans);
475
477
  collectNamespaceImports(program, source, namespaceImports);
476
478
  propCount = countProps(program);
477
479
  const nonBindableProps = collectNonBindableProps(program);
@@ -509,6 +511,7 @@ function parseComponentFacts(source, filename) {
509
511
  if (!writtenOrEscaped.has(d.name)) constableStates.push(d);
510
512
  }
511
513
  }
514
+ const imports = importSpans.map((s) => s.source);
512
515
  return {
513
516
  eachBlocks,
514
517
  effects,
@@ -517,6 +520,7 @@ function parseComponentFacts(source, filename) {
517
520
  loc,
518
521
  propCount,
519
522
  imports,
523
+ importSpans,
520
524
  namespaceImports,
521
525
  constableStates,
522
526
  mutatedProps,
@@ -535,6 +539,7 @@ function emptyComponentFacts(file) {
535
539
  loc: 0,
536
540
  propCount: 0,
537
541
  imports: [],
542
+ importSpans: [],
538
543
  namespaceImports: [],
539
544
  constableStates: [],
540
545
  mutatedProps: [],
@@ -2143,7 +2148,7 @@ var arch002PropCount = componentRule({
2143
2148
  bad: (c) => c.propCount > MAX_PROPS ? [{ line: 1, message: `Component takes ${c.propCount} props (over ${MAX_PROPS})` }] : []
2144
2149
  });
2145
2150
 
2146
- // src/rules/performance/perf009-heavy-import.ts
2151
+ // src/rules/perf/perf009-heavy-import.ts
2147
2152
  var HEAVY_PACKAGES = {
2148
2153
  lodash: "import a submodule (lodash/debounce) or use lodash-es for tree-shaking",
2149
2154
  moment: "use a lighter date library (date-fns or dayjs) \u2014 moment is large and not tree-shakeable"
@@ -2156,20 +2161,24 @@ var perf009HeavyImport = componentRule({
2156
2161
  label: "No heavy imports",
2157
2162
  recommendation: "Import a submodule or switch to a lighter, tree-shakeable alternative.",
2158
2163
  rationale: "Importing a large, non-tree-shakeable package pulls its whole weight into the bundle even when only a fraction is used, slowing load.",
2159
- applies: (c) => c.imports.length > 0,
2164
+ // ComponentFacts is a public @svelte-vitals/core export — an external caller compiled
2165
+ // against an older version may still construct one without importSpans. Fall back to the
2166
+ // line-less `imports` (line: 0, the pre-fix behavior) instead of crashing on `undefined`.
2167
+ applies: (c) => (c.importSpans ?? c.imports).length > 0,
2160
2168
  bad: (c) => {
2161
2169
  const seen = /* @__PURE__ */ new Set();
2162
2170
  const out = [];
2163
- for (const src of c.imports) {
2171
+ const spans = c.importSpans ?? c.imports.map((source) => ({ source, line: 0 }));
2172
+ for (const { source: src, line } of spans) {
2164
2173
  if (!Object.hasOwn(HEAVY_PACKAGES, src) || seen.has(src)) continue;
2165
2174
  seen.add(src);
2166
- out.push({ line: 0, message: `Heavy import "${src}" \u2014 ${HEAVY_PACKAGES[src]}` });
2175
+ out.push({ line, message: `Heavy import "${src}" \u2014 ${HEAVY_PACKAGES[src]}` });
2167
2176
  }
2168
2177
  return out;
2169
2178
  }
2170
2179
  });
2171
2180
 
2172
- // src/rules/performance/perf010-namespace-import.ts
2181
+ // src/rules/perf/perf010-namespace-import.ts
2173
2182
  var perf010NamespaceImport = componentRule({
2174
2183
  id: "PERF010",
2175
2184
  title: "Namespace import",
@@ -2399,27 +2408,53 @@ var CATEGORY_LABEL = {
2399
2408
  architecture: "Architecture"
2400
2409
  };
2401
2410
  var CATEGORY_ORDER = ["seo", "performance", "correctness", "security", "architecture"];
2411
+ var MAX_RULE_GROUPS_PER_BUCKET = 5;
2412
+ function groupByRule(results) {
2413
+ const groups = /* @__PURE__ */ new Map();
2414
+ for (const r of results) {
2415
+ const bucket = groups.get(r.id);
2416
+ if (bucket) bucket.push(r);
2417
+ else groups.set(r.id, [r]);
2418
+ }
2419
+ return [...groups.entries()].map(([id, rs]) => ({ id, results: rs })).sort((a, b) => b.results.length - a.results.length || a.id.localeCompare(b.id));
2420
+ }
2402
2421
  function scoreLine(p, label, { score, scoreModel }) {
2403
2422
  const parts = [`route avg ${scoreModel.routeAverage}`];
2404
2423
  if (scoreModel.sitePenalty > 0) parts.push(`site \u2212${scoreModel.sitePenalty}`);
2405
2424
  if (scoreModel.criticalCap !== null) parts.push(`capped at ${scoreModel.criticalCap}: critical present`);
2406
2425
  return `${label} Score: ${scoreColor(p, score)(`${score}/100`)} ${p.dim(`(${parts.join(" \xB7 ")})`)}`;
2407
2426
  }
2408
- function byRouteTree(p, results, config) {
2427
+ var MAX_ROUTES_BY_ROUTE = 10;
2428
+ function byRouteTree(p, results, config, verbose) {
2409
2429
  const routes = /* @__PURE__ */ new Map();
2410
2430
  for (const r of results) {
2411
2431
  if (r.route === void 0) continue;
2412
2432
  if (!routes.has(r.route)) routes.set(r.route, []);
2413
2433
  routes.get(r.route).push(r);
2414
2434
  }
2435
+ const scored = [...routes.entries()].map(([route, rs]) => ({
2436
+ route,
2437
+ rs,
2438
+ score: computeScore(rs, config, { applyCriticalCap: false }).score
2439
+ }));
2440
+ scored.sort((a, b) => a.score - b.score || a.route.localeCompare(b.route));
2441
+ const shown = verbose ? scored : scored.slice(0, MAX_ROUTES_BY_ROUTE);
2415
2442
  const lines = [p.bold("By route"), p.dim(RULE)];
2416
- for (const [route, rs] of [...routes.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
2417
- const { score } = computeScore(rs, config, { applyCriticalCap: false });
2443
+ for (const { route, rs, score } of shown) {
2418
2444
  lines.push(`${route.padEnd(28)} ${scoreColor(p, score)(`${score}`)}`);
2419
2445
  for (const r of rs.filter((x) => classify(x, config) === "fail")) {
2420
2446
  lines.push(` ${p.red("\u2717")} ${r.id} ${r.message}`);
2421
2447
  }
2422
2448
  }
2449
+ if (!verbose && scored.length > MAX_ROUTES_BY_ROUTE) {
2450
+ const remaining = scored.slice(MAX_ROUTES_BY_ROUTE);
2451
+ const avgScore = Math.round(remaining.reduce((sum, r) => sum + r.score, 0) / remaining.length);
2452
+ lines.push(
2453
+ p.dim(
2454
+ `\u2026and ${remaining.length} more route${remaining.length > 1 ? "s" : ""} (avg score ${avgScore}) \u2014 run with --verbose to see all`
2455
+ )
2456
+ );
2457
+ }
2423
2458
  lines.push("");
2424
2459
  return lines;
2425
2460
  }
@@ -2428,15 +2463,18 @@ function formatConsoleReport(results, config, options = {}) {
2428
2463
  const summary = summarize(results, config);
2429
2464
  const { health, categories: byCat } = computeHealth(results, config);
2430
2465
  const present2 = CATEGORY_ORDER.filter((c) => byCat[c] !== void 0);
2431
- const header = [
2432
- p.bold(`Svelte Vitals \xB7 ${options.mode ?? "static mode"}`),
2433
- "",
2434
- `${p.bold("Health:")} ${scoreColor(p, health)(`${health}/100`)}`
2435
- ];
2466
+ const lines = [];
2467
+ if (!options.omitHeader) {
2468
+ lines.push(
2469
+ p.bold(`Svelte Vitals \xB7 ${options.mode ?? "static mode"}`),
2470
+ "",
2471
+ `${p.bold("Health:")} ${scoreColor(p, health)(`${health}/100`)}`
2472
+ );
2473
+ }
2436
2474
  for (const c of present2) {
2437
- header.push(scoreLine(p, CATEGORY_LABEL[c] ?? c, byCat[c]));
2475
+ lines.push(scoreLine(p, CATEGORY_LABEL[c] ?? c, byCat[c]));
2438
2476
  }
2439
- const lines = [...header, ""];
2477
+ lines.push("");
2440
2478
  const SEVERITY_COLOR = {
2441
2479
  critical: (s) => p.red(p.bold(s)),
2442
2480
  warning: (s) => p.yellow(p.bold(s)),
@@ -2447,25 +2485,47 @@ function formatConsoleReport(results, config, options = {}) {
2447
2485
  const bucket = failures.filter((r) => effectiveSeverity(r, config) === severity);
2448
2486
  if (bucket.length === 0) continue;
2449
2487
  lines.push(SEVERITY_COLOR[severity](`${SEVERITY_TITLE[severity]} (${bucket.length})`), p.dim(RULE));
2450
- for (const r of bucket) {
2451
- lines.push(`${p.red("\u2717")} ${r.id} ${r.message}`);
2452
- if (r.route) lines.push(p.dim(` ${r.route}`));
2453
- if (r.location) lines.push(p.dim(` ${r.location}${r.line ? `:${r.line}` : ""}`));
2488
+ if (options.verbose) {
2489
+ for (const r of bucket) {
2490
+ lines.push(`${p.red("\u2717")} ${r.id} ${r.message}`);
2491
+ if (r.route) lines.push(p.dim(` ${r.route}`));
2492
+ if (r.location) lines.push(p.dim(` ${r.location}${r.line ? `:${r.line}` : ""}`));
2493
+ }
2494
+ } else {
2495
+ const groups = groupByRule(bucket);
2496
+ const shownGroups = groups.slice(0, MAX_RULE_GROUPS_PER_BUCKET);
2497
+ for (const group of shownGroups) {
2498
+ const r = group.results[0];
2499
+ lines.push(`${p.red("\u2717")} ${r.id} ${r.message}`);
2500
+ if (r.route) lines.push(p.dim(` ${r.route}`));
2501
+ if (r.location) lines.push(p.dim(` ${r.location}${r.line ? `:${r.line}` : ""}`));
2502
+ if (group.results.length > 1) {
2503
+ lines.push(p.dim(` \u2026and ${group.results.length - 1} more`));
2504
+ }
2505
+ }
2506
+ if (groups.length > MAX_RULE_GROUPS_PER_BUCKET) {
2507
+ const remaining = groups.length - MAX_RULE_GROUPS_PER_BUCKET;
2508
+ lines.push(
2509
+ p.dim(`\u2026and ${remaining} more rule${remaining > 1 ? "s" : ""} affected \u2014 run with --verbose to see all`)
2510
+ );
2511
+ }
2454
2512
  }
2455
2513
  lines.push("");
2456
2514
  }
2457
2515
  const passed = results.filter((r) => classify(r, config) !== "fail");
2458
2516
  if (passed.length > 0) {
2459
2517
  lines.push(p.bold(`Passed (${passed.length})`), p.dim(RULE));
2460
- for (const r of passed) {
2461
- const marker = classify(r, config) === "dynamic" ? p.cyan(" \u21AF dynamic") : "";
2462
- const route = r.route ? ` ${r.route}` : "";
2463
- lines.push(`${p.green("\u2713")} ${r.id} ${r.message}${marker}${route}`);
2518
+ if (options.verbose) {
2519
+ for (const r of passed) {
2520
+ const marker = classify(r, config) === "dynamic" ? p.cyan(" \u21AF dynamic") : "";
2521
+ const route = r.route ? ` ${r.route}` : "";
2522
+ lines.push(`${p.green("\u2713")} ${r.id} ${r.message}${marker}${route}`);
2523
+ }
2464
2524
  }
2465
2525
  lines.push("");
2466
2526
  }
2467
- if (options.byRoute) lines.push(...byRouteTree(p, results, config));
2468
- if (summary.dynamic > 0) lines.push(p.dim("\u21AF = set dynamically (verified at runtime)."));
2527
+ if (options.byRoute) lines.push(...byRouteTree(p, results, config, options.verbose ?? false));
2528
+ if (options.verbose && summary.dynamic > 0) lines.push(p.dim("\u21AF = set dynamically (verified at runtime)."));
2469
2529
  return lines.join("\n").replace(/\n+$/, "\n");
2470
2530
  }
2471
2531
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-vitals/core",
3
- "version": "0.22.1",
3
+ "version": "0.24.0",
4
4
  "description": "Shared, runtime-agnostic core for svelte-vitals (types, rule engine, scorer, reporter).",
5
5
  "type": "module",
6
6
  "license": "MIT",