svelte-vitals 0.15.0 → 0.17.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
@@ -6,10 +6,10 @@ import {
6
6
  knownRuleIds,
7
7
  readPackageVersion,
8
8
  run
9
- } from "./chunk-2NZBWTKF.js";
9
+ } from "./chunk-VG647TPJ.js";
10
10
 
11
11
  // src/bin.ts
12
- import mri from "mri";
12
+ import mri2 from "mri";
13
13
 
14
14
  // src/resolve-args.ts
15
15
  var toList = (v) => typeof v === "string" ? v.split(",").map((s) => s.trim()).filter(Boolean) : [];
@@ -26,6 +26,8 @@ function resolveArgs(argv) {
26
26
  );
27
27
  }
28
28
  const route = typeof argv.route === "string" ? argv.route : void 0;
29
+ const diffBase = typeof argv.diff === "string" ? argv.diff || "HEAD" : void 0;
30
+ const staged = Boolean(argv.staged);
29
31
  const allow = toList(argv.rules);
30
32
  const ignore = toList(argv.ignore);
31
33
  const unknown = findUnknownRuleIds([...allow, ...ignore]);
@@ -64,23 +66,330 @@ function resolveArgs(argv) {
64
66
  outFile: typeof argv["out-file"] === "string" ? argv["out-file"] : void 0,
65
67
  byRoute: Boolean(argv["by-route"]),
66
68
  failOn,
67
- rules: buildRulesConfig(allow, ignore)
69
+ rules: buildRulesConfig(allow, ignore),
70
+ ...diffBase !== void 0 ? { diffBase } : {},
71
+ ...staged ? { staged } : {}
72
+ },
73
+ warnings,
74
+ errors
75
+ };
76
+ }
77
+
78
+ // src/install/cli.ts
79
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
80
+ import { dirname } from "path";
81
+ import { homedir } from "os";
82
+ import mri from "mri";
83
+ import * as p from "@clack/prompts";
84
+
85
+ // src/install/clients.ts
86
+ import { join } from "path";
87
+ var MCP_ENTRY = { command: "npx", args: ["-y", "@svelte-vitals/mcp"] };
88
+ var CLIENTS = [
89
+ {
90
+ id: "claude-code",
91
+ label: "Claude Code",
92
+ scopes: ["project", "global"],
93
+ format: "json",
94
+ resolvePath: (scope, cwd, home) => scope === "project" ? join(cwd, ".mcp.json") : join(home, ".claude.json")
95
+ },
96
+ {
97
+ id: "cursor",
98
+ label: "Cursor",
99
+ scopes: ["project", "global"],
100
+ format: "json",
101
+ resolvePath: (scope, cwd, home) => scope === "project" ? join(cwd, ".cursor", "mcp.json") : join(home, ".cursor", "mcp.json")
102
+ },
103
+ {
104
+ id: "codex",
105
+ label: "Codex",
106
+ scopes: ["global"],
107
+ format: "toml",
108
+ resolvePath: (_scope, _cwd, home) => join(home, ".codex", "config.toml")
109
+ }
110
+ ];
111
+ function clientById(id) {
112
+ return CLIENTS.find((c) => c.id === id);
113
+ }
114
+
115
+ // src/install/merge.ts
116
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
117
+ var SERVER_KEY = "svelte-vitals";
118
+ function isPlainObject(v) {
119
+ return typeof v === "object" && v !== null && !Array.isArray(v);
120
+ }
121
+ function sameEntry(prior, entry) {
122
+ if (typeof prior !== "object" || prior === null) return false;
123
+ const o = prior;
124
+ return o.command === entry.command && Array.isArray(o.args) && o.args.length === entry.args.length && o.args.every((v, i) => v === entry.args[i]);
125
+ }
126
+ function statusFor(prior, entry, force, created) {
127
+ if (prior !== void 0) {
128
+ if (sameEntry(prior, entry)) return "exists";
129
+ return force ? "updated" : "skip";
130
+ }
131
+ return created ? "created" : "added";
132
+ }
133
+ function mergeJson(existing, entry, force) {
134
+ const created = existing === void 0;
135
+ const parsed = created ? {} : JSON.parse(existing);
136
+ if (!isPlainObject(parsed)) {
137
+ throw new Error("existing config is not a JSON object");
138
+ }
139
+ const root = parsed;
140
+ if (root.mcpServers !== void 0 && !isPlainObject(root.mcpServers)) {
141
+ throw new Error('existing config has a non-object "mcpServers" table');
142
+ }
143
+ const servers = isPlainObject(root.mcpServers) ? root.mcpServers : {};
144
+ const status = statusFor(servers[SERVER_KEY], entry, force, created);
145
+ if (status === "exists" || status === "skip") return { content: existing, status: "exists" };
146
+ servers[SERVER_KEY] = { command: entry.command, args: entry.args };
147
+ root.mcpServers = servers;
148
+ return { content: JSON.stringify(root, null, 2) + "\n", status };
149
+ }
150
+ function mergeToml(existing, entry, force) {
151
+ const created = existing === void 0;
152
+ const parsed = created ? {} : parseToml(existing);
153
+ if (!isPlainObject(parsed)) {
154
+ throw new Error("existing config is not a TOML table");
155
+ }
156
+ const root = parsed;
157
+ if (root.mcp_servers !== void 0 && !isPlainObject(root.mcp_servers)) {
158
+ throw new Error('existing config has a non-table "mcp_servers" section');
159
+ }
160
+ const servers = isPlainObject(root.mcp_servers) ? root.mcp_servers : {};
161
+ const status = statusFor(servers[SERVER_KEY], entry, force, created);
162
+ if (status === "exists" || status === "skip") return { content: existing, status: "exists" };
163
+ servers[SERVER_KEY] = { command: entry.command, args: entry.args };
164
+ root.mcp_servers = servers;
165
+ return { content: stringifyToml(root), status };
166
+ }
167
+
168
+ // src/install/index.ts
169
+ function planFor(client, scope, io, force) {
170
+ const path = client.resolvePath(scope, io.cwd, io.home);
171
+ const existing = io.readFile(path);
172
+ const merged = client.format === "toml" ? mergeToml(existing, MCP_ENTRY, force) : mergeJson(existing, MCP_ENTRY, force);
173
+ return { client, scope, path, status: merged.status, content: merged.content };
174
+ }
175
+ async function runInstall(flags, io, prompts) {
176
+ let ids;
177
+ if (flags.client && flags.client.length > 0) {
178
+ ids = flags.client;
179
+ } else if (io.isTTY) {
180
+ const configExists = (path) => {
181
+ try {
182
+ return io.readFile(path) !== void 0;
183
+ } catch {
184
+ return false;
185
+ }
186
+ };
187
+ const detected = CLIENTS.filter((c) => c.scopes.some((s) => configExists(c.resolvePath(s, io.cwd, io.home)))).map(
188
+ (c) => c.id
189
+ );
190
+ const picked = await prompts.selectClients(CLIENTS, detected);
191
+ if (picked === null) {
192
+ io.log("Cancelled.");
193
+ return 0;
194
+ }
195
+ ids = picked;
196
+ } else {
197
+ io.errorLog("svelte-vitals: no TTY; pass --client <claude-code,cursor,codex> to install non-interactively.");
198
+ return 2;
199
+ }
200
+ const clients = ids.map(clientById).filter((c) => c !== void 0);
201
+ if (clients.length === 0) {
202
+ io.errorLog("svelte-vitals: no valid clients selected.");
203
+ return 2;
204
+ }
205
+ const rows = [];
206
+ for (const client of clients) {
207
+ let scope;
208
+ if (client.scopes.length === 1) {
209
+ scope = client.scopes[0];
210
+ } else if (flags.scope) {
211
+ scope = flags.scope;
212
+ } else if (io.isTTY) {
213
+ const picked = await prompts.selectScope(client);
214
+ if (picked === null) {
215
+ io.log("Cancelled.");
216
+ return 0;
217
+ }
218
+ scope = picked;
219
+ } else {
220
+ scope = "project";
221
+ }
222
+ try {
223
+ rows.push(planFor(client, scope, io, flags.force ?? false));
224
+ } catch (err) {
225
+ const path = client.resolvePath(scope, io.cwd, io.home);
226
+ io.errorLog(
227
+ `svelte-vitals: could not parse existing config at ${path}: ${err instanceof Error ? err.message : String(err)}`
228
+ );
229
+ return 2;
230
+ }
231
+ }
232
+ const planText = rows.map((r) => ` ${r.client.label} (${r.scope}) \u2192 ${r.path} [${r.status}]`).join("\n");
233
+ io.log("Plan:");
234
+ io.log(planText);
235
+ if (flags.dryRun) {
236
+ io.log("Dry run \u2014 no files written.");
237
+ return 0;
238
+ }
239
+ if (!flags.yes && io.isTTY) {
240
+ const ok = await prompts.confirm(planText);
241
+ if (!ok) {
242
+ io.log("Cancelled.");
243
+ return 0;
244
+ }
245
+ }
246
+ let hadFailure = false;
247
+ for (const r of rows) {
248
+ if (r.status === "exists") {
249
+ io.log(`= ${r.client.label}: already configured (${r.path}) \u2014 use --force to overwrite.`);
250
+ continue;
251
+ }
252
+ try {
253
+ io.writeFile(r.path, r.content);
254
+ io.log(`\u2713 ${r.client.label}: ${r.status} ${r.path}`);
255
+ } catch (err) {
256
+ hadFailure = true;
257
+ io.errorLog(`svelte-vitals: failed to write ${r.path}: ${err instanceof Error ? err.message : String(err)}`);
258
+ }
259
+ }
260
+ if (hadFailure) return 2;
261
+ io.log("");
262
+ io.log("Done. Restart your client to load the svelte-vitals MCP server.");
263
+ return 0;
264
+ }
265
+
266
+ // src/install/args.ts
267
+ var VALID_CLIENTS = CLIENTS.map((c) => c.id);
268
+ function resolveInstallArgs(argv) {
269
+ const warnings = [];
270
+ const errors = [];
271
+ const rawClients = typeof argv.client === "string" ? argv.client.split(",").map((s) => s.trim()).filter(Boolean) : [];
272
+ const client = [];
273
+ for (const c of rawClients) {
274
+ if (VALID_CLIENTS.includes(c)) {
275
+ if (!client.includes(c)) client.push(c);
276
+ } else {
277
+ warnings.push(`svelte-vitals: unknown --client '${c}'; expected claude-code|cursor|codex. Skipping.`);
278
+ }
279
+ }
280
+ if (rawClients.length > 0 && client.length === 0) {
281
+ errors.push("svelte-vitals: no valid --client values; expected claude-code|cursor|codex.");
282
+ }
283
+ let scope;
284
+ const rawScope = argv.scope;
285
+ if (typeof rawScope === "string") {
286
+ if (rawScope === "project" || rawScope === "global") scope = rawScope;
287
+ else errors.push(`svelte-vitals: unknown --scope '${rawScope}'; expected project|global.`);
288
+ }
289
+ if (errors.length > 0) return { flags: null, warnings, errors };
290
+ return {
291
+ flags: {
292
+ ...client.length > 0 ? { client } : {},
293
+ ...scope ? { scope } : {},
294
+ yes: Boolean(argv.yes),
295
+ dryRun: Boolean(argv["dry-run"]),
296
+ force: Boolean(argv.force)
68
297
  },
69
298
  warnings,
70
299
  errors
71
300
  };
72
301
  }
