@svelte-vitals/core 0.31.0 → 0.32.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
@@ -544,6 +544,20 @@ interface KitModuleFacts {
544
544
  name: string;
545
545
  line: number;
546
546
  }[];
547
+ /**
548
+ * `.set()`/`.update()` in a handler on an import resolving under the `$lib` server root.
549
+ * The call shape alone cannot tell a persistence client (`db.set(…)`) from a hand-rolled
550
+ * in-memory store, so the decision needs the target module — which this pure parse cannot
551
+ * read. `collectKitModuleFacts` resolves each one and promotes the in-memory ones into
552
+ * `importedStateWrites`; a consumer that ignores this field sees the pre-arbitration
553
+ * behaviour, i.e. every one of these exempt.
554
+ */
555
+ pendingServerStoreWrites: {
556
+ name: string;
557
+ imported: string;
558
+ resolved: string;
559
+ line: number;
560
+ }[];
547
561
  /** Value imports whose specifier resolves to a repo-local `.svelte.ts`/`.svelte.js` runes module (security/shared-state-import). */
548
562
  runesModuleImports: {
549
563
  source: string;
@@ -946,9 +960,9 @@ interface Rule {
946
960
  severity: Severity;
947
961
  /** 'route' = evaluated per route, 'project' = site-wide (design §10, §12). */
948
962
  scope: Scope;
949
- /** Why this rule matters — one or two sentences, surfaced by explain_rule (issue #24). */
963
+ /** Why this rule matters — one or two sentences, surfaced by `svelte-vitals explain` (issue #24). */
950
964
  rationale: string;
951
- /** Canonical remediation template, shared by findings and explain_rule (issue #24). */
965
+ /** Canonical remediation template, shared by findings and `svelte-vitals explain` (issue #24). */
952
966
  fix?: Fix;
953
967
  /** Configurable options for this rule; absent means the rule takes none. */
954
968
  options?: RuleOptionsSpec;
@@ -1290,7 +1304,7 @@ declare const performanceStateRaw: Rule;
1290
1304
 
1291
1305
  declare const allRules: Rule[];
1292
1306
 
1293
- /** One configurable option of a rule, flattened for explain_rule's consumers. */
1307
+ /** One configurable option of a rule, flattened for `svelte-vitals explain`'s output. */
1294
1308
  interface RuleOptionInfo {
1295
1309
  name: string;
1296
1310
  /** `integer` replaces the default; `string-list`/`string-map` are ADDED to it. */
@@ -1314,7 +1328,7 @@ interface RuleInfo {
1314
1328
  */
1315
1329
  options?: RuleOptionInfo[];
1316
1330
  }
1317
- /** Look up a rule's static metadata for the MCP explain_rule tool (issue #24). Rule ids are matched exactly (case-sensitive, e.g. "seo/ssr-disabled"). */
1331
+ /** Look up a rule's static metadata, as `svelte-vitals explain` renders it (issue #24). Rule ids are matched exactly (case-sensitive, e.g. "seo/ssr-disabled"). */
1318
1332
  declare function explainRule(id: string): RuleInfo | undefined;
1319
1333
 
1320
1334
  interface HeadTagRuleOptions {
@@ -1326,7 +1340,7 @@ interface HeadTagRuleOptions {
1326
1340
  /** Short human label, e.g. 'description'. */
1327
1341
  label: string;
1328
1342
  recommendation: string;
1329
- /** Why this rule matters — surfaced by explain_rule (issue #24). */
1343
+ /** Why this rule matters — surfaced by `svelte-vitals explain` (issue #24). */
1330
1344
  rationale: string;
1331
1345
  /** Agent-actionable remediation attached to every finding (issue #18). */
1332
1346
  fix?: Fix;
@@ -1432,7 +1446,14 @@ interface ScoreModel {
1432
1446
  criticalCap: number | null;
1433
1447
  }
1434
1448
  interface ScoreResult {
1449
+ /** The score as displayed: `Math.floor(rawScore)`, so 100 means the deduction was exactly zero. */
1435
1450
  score: number;
1451
+ /**
1452
+ * The same score before flooring, after `sitePenalty` and the cap, clamped to `[0, 100]`. Exposed so
1453
+ * `computeHealth` can average unrounded values and floor once — averaging the displayed scores would
1454
+ * compose two roundings and move Health by up to two points.
1455
+ */
1456
+ rawScore: number;
1436
1457
  scoreModel: ScoreModel;
1437
1458
  }
1438
1459
  interface ScoreOptions {
@@ -1482,7 +1503,7 @@ interface JsonReport {
1482
1503
  }>;
1483
1504
  siteIssues: JsonIssue[];
1484
1505
  }
1485
- /** Build the structured JSON report object (design §7). Shared by the json reporter and the MCP `analyze` tool (issue #24). */
1506
+ /** Build the structured JSON report object (design §7). The shape the `json` reporter emits (issue #24). */
1486
1507
  declare function buildJsonReport(results: Result[], config: Config, meta: {
1487
1508
  version: string;
1488
1509
  }): JsonReport;
package/dist/index.js CHANGED
@@ -1842,6 +1842,13 @@ function libServerRoot(aliases) {
1842
1842
  if (lib && lib.replacement === null) return void 0;
1843
1843
  return `${lib?.replacement ?? "src/lib"}/server`;
1844
1844
  }
1845
+ function serverRootRelativePath(spec, importerFile, aliases) {
1846
+ const serverRoot = libServerRoot(aliases);
1847
+ if (serverRoot === void 0) return void 0;
1848
+ const path = resolveRepoLocalPath(spec, importerFile, aliases);
1849
+ if (path === void 0) return void 0;
1850
+ return path === serverRoot || path.startsWith(`${serverRoot}/`) ? path : void 0;
1851
+ }
1845
1852
  function isLocalStateSpecifier(spec, importerFile, aliases) {
1846
1853
  const serverRoot = libServerRoot(aliases);
1847
1854
  if (serverRoot === void 0) return false;
@@ -1849,12 +1856,32 @@ function isLocalStateSpecifier(spec, importerFile, aliases) {
1849
1856
  if (path === void 0) return false;
1850
1857
  return path !== serverRoot && !path.startsWith(`${serverRoot}/`);
1851
1858
  }
1859
+ var IN_MEMORY_CTORS = /* @__PURE__ */ new Set(["Map", "Set", "WeakMap", "WeakSet"]);
1860
+ function isInMemoryInit(init) {
1861
+ if (!init) return false;
1862
+ if (init.type === "ObjectExpression" || init.type === "ArrayExpression") return true;
1863
+ return init.type === "NewExpression" && init.callee?.type === "Identifier" && IN_MEMORY_CTORS.has(init.callee.name);
1864
+ }
1865
+ function parseInMemoryExports(source, filename) {
1866
+ const names = /* @__PURE__ */ new Set();
1867
+ const { program } = parseModuleProgram(source, filename);
1868
+ for (const stmt of program?.body ?? []) {
1869
+ if (stmt?.type !== "ExportNamedDeclaration" || !stmt.declaration) continue;
1870
+ const decl = stmt.declaration;
1871
+ if (decl.type !== "VariableDeclaration") continue;
1872
+ for (const d of decl.declarations ?? []) {
1873
+ if (d?.id?.type === "Identifier" && isInMemoryInit(d.init)) names.add(d.id.name);
1874
+ }
1875
+ }
1876
+ return names;
1877
+ }
1852
1878
  function parseKitModuleFacts(source, filename, aliases) {
1853
1879
  const suppressions = collectSuppressions(source);
1854
1880
  const { program, wrapped } = parseModuleProgram(source, filename);
1855
1881
  const moduleStateReassignments = [];
1856
1882
  const importedStateWrites = [];
1857
1883
  const importedStateWritesOutsideHandlers = [];
1884
+ const pendingServerStoreWrites = [];
1858
1885
  const runesModuleImports = [];
1859
1886
  const lifecycleCalls = [];
1860
1887
  const browserGlobalRefs = [];
@@ -1863,6 +1890,7 @@ function parseKitModuleFacts(source, filename, aliases) {
1863
1890
  moduleStateReassignments,
1864
1891
  importedStateWrites,
1865
1892
  importedStateWritesOutsideHandlers,
1893
+ pendingServerStoreWrites,
1866
1894
  runesModuleImports,
1867
1895
  lifecycleCalls,
1868
1896
  browserGlobalRefs,
@@ -1872,6 +1900,7 @@ function parseKitModuleFacts(source, filename, aliases) {
1872
1900
  }
1873
1901
  const line = (start) => Math.max(0, lineOf(wrapped, start) - 1);
1874
1902
  const importedSpecifiers = /* @__PURE__ */ new Map();
1903
+ const importedNames = /* @__PURE__ */ new Map();
1875
1904
  for (const stmt of program.body ?? []) {
1876
1905
  if (stmt?.type !== "ImportDeclaration" || stmt.importKind === "type") continue;
1877
1906
  const spec = typeof stmt.source?.value === "string" ? stmt.source.value : "";
@@ -1880,6 +1909,10 @@ function parseKitModuleFacts(source, filename, aliases) {
1880
1909
  if (s?.importKind === "type" || s?.local?.type !== "Identifier") continue;
1881
1910
  names.push(s.local.name);
1882
1911
  importedSpecifiers.set(s.local.name, spec);
1912
+ importedNames.set(
1913
+ s.local.name,
1914
+ s.type === "ImportSpecifier" && s.imported?.type === "Identifier" ? s.imported.name : s.local.name
1915
+ );
1883
1916
  }
1884
1917
  if (names.length === 0) continue;
1885
1918
  const resolved = resolveRunesModuleSpecifier(spec, filename, aliases);
@@ -1956,8 +1989,21 @@ function parseKitModuleFacts(source, filename, aliases) {
1956
1989
  const method = n.callee.property?.type === "Identifier" ? n.callee.property.name : void 0;
1957
1990
  if (method === "set" || method === "update") {
1958
1991
  const r = importedRoot(n.callee.object);
1959
- if (r && isLocalStateSpecifier(importedSpecifiers.get(r), filename, aliases))
1960
- write = { name: r, via: "set-call" };
1992
+ const spec = r ? importedSpecifiers.get(r) : void 0;
1993
+ if (r && spec !== void 0) {
1994
+ if (isLocalStateSpecifier(spec, filename, aliases)) write = { name: r, via: "set-call" };
1995
+ else {
1996
+ const resolved = serverRootRelativePath(spec, filename, aliases);
1997
+ if (resolved !== void 0 && inHandler) {
1998
+ pendingServerStoreWrites.push({
1999
+ name: r,
2000
+ imported: importedNames.get(r) ?? r,
2001
+ resolved,
2002
+ line: line(n.start)
2003
+ });
2004
+ }
2005
+ }
2006
+ }
1961
2007
  }
1962
2008
  } else if (n.type === "AssignmentExpression" && (n.left?.type === "ObjectPattern" || n.left?.type === "ArrayPattern")) {
1963
2009
  const scanPatternTargets = (pat) => {
@@ -2005,6 +2051,7 @@ function parseKitModuleFacts(source, filename, aliases) {
2005
2051
  moduleStateReassignments: byLine(moduleStateReassignments),
2006
2052
  importedStateWrites: byLine(importedStateWrites),
2007
2053
  importedStateWritesOutsideHandlers: byLine(importedStateWritesOutsideHandlers),
2054
+ pendingServerStoreWrites: byLine(pendingServerStoreWrites),
2008
2055
  runesModuleImports: byLine(runesModuleImports),
2009
2056
  lifecycleCalls: byLine(lifecycleCalls),
2010
2057
  browserGlobalRefs: byLine(browserGlobalRefs),
@@ -2024,6 +2071,7 @@ function emptyKitModuleFacts(file, kind) {
2024
2071
  moduleStateReassignments: [],
2025
2072
  importedStateWrites: [],
2026
2073
  importedStateWritesOutsideHandlers: [],
2074
+ pendingServerStoreWrites: [],
2027
2075
  runesModuleImports: [],
2028
2076
  lifecycleCalls: [],
2029
2077
  browserGlobalRefs: [],
@@ -2044,7 +2092,7 @@ async function collectKitModuleFacts(rt, cwd, aliases) {
2044
2092
  ];
2045
2093
  const lists = await Promise.all(patterns.map((p) => rt.glob(p, cwd)));
2046
2094
  const files = [...new Set(lists.flat())];
2047
- return Promise.all(
2095
+ const facts = await Promise.all(
2048
2096
  files.sort().map(async (rel) => {
2049
2097
  const kind = kindOf(rel);
2050
2098
  try {
@@ -2055,6 +2103,38 @@ async function collectKitModuleFacts(rt, cwd, aliases) {
2055
2103
  }
2056
2104
  })
2057
2105
  );
2106
+ return arbitrateServerStoreWrites(rt, cwd, facts);
2107
+ }
2108
+ function moduleCandidates(repoPath) {
2109
+ if (repoPath.endsWith(".js")) return [repoPath, `${repoPath.slice(0, -3)}.ts`];
2110
+ if (repoPath.endsWith(".ts")) return [repoPath];
2111
+ return [`${repoPath}.ts`, `${repoPath}.js`, `${repoPath}/index.ts`, `${repoPath}/index.js`];
2112
+ }
2113
+ async function inMemoryExportsOf(rt, cwd, repoPath) {
2114
+ for (const rel of moduleCandidates(repoPath)) {
2115
+ try {
2116
+ if (!await rt.exists(rt.join(cwd, rel))) continue;
2117
+ return parseInMemoryExports(await rt.readFile(rt.join(cwd, rel)), rel);
2118
+ } catch {
2119
+ return /* @__PURE__ */ new Set();
2120
+ }
2121
+ }
2122
+ return /* @__PURE__ */ new Set();
2123
+ }
2124
+ async function arbitrateServerStoreWrites(rt, cwd, facts) {
2125
+ const targets = [...new Set(facts.flatMap((f) => f.pendingServerStoreWrites.map((w) => w.resolved)))];
2126
+ if (targets.length === 0) return facts;
2127
+ const byPath = new Map(
2128
+ await Promise.all(targets.map(async (t) => [t, await inMemoryExportsOf(rt, cwd, t)]))
2129
+ );
2130
+ return facts.map((f) => {
2131
+ const promoted = f.pendingServerStoreWrites.filter((w) => byPath.get(w.resolved)?.has(w.imported)).map((w) => ({ name: w.name, line: w.line, via: "set-call" }));
2132
+ if (promoted.length === 0) return f;
2133
+ return {
2134
+ ...f,
2135
+ importedStateWrites: [...f.importedStateWrites, ...promoted].sort((a, b) => a.line - b.line)
2136
+ };
2137
+ });
2058
2138
  }
2059
2139
 
2060
2140
  // src/config-object.ts
@@ -4938,7 +5018,7 @@ var architectureUnitEntryFile = {
4938
5018
  category: "architecture",
4939
5019
  severity: "info",
4940
5020
  detection: { presence: "own", value: "static" },
4941
- route: expected,
5021
+ location: expected,
4942
5022
  message: "Unit entry file",
4943
5023
  recommendation: recommendation8,
4944
5024
  docsUrl: docsUrl8
@@ -5641,7 +5721,8 @@ function computeScore(results, config, options = {}) {
5641
5721
  routeScores.set(route, routeScores.get(route) - deduction);
5642
5722
  }
5643
5723
  const scores = [...routeScores.values()].map(clamp);
5644
- const routeAverage = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : 100;
5724
+ const rawRouteAverage = scores.length ? scores.reduce((a, b) => a + b, 0) / scores.length : 100;
5725
+ const routeAverage = Math.floor(rawRouteAverage);
5645
5726
  const projectRuleMax = /* @__PURE__ */ new Map();
5646
5727
  for (const r of projectResults) {
5647
5728
  if (!isPenalized(r.detection, config.treatDynamicAs)) continue;
@@ -5653,11 +5734,11 @@ function computeScore(results, config, options = {}) {
5653
5734
  let sitePenalty = 0;
5654
5735
  for (const deduction of projectRuleMax.values()) sitePenalty += deduction;
5655
5736
  const applyCap = options.applyCriticalCap ?? true;
5656
- const uncapped = routeAverage - sitePenalty;
5657
- const capBinds = applyCap && anyCritical && uncapped > CRITICAL_CAP;
5737
+ const rawUncapped = rawRouteAverage - sitePenalty;
5738
+ const capBinds = applyCap && anyCritical && rawUncapped > CRITICAL_CAP;
5658
5739
  const criticalCap = capBinds ? CRITICAL_CAP : null;
5659
- const score = capBinds ? CRITICAL_CAP : uncapped;
5660
- return { score: clamp(score), scoreModel: { routeAverage, sitePenalty, criticalCap } };
5740
+ const rawScore = clamp(capBinds ? CRITICAL_CAP : rawUncapped);
5741
+ return { score: Math.floor(rawScore), rawScore, scoreModel: { routeAverage, sitePenalty, criticalCap } };
5661
5742
  }
5662
5743
  function scoresByCategory(results, config) {
5663
5744
  const byCat = /* @__PURE__ */ new Map();
@@ -5674,7 +5755,7 @@ function scoresByCategory(results, config) {
5674
5755
  function computeHealth(results, config) {
5675
5756
  const categories = scoresByCategory(results, config);
5676
5757
  const weights = {};
5677
- let weighted = 0;
5758
+ let weightedDeficit = 0;
5678
5759
  let total = 0;
5679
5760
  for (const cat of Object.keys(categories)) {
5680
5761
  const w = config.weights?.[cat] ?? 1;
@@ -5682,14 +5763,15 @@ function computeHealth(results, config) {
5682
5763
  throw new RangeError(`invalid weight for '${cat}'; expected a finite number >= 0.`);
5683
5764
  }
5684
5765
  weights[cat] = w;
5685
- weighted += categories[cat].score * w;
5766
+ weightedDeficit += (100 - categories[cat].rawScore) * w;
5686
5767
  total += w;
5687
5768
  }
5688
5769
  if (Object.keys(weights).length === 0) return { health: 100, categories, weights };
5689
5770
  if (total === 0) {
5690
5771
  throw new RangeError("Health weights sum to 0; at least one present category must have a positive weight.");
5691
5772
  }
5692
- const health = Math.round(weighted / total);
5773
+ const averageDeficit = weightedDeficit / total;
5774
+ const health = averageDeficit === 0 ? 100 : Math.min(99, Math.floor(100 - averageDeficit));
5693
5775
  return { health, categories, weights };
5694
5776
  }
5695
5777
 
@@ -5763,7 +5845,7 @@ function byRouteTree(p, results, config, verbose) {
5763
5845
  }
5764
5846
  if (!verbose && scored.length > MAX_ROUTES_BY_ROUTE) {
5765
5847
  const remaining = scored.slice(MAX_ROUTES_BY_ROUTE);
5766
- const avgScore = Math.round(remaining.reduce((sum, r) => sum + r.score, 0) / remaining.length);
5848
+ const avgScore = Math.floor(remaining.reduce((sum, r) => sum + r.score, 0) / remaining.length);
5767
5849
  lines.push(
5768
5850
  p.dim(
5769
5851
  `\u2026and ${remaining.length} more route${remaining.length > 1 ? "s" : ""} (avg score ${avgScore}) \u2014 run with --verbose to see all`
@@ -5833,8 +5915,9 @@ function formatConsoleReport(results, config, options = {}) {
5833
5915
  if (options.verbose) {
5834
5916
  for (const r of passed) {
5835
5917
  const marker = classify(r, config) === "dynamic" ? p.cyan(" \u21AF dynamic") : "";
5836
- const route = r.route ? ` ${r.route}` : "";
5837
- lines.push(`${p.green("\u2713")} ${r.id} ${r.message}${marker}${route}`);
5918
+ const where = r.location ?? r.route;
5919
+ const suffix = where ? ` ${where}` : "";
5920
+ lines.push(`${p.green("\u2713")} ${r.id} ${r.message}${marker}${suffix}`);
5838
5921
  }
5839
5922
  }
5840
5923
  lines.push("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-vitals/core",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Shared, runtime-agnostic core for svelte-vitals (types, rule engine, scorer, reporter).",
5
5
  "type": "module",
6
6
  "license": "MIT",