73
302
 
303
+ // src/install/cli.ts
304
+ var INSTALL_HELP = `svelte-vitals install \u2014 set up the svelte-vitals MCP server for your AI-agent clients
305
+
306
+ Usage:
307
+ svelte-vitals install [options]
308
+
309
+ Options:
310
+ --client <ids> Comma-separated: claude-code,cursor,codex (skips the interactive picker)
311
+ --scope <scope> project | global (applies to all selected clients; codex is always global)
312
+ --yes, -y Skip the confirmation prompt
313
+ --dry-run Print the planned changes and exit without writing
314
+ --force Overwrite an existing svelte-vitals entry
315
+ -h, --help Show this help`;
316
+ function realIO() {
317
+ return {
318
+ readFile: (path) => {
319
+ try {
320
+ return readFileSync(path, "utf8");
321
+ } catch (err) {
322
+ if (err.code === "ENOENT") return void 0;
323
+ throw err;
324
+ }
325
+ },
326
+ writeFile: (path, content) => {
327
+ mkdirSync(dirname(path), { recursive: true });
328
+ writeFileSync(path, content);
329
+ },
330
+ cwd: process.cwd(),
331
+ home: homedir(),
332
+ isTTY: Boolean(process.stdout.isTTY),
333
+ log: (line) => console.log(line),
334
+ errorLog: (line) => console.error(line)
335
+ };
336
+ }
337
+ function clackPrompts() {
338
+ return {
339
+ selectClients: async (all, defaults) => {
340
+ const res = await p.multiselect({
341
+ message: "Which clients should svelte-vitals be installed for?",
342
+ options: all.map((c) => ({ value: c.id, label: c.label })),
343
+ initialValues: defaults,
344
+ required: true
345
+ });
346
+ return p.isCancel(res) ? null : res;
347
+ },
348
+ selectScope: async (client) => {
349
+ const res = await p.select({
350
+ message: `Scope for ${client.label}?`,
351
+ options: client.scopes.map((s) => ({ value: s, label: s })),
352
+ initialValue: client.scopes[0]
353
+ });
354
+ return p.isCancel(res) ? null : res;
355
+ },
356
+ confirm: async (planText) => {
357
+ const res = await p.confirm({ message: `Apply this plan?
358
+ ${planText}` });
359
+ return p.isCancel(res) ? false : Boolean(res);
360
+ }
361
+ };
362
+ }
363
+ async function runInstallCli(args) {
364
+ const argv = mri(args, {
365
+ boolean: ["yes", "dry-run", "force", "help"],
366
+ string: ["client", "scope"],
367
+ alias: { y: "yes", h: "help" }
368
+ });
369
+ if (argv.help) {
370
+ console.log(INSTALL_HELP);
371
+ return 0;
372
+ }
373
+ const { flags, warnings, errors } = resolveInstallArgs(argv);
374
+ for (const w of warnings) console.error(w);
375
+ for (const e of errors) console.error(e);
376
+ if (!flags) return 2;
377
+ return runInstall(flags, realIO(), clackPrompts());
378
+ }
379
+
74
380
  // src/bin.ts
75
- var HELP = `svelte-vitals \u2014 a SvelteKit SEO checker (static mode)
381
+ var HELP = `svelte-vitals \u2014 a deterministic SvelteKit code-health scanner (SEO \xB7 performance \xB7 correctness \xB7 security \xB7 architecture)
76
382
 
77
383
  Usage:
78
384
  svelte-vitals [path] [options]
385
+ svelte-vitals install Set up the MCP server for Claude Code / Cursor / Codex
79
386
 
80
387
  Options:
81
388
  --meta-components <names> Comma-separated component names that emit head metadata
82
389
  --treat-dynamic-as <mode> pass | warn | fail (default: pass)
83
390
  --route <glob> Only analyze routes matching this glob
391
+ --diff [ref] Report only findings in files changed vs ref (default HEAD; e.g. --diff main)
392
+ --staged Report only findings in files staged for commit (pre-commit gate)
84
393
  --by-route Show per-route score breakdown in console output
85
394
  --reporter <fmt> console | json | agent | sarif | github | html (auto: agent under AI-agent envs, github under GitHub Actions)
86
395
  --out-file <path> Output path for --reporter html (default: svelte-vitals-report.html; '-' for stdout)
@@ -90,6 +399,7 @@ Options:
90
399
  --min-health <0-100> Fail (exit 1) when the combined Health score is below this value
91
400
  --rules <ids> Comma-separated rule ids to enable (all others disabled)
92
401
  --ignore <ids> Comma-separated rule ids to disable
402
+ --no-color Disable ANSI color in console output
93
403
  -h, --help Show this help
94
404
  -v, --version Show version
95
405
 
@@ -99,9 +409,14 @@ Exit codes:
99
409
  2 execution error (not a SvelteKit project / internal error)`;
100
410
  var VERSION = readPackageVersion();
101
411
  async function main() {
102
- const argv = mri(process.argv.slice(2), {
412
+ const rawArgs = process.argv.slice(2);
413
+ if (rawArgs[0] === "install") {
414
+ const code2 = await runInstallCli(rawArgs.slice(1));
415
+ process.exit(code2);
416
+ }
417
+ const argv = mri2(process.argv.slice(2), {
103
418
  alias: { h: "help", v: "version" },
104
- boolean: ["by-route", "json", "fail-on-warning"],
419
+ boolean: ["by-route", "json", "fail-on-warning", "staged", "no-color"],
105
420
  string: [
106
421
  "meta-components",
107
422
  "treat-dynamic-as",
@@ -111,7 +426,8 @@ async function main() {
111
426
  "rules",
112
427
  "ignore",
113
428
  "min-health",
114
- "out-file"
429
+ "out-file",
430
+ "diff"
115
431
  ]
116
432
  });
117
433
  if (argv.help) {
@@ -136,7 +452,7 @@ async function main() {
136
452
  }
137
453
  minHealth = n;
138
454
  }
139
- const code = await run({ ...options, minHealth });
455
+ const code = await run({ ...options, minHealth, noColor: argv["no-color"] });
140
456
  process.exit(code);
141
457
  }
142
458
  void main();
@@ -419,6 +419,138 @@ function bodyOnlyAssignsState(fn, stateNames) {
419
419
  if (body.body.length === 0) return false;
420
420
  return body.body.every((s) => s?.type === "ExpressionStatement" && isStateAssign(s.expression));
421
421
  }
422
+ function isDerivedDeclaration(node) {
423
+ const c = node?.callee;
424
+ if (c?.type === "Identifier") return c.name === "$derived";
425
+ if (c?.type === "MemberExpression" && c.object?.type === "Identifier" && c.object.name === "$derived") {
426
+ return c.property?.type === "Identifier" && c.property.name === "by";
427
+ }
428
+ return false;
429
+ }
430
+ function addBoundNames(id, acc) {
431
+ if (!id) return;
432
+ switch (id.type) {
433
+ case "Identifier":
434
+ acc.add(id.name);
435
+ break;
436
+ case "ObjectPattern":
437
+ for (const p of id.properties ?? []) {
438
+ if (p?.type === "Property") addBoundNames(p.value, acc);
439
+ else if (p?.type === "RestElement") addBoundNames(p.argument, acc);
440
+ }
441
+ break;
442
+ case "ArrayPattern":
443
+ for (const el of id.elements ?? []) addBoundNames(el, acc);
444
+ break;
445
+ case "AssignmentPattern":
446
+ addBoundNames(id.left, acc);
447
+ break;
448
+ case "RestElement":
449
+ addBoundNames(id.argument, acc);
450
+ break;
451
+ }
452
+ }
453
+ function rootObjectName(node) {
454
+ let cur = node;
455
+ while (cur?.type === "MemberExpression") cur = cur.object;
456
+ return cur?.type === "Identifier" ? cur.name : void 0;
457
+ }
458
+ function collectStateWrites(root, stateNames, acc) {
459
+ walkEstree(root, (n) => {
460
+ if (n?.type === "AssignmentExpression") {
461
+ if (n.left?.type === "Identifier" && stateNames.has(n.left.name)) acc.add(n.left.name);
462
+ else if (n.left?.type === "MemberExpression") {
463
+ const r = rootObjectName(n.left);
464
+ if (r && stateNames.has(r)) acc.add(r);
465
+ } else if (n.left?.type === "ObjectPattern" || n.left?.type === "ArrayPattern") {
466
+ const bound = /* @__PURE__ */ new Set();
467
+ addBoundNames(n.left, bound);
468
+ for (const name of bound) if (stateNames.has(name)) acc.add(name);
469
+ }
470
+ } else if (n?.type === "UpdateExpression") {
471
+ const r = rootObjectName(n.argument);
472
+ if (r && stateNames.has(r)) acc.add(r);
473
+ } else if (n?.type === "UnaryExpression" && n.operator === "delete") {
474
+ const r = rootObjectName(n.argument);
475
+ if (r && stateNames.has(r)) acc.add(r);
476
+ } else if (n?.type === "CallExpression") {
477
+ if (n.callee?.type === "MemberExpression") {
478
+ const r = rootObjectName(n.callee);
479
+ if (r && stateNames.has(r)) acc.add(r);
480
+ }
481
+ for (const a of n.arguments ?? []) {
482
+ const arg = a?.type === "SpreadElement" ? a.argument : a;
483
+ const r = rootObjectName(arg);
484
+ if (r && stateNames.has(r)) acc.add(r);
485
+ }
486
+ }
487
+ });
488
+ }
489
+ var COMPONENT_LIKE_TYPES = /* @__PURE__ */ new Set(["Component", "SvelteComponent", "SvelteSelf"]);
490
+ function collectTemplateEscapes(node, stateNames, acc) {
491
+ if (Array.isArray(node)) {
492
+ for (const c of node) collectTemplateEscapes(c, stateNames, acc);
493
+ return;
494
+ }
495
+ if (!node || typeof node !== "object" || typeof node.type !== "string") return;
496
+ if (Array.isArray(node.attributes)) {
497
+ for (const attr of node.attributes) {
498
+ if (attr?.type === "BindDirective") {
499
+ const r = rootObjectName(attr.expression);
500
+ if (r && stateNames.has(r)) acc.add(r);
501
+ } else if (COMPONENT_LIKE_TYPES.has(node.type)) {
502
+ walkEstree(attr, (m) => {
503
+ if (m?.type === "Identifier" && stateNames.has(m.name)) acc.add(m.name);
504
+ });
505
+ }
506
+ }
507
+ }
508
+ for (const key of CHILD_NODE_KEYS) {
509
+ if (key in node) collectTemplateEscapes(node[key], stateNames, acc);
510
+ }
511
+ }
512
+ var RUNE_NAMES = /* @__PURE__ */ new Set(["$state", "$derived", "$effect", "$props", "$bindable", "$inspect", "$host"]);
513
+ function bodyReadsReactive(fn, reactiveNames) {
514
+ let reads = false;
515
+ const IGNORED_KEYS = /* @__PURE__ */ new Set(["type", "start", "end", "loc", "range"]);
516
+ const visit = (n) => {
517
+ if (reads || !n) return;
518
+ if (Array.isArray(n)) {
519
+ for (const c of n) visit(c);
520
+ return;
521
+ }
522
+ if (typeof n !== "object" || typeof n.type !== "string") return;
523
+ if (n.type === "Identifier") {
524
+ if (reactiveNames.has(n.name) || n.name.startsWith("$") && !RUNE_NAMES.has(n.name)) reads = true;
525
+ return;
526
+ }
527
+ if (n.type === "CallExpression" && n.callee?.type === "Identifier") {
528
+ reads = true;
529
+ return;
530
+ }
531
+ if (n.type === "MemberExpression") {
532
+ visit(n.object);
533
+ if (n.computed) visit(n.property);
534
+ return;
535
+ }
536
+ if (n.type === "Property") {
537
+ if (n.computed) visit(n.key);
538
+ visit(n.value);
539
+ return;
540
+ }
541
+ for (const key of Object.keys(n)) {
542
+ if (!IGNORED_KEYS.has(key)) visit(n[key]);
543
+ }
544
+ };
545
+ visit(fn.body);
546
+ return reads;
547
+ }
548
+ function bodyIsEmpty(fn) {
549
+ const body = fn?.body;
550
+ if (!body) return true;
551
+ if (body.type === "BlockStatement") return (body.body ?? []).length === 0;
552
+ return false;
553
+ }
422
554
  var URL_ATTRS = ["href", "src", "action", "formaction"];
423
555
  function collectSecurityFacts(node, source, htmlTags, jsUrls) {
424
556
  if (Array.isArray(node)) {
@@ -441,6 +573,47 @@ function collectSecurityFacts(node, source, htmlTags, jsUrls) {
441
573
  if (key in node) collectSecurityFacts(node[key], source, htmlTags, jsUrls);
442
574
  }
443
575
  }
576
+ function isPropsCall(node) {
577
+ return node?.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "$props";
578
+ }
579
+ function countProps(program) {
580
+ let count = 0;
581
+ let seen = 0;
582
+ let uncountable = false;
583
+ walkEstree(program, (n) => {
584
+ if (n.type !== "VariableDeclarator" || !n.init || !isPropsCall(n.init)) return;
585
+ seen++;
586
+ const props = n.id?.type === "ObjectPattern" ? n.id.properties : void 0;
587
+ if (!Array.isArray(props) || props.some((p) => p?.type === "RestElement")) {
588
+ uncountable = true;
589
+ return;
590
+ }
591
+ count = props.filter((p) => p?.type === "Property").length;
592
+ });
593
+ return uncountable || seen > 1 ? 0 : count;
594
+ }
595
+ function countLines(source) {
596
+ if (source.length === 0) return 0;
597
+ return source.split("\n").length - (source.endsWith("\n") ? 1 : 0);
598
+ }
599
+ function collectImportSources(program, acc) {
600
+ walkEstree(program, (n) => {
601
+ if (n.type === "ImportDeclaration" && typeof n.source?.value === "string") acc.push(n.source.value);
602
+ });
603
+ }
604
+ function isBareSpecifier(s) {
605
+ return !/^[./$#]/.test(s);
606
+ }
607
+ function collectNamespaceImports(program, source, acc) {
608
+ walkEstree(program, (n) => {
609
+ if (n.type !== "ImportDeclaration" || n.importKind === "type") return;
610
+ const spec = n.source?.value;
611
+ if (typeof spec !== "string" || !isBareSpecifier(spec)) return;
612
+ if (Array.isArray(n.specifiers) && n.specifiers.some((s) => s?.type === "ImportNamespaceSpecifier")) {
613
+ acc.push({ source: spec, line: lineOf(source, n.start) });
614
+ }
615
+ });
616
+ }
444
617
  function parseComponentFacts(source, filename) {
445
618
  const ast = parse(source, { modern: true, filename });
446
619
  const eachBlocks = [];
@@ -448,14 +621,32 @@ function parseComponentFacts(source, filename) {
448
621
  const htmlTags = [];
449
622
  const javascriptUrls = [];
450
623
  collectSecurityFacts(ast.fragment ?? ast, source, htmlTags, javascriptUrls);
624
+ const loc = countLines(source);
625
+ const imports = [];
626
+ const namespaceImports = [];
627
+ if (ast.module?.content) {
628
+ collectImportSources(ast.module.content, imports);
629
+ collectNamespaceImports(ast.module.content, source, namespaceImports);
630
+ }
451
631
  const effects = [];
632
+ const constableStates = [];
633
+ let propCount = 0;
452
634
  const program = ast.instance?.content;
453
635
  if (program) {
636
+ collectImportSources(program, imports);
637
+ collectNamespaceImports(program, source, namespaceImports);
638
+ propCount = countProps(program);
454
639
  const stateNames = /* @__PURE__ */ new Set();
640
+ const reactiveNames = /* @__PURE__ */ new Set();
641
+ const stateDecls = [];
455
642
  walkEstree(program, (n) => {
456
- if (n.type === "VariableDeclarator" && n.init && isStateDeclaration(n.init) && n.id?.type === "Identifier") {
643
+ if (n.type !== "VariableDeclarator" || !n.init) return;
644
+ if (isStateDeclaration(n.init) && n.id?.type === "Identifier") {
457
645
  stateNames.add(n.id.name);
646
+ stateDecls.push({ name: n.id.name, line: lineOf(source, n.start) });
458
647
  }
648
+ if (isStateDeclaration(n.init) || isDerivedDeclaration(n.init) || isPropsCall(n.init))
649
+ addBoundNames(n.id, reactiveNames);
459
650
  });
460
651
  walkEstree(program, (n) => {
461
652
  if (n.type !== "CallExpression" || !isEffectCall(n)) return;
@@ -463,11 +654,21 @@ function parseComponentFacts(source, filename) {
463
654
  const isFn = fn?.type === "ArrowFunctionExpression" || fn?.type === "FunctionExpression";
464
655
  effects.push({
465
656
  line: lineOf(source, n.start),
466
- assignsOnlyState: isFn ? bodyOnlyAssignsState(fn, stateNames) : false
657
+ assignsOnlyState: isFn ? bodyOnlyAssignsState(fn, stateNames) : false,
658
+ mountOnly: isFn ? !bodyIsEmpty(fn) && !bodyReadsReactive(fn, reactiveNames) : false
467
659
  });
468
660
  });
661
+ const writtenOrEscaped = /* @__PURE__ */ new Set();
662
+ collectStateWrites(program, stateNames, writtenOrEscaped);
663
+ if (ast.fragment) {
664
+ collectStateWrites(ast.fragment, stateNames, writtenOrEscaped);
665
+ collectTemplateEscapes(ast.fragment, stateNames, writtenOrEscaped);
666
+ }
667
+ for (const d of stateDecls) {
668
+ if (!writtenOrEscaped.has(d.name)) constableStates.push(d);
669
+ }
469
670
  }
470
- return { eachBlocks, effects, htmlTags, javascriptUrls };
671
+ return { eachBlocks, effects, htmlTags, javascriptUrls, loc, propCount, imports, namespaceImports, constableStates };
471
672
  }
472
673
 
473
674
  // src/providers/source/adapters/svelte-meta-tags.ts
@@ -743,7 +944,18 @@ async function collectComponentFacts(rt, cwd) {
743
944
  const source = await rt.readFile(rt.join(cwd, rel));
744
945
  return { file: rel, ...parseComponentFacts(source, rel) };
745
946
  } catch {
746
- return { file: rel, eachBlocks: [], effects: [], htmlTags: [], javascriptUrls: [] };
947
+ return {
948
+ file: rel,
949
+ eachBlocks: [],
950
+ effects: [],
951
+ htmlTags: [],
952
+ javascriptUrls: [],
953
+ loc: 0,
954
+ propCount: 0,
955
+ imports: [],
956
+ namespaceImports: [],
957
+ constableStates: []
958
+ };
747
959
  }
748
960
  })
749
961
  );
@@ -789,6 +1001,69 @@ function isAutoDetectedGithub(explicit, env = process.env) {
789
1001
  return !explicit && !isReporterName(env.SVELTE_VITALS_REPORTER) && !isAgentEnv(env) && isGithubActionsEnv(env);
790
1002
  }
791
1003
 
1004
+ // src/changed-files.ts
1005
+ import { execFileSync } from "child_process";
1006
+ function git(args, cwd) {
1007
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\n");
1008
+ }
1009
+ function getChangedFiles(cwd, opts) {
1010
+ try {
1011
+ const files = opts.staged ? git(["diff", "--name-only", "--cached", "--diff-filter=d"], cwd) : [
1012
+ ...git(["diff", "--name-only", "--diff-filter=d", "--merge-base", opts.base ?? "HEAD"], cwd),
1013
+ ...git(["ls-files", "--others", "--exclude-standard"], cwd)
1014
+ // untracked / new files
1015
+ ];
1016
+ return new Set(files.map((s) => s.trim()).filter(Boolean));
1017
+ } catch {
1018
+ return void 0;
1019
+ }
1020
+ }
1021
+ function filterToChangedFiles(results, changed) {
1022
+ return results.filter((r) => r.location !== void 0 && changed.has(r.location));
1023
+ }
1024
+
1025
+ // src/color.ts
1026
+ import { noColorPalette } from "@svelte-vitals/core";
1027
+ var wrap = (open, close = 0) => (s) => `\x1B[${open}m${s}\x1B[${close}m`;
1028
+ var ansiPalette = {
1029
+ bold: wrap(1, 22),
1030
+ dim: wrap(2, 22),
1031
+ red: wrap(31, 39),
1032
+ yellow: wrap(33, 39),
1033
+ green: wrap(32, 39),
1034
+ cyan: wrap(36, 39)
1035
+ };
1036
+ function colorEnabled(opts) {
1037
+ if (opts.noColorFlag) return false;
1038
+ if (opts.env.NO_COLOR !== void 0 && opts.env.NO_COLOR !== "") return false;
1039
+ const fc = opts.env.FORCE_COLOR;
1040
+ if (fc !== void 0 && fc !== "" && fc !== "0") return true;
1041
+ return opts.reporter === "console" && opts.isTTY;
1042
+ }
1043
+ var paletteFor = (enabled) => enabled ? ansiPalette : noColorPalette;
1044
+
1045
+ // src/spinner.ts
1046
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1047
+ function startSpinner(text, opts) {
1048
+ const stream = opts.stream ?? process.stderr;
1049
+ if (!opts.enabled) return { stop() {
1050
+ } };
1051
+ let i = 0;
1052
+ const tick = () => {
1053
+ stream.write(`\r${FRAMES[i % FRAMES.length]} ${text}`);
1054
+ i++;
1055
+ };
1056
+ tick();
1057
+ const timer = setInterval(tick, 80);
1058
+ if (typeof timer.unref === "function") timer.unref();
1059
+ return {
1060
+ stop() {
1061
+ clearInterval(timer);
1062
+ stream.write("\r\x1B[K");
1063
+ }
1064
+ };
1065
+ }
1066
+
792
1067
  // src/rules-config.ts
793
1068
  import { allRules } from "@svelte-vitals/core";
794
1069
  var KNOWN_IDS = new Set(allRules.map((r) => r.id));
@@ -808,6 +1083,9 @@ function buildRulesConfig(allow, ignore) {
808
1083
  }
809
1084
 
810
1085
  // src/index.ts
1086
+ function spinnerEnabled(opts) {
1087
+ return opts.reporter === "console" && opts.stderrIsTTY && !isAutoDetectedAgent(opts.rawReporter, opts.env) && colorEnabled({ reporter: opts.reporter, isTTY: opts.stderrIsTTY, env: opts.env, noColorFlag: opts.noColorFlag });
1088
+ }
811
1089
  function routeMatcher(glob) {
812
1090
  if (!glob) return () => true;
813
1091
  const body = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, " ").replace(/\*/g, "[^/]*").replace(/\/ $/g, "(?:/.*)?").replace(/^ \//g, "(?:.*/)?").replace(/ \//g, "(?:.*/)?").replace(/\/ /g, "(?:/.*)?").replace(/ /g, ".*");
@@ -845,6 +1123,17 @@ async function run(opts = {}) {
845
1123
  errorLog(`svelte-vitals: invalid minHealth '${opts.minHealth}'; expected a number 0-100.`);
846
1124
  return 2;
847
1125
  }
1126
+ const env = opts.env ?? process.env;
1127
+ const reporter = resolveReporter(opts.reporter, env);
1128
+ const spinner = startSpinner("Analyzing\u2026", {
1129
+ enabled: spinnerEnabled({
1130
+ reporter,
1131
+ rawReporter: opts.reporter,
1132
+ stderrIsTTY: opts.stderrIsTTY ?? !!process.stderr.isTTY,
1133
+ env,
1134
+ noColorFlag: opts.noColor
1135
+ })
1136
+ });
848
1137
  let analysis;
849
1138
  try {
850
1139
  analysis = await analyzeProject({
@@ -856,6 +1145,7 @@ async function run(opts = {}) {
856
1145
  rules: opts.rules
857
1146
  });
858
1147
  } catch (err) {
1148
+ spinner.stop();
859
1149
  if (err instanceof ProjectError) {
860
1150
  errorLog(err.message);
861
1151
  return 2;
@@ -863,10 +1153,21 @@ async function run(opts = {}) {
863
1153
  errorLog(`svelte-vitals: ${err instanceof Error ? err.message : String(err)}`);
864
1154
  return 2;
865
1155
  }
1156
+ spinner.stop();
866
1157
  try {
867
- const { results, config, version } = analysis;
868
- const env = opts.env ?? process.env;
869
- const reporter = resolveReporter(opts.reporter, env);
1158
+ const { config, version } = analysis;
1159
+ let results = analysis.results;
1160
+ if (opts.staged || opts.diffBase !== void 0) {
1161
+ const cwd = opts.cwd ?? process.cwd();
1162
+ const changed = opts.staged ? getChangedFiles(cwd, { staged: true }) : getChangedFiles(cwd, { base: opts.diffBase });
1163
+ if (changed === void 0) {
1164
+ errorLog(
1165
+ "svelte-vitals: could not determine changed files (not a git repo, git unavailable, or bad ref); analyzing all."
1166
+ );
1167
+ } else {
1168
+ results = filterToChangedFiles(results, changed);
1169
+ }
1170
+ }
870
1171
  if (reporter === "agent" && isAutoDetectedAgent(opts.reporter, env)) {
871
1172
  errorLog(
872
1173
  "svelte-vitals: agent reporter auto-selected (AI-agent env detected); override with --reporter console|json."
@@ -900,7 +1201,13 @@ async function run(opts = {}) {
900
1201
  errorLog(`svelte-vitals: wrote report to ${path}`);
901
1202
  }
902
1203
  } else {
903
- log(formatConsoleReport(results, config, { byRoute: opts.byRoute ?? false }));
1204
+ const colorOn = colorEnabled({
1205
+ reporter,
1206
+ isTTY: opts.stdoutIsTTY ?? !!process.stdout.isTTY,
1207
+ env,
1208
+ noColorFlag: opts.noColor
1209
+ });
1210
+ log(formatConsoleReport(results, config, { byRoute: opts.byRoute ?? false, palette: paletteFor(colorOn) }));
904
1211
  }
905
1212
  const summary = summarize(results, config);
906
1213
  const failBySeverity = hasFailureAtOrAbove(summary, config.failOn);
@@ -919,6 +1226,7 @@ export {
919
1226
  findUnknownRuleIds,
920
1227
  knownRuleIds,
921
1228
  buildRulesConfig,
1229
+ spinnerEnabled,
922
1230
  routeMatcher,
923
1231
  analyzeProject,
924
1232
  run
package/dist/index.d.ts CHANGED
@@ -39,7 +39,31 @@ interface RunOptions {
39
39
  outFile?: string;
40
40
  /** Injected file writer for --reporter html (defaults to node:fs writeFileSync). Mainly for tests. */
41
41
  writeFile?: (path: string, content: string) => void;
42
+ /** Report only findings in files changed vs the merge-base with this ref ('HEAD' = uncommitted). Undefined = no gating. */
43
+ diffBase?: string;
44
+ /** Report only findings in files staged for commit. Takes precedence over `diffBase`. */
45
+ staged?: boolean;
46
+ /** Disable ANSI color in console output. */
47
+ noColor?: boolean;
48
+ /** Override stdout TTY detection (tests). */
49
+ stdoutIsTTY?: boolean;
50
+ /** Override stderr TTY detection (tests). */
51
+ stderrIsTTY?: boolean;
42
52
  }
53
+ /**
54
+ * Whether the "Analyzing…" spinner should run. Unlike color, the spinner animates
55
+ * with carriage returns and escape codes, so it needs a real interactive stderr —
56
+ * `FORCE_COLOR` must NOT force it on in a non-TTY stderr (e.g. CI), where `\r` would
57
+ * clutter the log. So gate on `stderrIsTTY` unconditionally, then reuse the color
58
+ * gating (NO_COLOR / --no-color / agent env) with that same TTY value.
59
+ */
60
+ declare function spinnerEnabled(opts: {
61
+ reporter: ReporterName;
62
+ rawReporter: ReporterName | undefined;
63
+ stderrIsTTY: boolean;
64
+ env: NodeJS.ProcessEnv;
65
+ noColorFlag?: boolean;
66
+ }): boolean;
43
67
  declare function routeMatcher(glob: string | undefined): (route: string) => boolean;
44
68
  interface AnalyzeOptions {
45
69
  cwd?: string;
@@ -67,4 +91,4 @@ declare function analyzeProject(opts?: AnalyzeOptions): Promise<AnalyzeResult>;
67
91
  */
68
92
  declare function run(opts?: RunOptions): Promise<number>;
69
93
 
70
- export { type AnalyzeOptions, type AnalyzeResult, ProjectError, type RunOptions, analyzeProject, buildRulesConfig, findUnknownRuleIds, knownRuleIds, routeMatcher, run };
94
+ export { type AnalyzeOptions, type AnalyzeResult, ProjectError, type RunOptions, analyzeProject, buildRulesConfig, findUnknownRuleIds, knownRuleIds, routeMatcher, run, spinnerEnabled };
package/dist/index.js CHANGED
@@ -5,8 +5,9 @@ import {
5
5
  findUnknownRuleIds,
6
6
  knownRuleIds,
7
7
  routeMatcher,
8
- run
9
- } from "./chunk-2NZBWTKF.js";
8
+ run,
9
+ spinnerEnabled
10
+ } from "./chunk-VG647TPJ.js";
10
11
  export {
11
12
  ProjectError,
12
13
  analyzeProject,
@@ -14,5 +15,6 @@ export {
14
15
  findUnknownRuleIds,
15
16
  knownRuleIds,
16
17
  routeMatcher,
17
- run
18
+ run,
19
+ spinnerEnabled
18
20
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-vitals",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "A SvelteKit SEO checker — not a runtime Web Vitals reporter. Static analysis of your routes' head metadata.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "homepage": "https://github.com/oekazuma/svelte-vitals#readme",
25
25
  "engines": {
26
- "node": ">=18"
26
+ "node": ">=18.20.8"
27
27
  },
28
28
  "sideEffects": false,
29
29
  "bin": {
@@ -39,10 +39,12 @@
39
39
  "dist"
40
40
  ],
41
41
  "dependencies": {
42
+ "@clack/prompts": "^0.11.0",
42
43
  "mri": "^1.2.0",
44
+ "smol-toml": "^1.7.0",
43
45
  "svelte": "^5.56.3",
44
46
  "tinyglobby": "^0.2.17",
45
- "@svelte-vitals/core": "0.16.0"
47
+ "@svelte-vitals/core": "0.18.0"
46
48
  },
47
49
  "devDependencies": {
48
50
  "@types/node": "^24.7.0"