svelte-5-doctor-core 0.1.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.
@@ -0,0 +1,4 @@
1
+
2
+ > svelte-5-doctor-core@0.1.0 build C:\Users\admin\Documents\Default Project\svelte-doctor\packages\core
3
+ > tsc -p tsconfig.json
4
+
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Constants — ported from react-doctor-source/packages/core/src/constants.ts
3
+ * Adapted for Svelte Doctor. Magic numbers use SCREAMING_SNAKE_CASE with unit suffix.
4
+ */
5
+ export declare const MAX_FILE_SIZE_BYTES = 1000000;
6
+ export declare const MAX_DIAGNOSTICS_PER_FILE = 50;
7
+ export declare const GIANT_COMPONENT_THRESHOLD_LINES = 400;
8
+ export declare const GIANT_COMPONENT_THRESHOLD_COMPLEXITY = 300;
9
+ export declare const TELEMETRY_SHUTDOWN_TIMEOUT_MS = 1000;
10
+ export declare const TELEMETRY_EXPORT_INTERVAL_MS = 60000;
11
+ export declare const OXLINT_SPAWN_TIMEOUT_MS = 120000;
12
+ export declare const SVELTE_EXTENSIONS: readonly [".svelte", ".svelte.js", ".svelte.ts"];
13
+ export declare const JS_EXTENSIONS: readonly [".js", ".ts", ".jsx", ".tsx"];
14
+ export declare const ALL_SCAN_EXTENSIONS: readonly [".svelte", ".svelte.js", ".svelte.ts", ".js", ".ts", ".jsx", ".tsx"];
15
+ export declare const SCORE_WEIGHTS: {
16
+ readonly ERROR: 15;
17
+ readonly WARN: 5;
18
+ };
19
+ export declare const SCORE_BANDS: {
20
+ readonly GREAT_THRESHOLD: 75;
21
+ readonly NEEDS_WORK_THRESHOLD: 50;
22
+ };
23
+ export declare const IGNORED_DIRS: readonly ["node_modules", ".git", ".svelte-kit", "dist", "build", ".turbo", ".vercel", ".next"];
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Constants — ported from react-doctor-source/packages/core/src/constants.ts
3
+ * Adapted for Svelte Doctor. Magic numbers use SCREAMING_SNAKE_CASE with unit suffix.
4
+ */
5
+ export const MAX_FILE_SIZE_BYTES = 1_000_000;
6
+ export const MAX_DIAGNOSTICS_PER_FILE = 50;
7
+ export const GIANT_COMPONENT_THRESHOLD_LINES = 400;
8
+ export const GIANT_COMPONENT_THRESHOLD_COMPLEXITY = 300;
9
+ export const TELEMETRY_SHUTDOWN_TIMEOUT_MS = 1000;
10
+ export const TELEMETRY_EXPORT_INTERVAL_MS = 60_000;
11
+ export const OXLINT_SPAWN_TIMEOUT_MS = 120_000;
12
+ export const SVELTE_EXTENSIONS = [".svelte", ".svelte.js", ".svelte.ts"];
13
+ export const JS_EXTENSIONS = [".js", ".ts", ".jsx", ".tsx"];
14
+ export const ALL_SCAN_EXTENSIONS = [...SVELTE_EXTENSIONS, ...JS_EXTENSIONS];
15
+ export const SCORE_WEIGHTS = {
16
+ ERROR: 15,
17
+ WARN: 5,
18
+ };
19
+ export const SCORE_BANDS = {
20
+ GREAT_THRESHOLD: 75,
21
+ NEEDS_WORK_THRESHOLD: 50,
22
+ };
23
+ export const IGNORED_DIRS = ["node_modules", ".git", ".svelte-kit", "dist", "build", ".turbo", ".vercel", ".next"];
@@ -0,0 +1,7 @@
1
+ export * from "./schemas.js";
2
+ export * from "./types.js";
3
+ export * from "./constants.js";
4
+ export * from "./scoring.js";
5
+ export * from "./project-info.js";
6
+ export * from "./run-inspect.js";
7
+ export * from "./rules/registry.js";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export * from "./schemas.js";
2
+ export * from "./types.js";
3
+ export * from "./constants.js";
4
+ export * from "./scoring.js";
5
+ export * from "./project-info.js";
6
+ export * from "./run-inspect.js";
7
+ export * from "./rules/registry.js";
@@ -0,0 +1,2 @@
1
+ import type { ProjectInfo } from "./types.js";
2
+ export declare const detectSvelteProject: (directory: string) => Promise<ProjectInfo>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Project Info — ported from react-doctor-source/packages/core/src/project-info
3
+ * Adapted for Svelte: detects Svelte version, SvelteKit, TypeScript, runes mode.
4
+ */
5
+ import { readFileSync, existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ export const detectSvelteProject = async (directory) => {
8
+ let svelteVersion = "unknown";
9
+ let isSvelteKit = false;
10
+ let hasTypeScript = false;
11
+ let framework = "unknown";
12
+ const pkgPath = join(directory, "package.json");
13
+ if (existsSync(pkgPath)) {
14
+ try {
15
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
16
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
17
+ if (deps.svelte) {
18
+ // strip ^~ >=
19
+ const raw = String(deps.svelte).replace(/^[^\d]*/, "");
20
+ svelteVersion = raw || "unknown";
21
+ const major = Number.parseInt(raw.split(".")[0] ?? "0", 10);
22
+ if (!Number.isNaN(major)) {
23
+ if (major >= 5)
24
+ framework = "svelte5";
25
+ else if (major >= 3)
26
+ framework = "svelte4";
27
+ }
28
+ }
29
+ if (deps["@sveltejs/kit"]) {
30
+ isSvelteKit = true;
31
+ framework = "sveltekit";
32
+ }
33
+ if (deps.typescript || deps["svelte-check"])
34
+ hasTypeScript = true;
35
+ }
36
+ catch { }
37
+ }
38
+ if (existsSync(join(directory, "svelte.config.js")) || existsSync(join(directory, "svelte.config.ts"))) {
39
+ if (framework === "unknown")
40
+ framework = "svelte5";
41
+ }
42
+ if (existsSync(join(directory, "tsconfig.json")))
43
+ hasTypeScript = true;
44
+ // runes detection: check svelte.config.js compilerOptions.runes
45
+ let runesMode = framework === "svelte5" || framework === "sveltekit";
46
+ try {
47
+ const configJs = existsSync(join(directory, "svelte.config.js"))
48
+ ? readFileSync(join(directory, "svelte.config.js"), "utf-8")
49
+ : existsSync(join(directory, "svelte.config.ts"))
50
+ ? readFileSync(join(directory, "svelte.config.ts"), "utf-8")
51
+ : "";
52
+ if (configJs.includes("runes: false") || configJs.includes("runes:false"))
53
+ runesMode = false;
54
+ }
55
+ catch { }
56
+ return { directory, svelteVersion, isSvelteKit, hasTypeScript, framework, runesMode };
57
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Rule Registry — ported from react-doctor-source/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts
3
+ * Generated via `pnpm gen` in React Doctor; here we hand-author Svelte 5 equivalents.
4
+ * 287 React rules → 52 Svelte Doctor rules (first wave). Categories + severity mirrored.
5
+ */
6
+ import type { RuleMeta } from "../types.js";
7
+ export declare const SVELTE_DOCTOR_RULES: RuleMeta[];
8
+ export declare const RULE_IDS: Set<string>;
9
+ export declare const RULES_BY_CATEGORY: Record<string, RuleMeta[]>;
10
+ export declare const RULE_MAP: Map<string, RuleMeta>;
@@ -0,0 +1,70 @@
1
+ export const SVELTE_DOCTOR_RULES = [
2
+ // ── Security (mirrors react-doctor/security + no-eval, no-secrets) ──
3
+ { id: "svelte-5-doctor/no-at-html-xss", category: "Security", severity: "error", description: "Disallows unsanitized {@html} — use DOMPurify or TrustedHTML", tags: ["security", "xss"], framework: "svelte5" },
4
+ { id: "svelte-5-doctor/no-eval", category: "Security", severity: "error", description: "Disallows eval() and new Function()", tags: ["security"], framework: "global" },
5
+ { id: "svelte-5-doctor/no-secrets-in-client-code", category: "Security", severity: "warn", description: "Detects hardcoded secrets in client components", tags: ["security"], framework: "global" },
6
+ { id: "svelte-5-doctor/dom-clobbering-risk", category: "Security", severity: "error", description: "Detects DOM clobbering via attribute spreading on form inputs (CVE-2026-42573)", tags: ["security"], framework: "svelte5" },
7
+ { id: "svelte-5-doctor/iframe-missing-sandbox", category: "Security", severity: "warn", description: "Requires sandbox on iframes", tags: ["security", "a11y"], framework: "global" },
8
+ // ── Correctness: Rune misuse (core Svelte 5 differentiator) ──
9
+ { id: "svelte-5-doctor/legacy-export-let", category: "Correctness", severity: "error", description: "export let is invalid in runes mode — use $props()", tags: ["correctness", "migration"], framework: "svelte5" },
10
+ { id: "svelte-5-doctor/legacy-dollars-colon", category: "Correctness", severity: "error", description: "$: reactive statement is invalid in runes mode — use $derived/$effect", tags: ["correctness", "migration"], framework: "svelte5" },
11
+ { id: "svelte-5-doctor/legacy-event-directive", category: "Correctness", severity: "warn", description: "on:click directive is deprecated in Svelte 5 — use onclick attribute", tags: ["correctness", "migration"], framework: "svelte5" },
12
+ { id: "svelte-5-doctor/legacy-slot", category: "Correctness", severity: "warn", description: "<slot> is deprecated — use {#snippet} + {@render}", tags: ["correctness", "migration"], framework: "svelte5" },
13
+ { id: "svelte-5-doctor/rune-invalid-placement", category: "Correctness", severity: "error", description: "$state/$derived/$effect outside valid placement", tags: ["correctness"], framework: "svelte5" },
14
+ { id: "svelte-5-doctor/state-invalid-export", category: "Correctness", severity: "error", description: "Exporting reassigned $state from .svelte.ts leaks SSR globals", tags: ["correctness"], framework: "svelte5" },
15
+ { id: "svelte-5-doctor/props-invalid-placement", category: "Correctness", severity: "error", description: "$props() must be top-level destructuring", tags: ["correctness"], framework: "svelte5" },
16
+ { id: "svelte-5-doctor/bindable-invalid-location", category: "Correctness", severity: "error", description: "$bindable() only inside $props() destructuring", tags: ["correctness"], framework: "svelte5" },
17
+ { id: "svelte-5-doctor/derived-invalid-export", category: "Correctness", severity: "error", description: "Exporting $derived from module is invalid", tags: ["correctness"], framework: "svelte5" },
18
+ { id: "svelte-5-doctor/store-rune-conflict", category: "Correctness", severity: "warn", description: "$ prefix ambiguity between store and rune", tags: ["correctness"], framework: "svelte5" },
19
+ { id: "svelte-5-doctor/non-reactive-update", category: "Correctness", severity: "warn", description: "let reassigned but not $state — won't trigger updates", tags: ["correctness", "performance"], framework: "svelte5" },
20
+ { id: "svelte-5-doctor/state-referenced-locally", category: "Correctness", severity: "warn", description: "setContext('key', state) loses reactivity — wrap in getter", tags: ["correctness"], framework: "svelte5" },
21
+ { id: "svelte-5-doctor/mixed-event-syntax", category: "Correctness", severity: "error", description: "Mixing on:click and onclick in same component", tags: ["correctness"], framework: "svelte5" },
22
+ { id: "svelte-5-doctor/slot-snippet-conflict", category: "Correctness", severity: "error", description: "Mixing <slot> and {@render} in same file", tags: ["correctness"], framework: "svelte5" },
23
+ { id: "svelte-5-doctor/each-item-mutation", category: "Correctness", severity: "error", description: "Direct mutation of {#each} item without index", tags: ["correctness"], framework: "svelte5" },
24
+ { id: "svelte-5-doctor/snippet-invalid-rest", category: "Correctness", severity: "error", description: "Snippet with rest parameters is invalid", tags: ["correctness"], framework: "svelte5" },
25
+ // ── Correctness: Effects & Derived (ported from you-might-not-need-an-effect) ──
26
+ { id: "svelte-5-doctor/no-effect-derived", category: "Correctness", severity: "error", description: "Deriving state inside $effect — use $derived instead", tags: ["correctness", "performance"], framework: "svelte5" },
27
+ { id: "svelte-5-doctor/no-effect-chain", category: "Correctness", severity: "warn", description: "Chained $effect syncing state — use $derived or event handler", tags: ["correctness"], framework: "svelte5" },
28
+ { id: "svelte-5-doctor/no-deriving-props-in-effect", category: "Correctness", severity: "warn", description: "Deriving props inside $effect — use $derived", tags: ["correctness"], framework: "svelte5" },
29
+ { id: "svelte-5-doctor/no-reset-on-prop", category: "Correctness", severity: "warn", description: "Resetting multiple $state on prop change — use {#key}", tags: ["correctness"], framework: "svelte5" },
30
+ { id: "svelte-5-doctor/effect-needs-cleanup", category: "Correctness", severity: "error", description: "setInterval/addEventListener in $effect without cleanup", tags: ["correctness"], framework: "svelte5" },
31
+ { id: "svelte-5-doctor/no-mutate-in-derived", category: "Correctness", severity: "error", description: "Mutating state inside $derived (forbidden)", tags: ["correctness"], framework: "svelte5" },
32
+ { id: "svelte-5-doctor/no-init-state-in-effect", category: "Correctness", severity: "warn", description: "Initializing $state inside $effect — init at declaration", tags: ["correctness"], framework: "svelte5" },
33
+ // ── Performance (ported from react-doctor/performance + js-*) ──
34
+ { id: "svelte-5-doctor/no-derived-simple", category: "Performance", severity: "warn", description: "Useless $derived wrapping trivial expression", tags: ["performance"], framework: "svelte5" },
35
+ { id: "svelte-5-doctor/no-index-as-key", category: "Performance", severity: "warn", description: "{#each} without key or using index as key", tags: ["performance", "correctness"], framework: "svelte5" },
36
+ { id: "svelte-5-doctor/perf-avoid-deep-proxy", category: "Performance", severity: "warn", description: "Large object with $state — consider $state.raw", tags: ["performance"], framework: "svelte5" },
37
+ { id: "svelte-5-doctor/perf-avoid-inline-class", category: "Performance", severity: "warn", description: "new class inside component/effect — hoist", tags: ["performance"], framework: "svelte5" },
38
+ { id: "svelte-5-doctor/perf-avoid-nested-class", category: "Performance", severity: "warn", description: "Nested class declarations degrade perf", tags: ["performance"], framework: "svelte5" },
39
+ { id: "svelte-5-doctor/no-layout-animation", category: "Performance", severity: "error", description: "Animating layout properties (width/height/top) causes thrash", tags: ["performance"], framework: "global" },
40
+ { id: "svelte-5-doctor/no-transition-all", category: "Performance", severity: "warn", description: "transition:all is expensive — specify property", tags: ["performance"], framework: "global" },
41
+ { id: "svelte-5-doctor/no-large-animated-blur", category: "Performance", severity: "warn", description: "Large blur radius animation is expensive", tags: ["performance"], framework: "global" },
42
+ { id: "svelte-5-doctor/js-combine-iterations", category: "Performance", severity: "warn", description: "Multiple iterations over same array — combine", tags: ["performance"], framework: "global" },
43
+ { id: "svelte-5-doctor/js-hoist-regexp", category: "Performance", severity: "warn", description: "RegExp literal inside loop — hoist", tags: ["performance"], framework: "global" },
44
+ { id: "svelte-5-doctor/js-hoist-intl", category: "Performance", severity: "warn", description: "Intl.* constructor inside loop — hoist", tags: ["performance"], framework: "global" },
45
+ { id: "svelte-5-doctor/no-barrel-import", category: "Performance", severity: "warn", description: "Barrel import hurts tree-shaking", tags: ["performance", "bundle-size"], framework: "global" },
46
+ // ── Maintainability / Architecture (ported from deslop-js + no-giant-component) ──
47
+ { id: "svelte-5-doctor/no-giant-component", category: "Maintainability", severity: "warn", description: "Component >400 lines — split via snippets/composition", tags: ["maintainability", "architecture"], framework: "svelte5" },
48
+ { id: "svelte-5-doctor/no-nested-snippet", category: "Maintainability", severity: "warn", description: "Snippet defined inside markup recreates each render", tags: ["maintainability"], framework: "svelte5" },
49
+ { id: "svelte-5-doctor/no-inline-snippet", category: "Maintainability", severity: "warn", description: "Inline snippet creation — extract to top-level", tags: ["maintainability"], framework: "svelte5" },
50
+ { id: "svelte-5-doctor/no-circular-import", category: "Maintainability", severity: "error", description: "Circular import detected", tags: ["maintainability", "architecture"], framework: "global" },
51
+ { id: "svelte-5-doctor/css-unused-selector", category: "Maintainability", severity: "warn", description: "Unused CSS selector in <style>", tags: ["maintainability"], framework: "svelte5" },
52
+ // ── Accessibility (bridges svelte compiler a11y warnings) ──
53
+ { id: "svelte-5-doctor/a11y-missing-attribute", category: "Accessibility", severity: "warn", description: "a11y: missing required attribute (img alt, a href)", tags: ["a11y"], framework: "svelte5" },
54
+ { id: "svelte-5-doctor/a11y-click-events-have-key-events", category: "Accessibility", severity: "warn", description: "click handler without key handler", tags: ["a11y"], framework: "global" },
55
+ { id: "svelte-5-doctor/a11y-no-static-element-interactions", category: "Accessibility", severity: "warn", description: "Interactive handler on static element without role", tags: ["a11y"], framework: "global" },
56
+ ];
57
+ export const RULE_IDS = new Set(SVELTE_DOCTOR_RULES.map((r) => r.id));
58
+ export const RULES_BY_CATEGORY = SVELTE_DOCTOR_RULES.reduce((acc, rule) => {
59
+ (acc[rule.category] ??= []).push(rule);
60
+ return acc;
61
+ }, {});
62
+ export const RULE_MAP = new Map(SVELTE_DOCTOR_RULES.map((r) => [r.id, r]));
63
+ // Backward compat: svelte-doctor/* alias for svelte-5-doctor/*
64
+ for (const rule of SVELTE_DOCTOR_RULES) {
65
+ if (rule.id.startsWith("svelte-5-doctor/")) {
66
+ const alias = rule.id.replace("svelte-5-doctor/", "svelte-doctor/");
67
+ if (!RULE_MAP.has(alias))
68
+ RULE_MAP.set(alias, rule);
69
+ }
70
+ }
@@ -0,0 +1,3 @@
1
+ import type { JsonReport } from "./schemas.js";
2
+ import type { InspectInput } from "./types.js";
3
+ export declare const runInspect: (input: InspectInput) => Promise<JsonReport>;
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Run Inspect — heart of Svelte Doctor diagnostic pipeline
3
+ * Ported from react-doctor-source/packages/core/src/run-inspect.ts
4
+ * Architecture mirrored: streaming orchestrator, file discovery → parse → rule visitors → score.
5
+ * Svelte difference: uses svelte/compiler parse+compile instead of oxlint.
6
+ */
7
+ import { readFileSync, existsSync, statSync } from "node:fs";
8
+ import { join, relative, extname } from "node:path";
9
+ import { glob } from "tinyglobby";
10
+ import { compile, parse } from "svelte/compiler";
11
+ import { detectSvelteProject } from "./project-info.js";
12
+ import { SVELTE_DOCTOR_RULES, RULE_MAP } from "./rules/registry.js";
13
+ import { calculateScore, getScoreLabel, summarizeDiagnostics } from "./scoring.js";
14
+ import { IGNORED_DIRS, GIANT_COMPONENT_THRESHOLD_LINES } from "./constants.js";
15
+ const SVELTE_RE = /\.(svelte|svelte\.js|svelte\.ts)$/;
16
+ const CODE_EXT_RE = /\.(svelte|svelte\.js|svelte\.ts|js|ts|jsx|tsx)$/;
17
+ const collectFiles = async (directory) => {
18
+ const patterns = ["**/*.{svelte,svelte.js,svelte.ts,js,ts,jsx,tsx}"];
19
+ const ignore = IGNORED_DIRS.map((d) => `**/${d}/**`);
20
+ const files = await glob(patterns, {
21
+ cwd: directory,
22
+ ignore: [...ignore, "**/*.d.ts", "**/*.test.*", "**/*.spec.*"],
23
+ absolute: false,
24
+ dot: false,
25
+ });
26
+ return files.filter((f) => CODE_EXT_RE.test(f));
27
+ };
28
+ const lineColFromIndex = (source, idx) => {
29
+ const before = source.slice(0, idx);
30
+ const lines = before.split("\n");
31
+ return { line: lines.length, column: (lines.at(-1)?.length ?? 0) + 1 };
32
+ };
33
+ // ── Rule Visitors (lightweight text+AST heuristics) ──
34
+ // Each mirrors a React Doctor visitor but adapted to Svelte 5 syntax.
35
+ // For full fidelity they'd use svelte AST walk (estree-walker) + compiler warnings.
36
+ const runRulesOnFile = (filePath, source) => {
37
+ const diags = [];
38
+ const isSvelte = SVELTE_RE.test(filePath) || filePath.endsWith(".svelte");
39
+ const isRunesFile = /\$state|\$derived|\$effect|\$props|\$bindable|\$inspect/.test(source);
40
+ const lines = source.split("\n");
41
+ const report = (ruleId, message, idx, fix) => {
42
+ const meta = RULE_MAP.get(ruleId);
43
+ if (!meta)
44
+ return;
45
+ const { line, column } = lineColFromIndex(source, idx);
46
+ diags.push({ ruleId, severity: meta.severity, category: meta.category, message, filePath, line, column, fix, tags: meta.tags });
47
+ };
48
+ // ── Security ──
49
+ // {@html} without sanitization — XSS (React's dangerouslySetInnerHTML equivalent)
50
+ // Avoid false positive from comment containing "sanitize": check for actual sanitizer usage in code
51
+ for (const m of source.matchAll(/\{@html\s+([^}]+)\}/g)) {
52
+ const expr = m[1]?.trim() ?? "";
53
+ const isSanitized = /DOMPurify\s*\.\s*sanitize|TrustedHTML|createHTML|Sanitizer\s*\./.test(source);
54
+ if (!isSanitized) {
55
+ report("svelte-5-doctor/no-at-html-xss", `{@html ${expr}} renders raw HTML without sanitization — XSS risk. Sanitize with DOMPurify.sanitize() or TrustedHTML.`, m.index ?? 0, "Sanitize before {@html}: DOMPurify.sanitize(expr)");
56
+ }
57
+ }
58
+ // eval
59
+ for (const m of source.matchAll(/\beval\s*\(/g))
60
+ report("svelte-5-doctor/no-eval", "eval() is dangerous — avoid dynamic code execution.", m.index ?? 0);
61
+ for (const m of source.matchAll(/\bnew\s+Function\s*\(/g))
62
+ report("svelte-5-doctor/no-eval", "new Function() is eval-like — avoid.", m.index ?? 0);
63
+ // secrets
64
+ for (const m of source.matchAll(/\b(api[_-]?key|secret|password|token)\s*[:=]\s*["'][A-Za-z0-9_\-]{16,}["']/gi))
65
+ report("svelte-5-doctor/no-secrets-in-client-code", `Possible hardcoded secret: ${m[0].slice(0, 40)}...`, m.index ?? 0);
66
+ // iframe sandbox
67
+ for (const m of source.matchAll(/<iframe\b(?![^>]*\bsandbox\b)[^>]*>/gi))
68
+ report("svelte-5-doctor/iframe-missing-sandbox", "<iframe> missing sandbox attribute.", m.index ?? 0);
69
+ // DOM clobbering: attribute spreading on input inside form
70
+ if (/<form\b[^>]*>[\s\S]*?\{...[^}]+\}[\s\S]*?<input\b/i.test(source)) {
71
+ const idx = source.search(/\{.../);
72
+ if (idx !== -1)
73
+ report("svelte-5-doctor/dom-clobbering-risk", "Attribute spreading inside <form> can enable DOM clobbering (CVE-2026-42573). Avoid spreading user-controlled 'name' onto inputs.", idx);
74
+ }
75
+ // ── Correctness: legacy syntax ──
76
+ if (isRunesFile || isSvelte) {
77
+ for (const m of source.matchAll(/\bexport\s+let\s+\w+/g)) {
78
+ if (isRunesFile || source.includes("$props") || source.includes("$state")) {
79
+ report("svelte-5-doctor/legacy-export-let", "`export let` is invalid in runes mode — use `let { prop } = $props()`", m.index ?? 0, "let { prop } = $props()");
80
+ }
81
+ }
82
+ for (const m of source.matchAll(/\$\s*:\s*\w+/g)) {
83
+ if (isRunesFile)
84
+ report("svelte-5-doctor/legacy-dollars-colon", "`$:` reactive statement is invalid in runes mode — use $derived / $effect", m.index ?? 0);
85
+ }
86
+ for (const m of source.matchAll(/on:\w+\s*=/g))
87
+ report("svelte-5-doctor/legacy-event-directive", "`on:click` is deprecated in Svelte 5 — use `onclick`", m.index ?? 0, "onclick={handler}");
88
+ for (const m of source.matchAll(/<slot\b/g))
89
+ report("svelte-5-doctor/legacy-slot", "<slot> is deprecated — use {#snippet} + {@render}", m.index ?? 0);
90
+ // mixed syntax
91
+ if (/on:\w+/.test(source) && /\bonclick\b/.test(source))
92
+ report("svelte-5-doctor/mixed-event-syntax", "Mixing `on:click` and `onclick` — use Svelte 5 `onclick` only.", source.indexOf("on:"));
93
+ if (/<slot/.test(source) && /\{@render/.test(source))
94
+ report("svelte-5-doctor/slot-snippet-conflict", "Mixing <slot> and {@render} in same file.", source.indexOf("<slot"));
95
+ }
96
+ // ── Correctness: rune placement ──
97
+ for (const m of source.matchAll(/\$state\s*\(/g)) {
98
+ const before = source.slice(0, m.index ?? 0);
99
+ const insideFunction = /function\s+\w*\s*\([^)]*\)\s*\{[^}]*$/.test(before.slice(-500));
100
+ // Top-level $state inside non-.svelte.js module export reassignment check
101
+ if (filePath.endsWith(".svelte.js") || filePath.endsWith(".svelte.ts")) {
102
+ if (/export\s+let\s+\w+\s*=\s*\$state/.test(source) && /\w+\s*\+=|\w+\s*=/.test(source.slice((m.index ?? 0) + 10, (m.index ?? 0) + 200))) {
103
+ report("svelte-5-doctor/state-invalid-export", "Exporting reassigned $state from .svelte.js leaks across SSR requests — export const instance or getter.", m.index ?? 0);
104
+ }
105
+ }
106
+ }
107
+ for (const m of source.matchAll(/\$props\s*\(/g)) {
108
+ const beforeLines = source.slice(0, m.index ?? 0).split("\n");
109
+ const lastFew = beforeLines.slice(-3).join("\n");
110
+ if (!/let\s*\{[^}]*\}\s*=\s*\$props\(\)/.test(source.slice((m.index ?? 0) - 100, (m.index ?? 0) + 50))) {
111
+ // Check not top-level destructuring
112
+ if (/const\s+\w+\s*=\s*\$props/.test(source.slice((m.index ?? 0) - 50, (m.index ?? 0) + 50)) && !/let\s*\{/.test(source.slice((m.index ?? 0) - 50, (m.index ?? 0) + 50))) {
113
+ report("svelte-5-doctor/props-invalid-placement", "$props() must be `let { ... } = $props()` at top-level.", m.index ?? 0);
114
+ }
115
+ }
116
+ }
117
+ for (const m of source.matchAll(/\$bindable\s*\(/g)) {
118
+ const hasPropsInFile = /\$props\(\)/.test(source);
119
+ const nearbyHasProps = /\$props\(\)/.test(source.slice(Math.max(0, (m.index ?? 0) - 500), (m.index ?? 0) + 500));
120
+ if (!hasPropsInFile || !nearbyHasProps) {
121
+ report("svelte-5-doctor/bindable-invalid-location", "$bindable() only inside $props() destructuring.", m.index ?? 0);
122
+ }
123
+ }
124
+ for (const m of source.matchAll(/\$derived\s*\(/g)) {
125
+ if (filePath.endsWith(".svelte.js") && /export\s+/.test(source.slice(Math.max(0, (m.index ?? 0) - 50), (m.index ?? 0)))) {
126
+ report("svelte-5-doctor/derived-invalid-export", "Exporting $derived from module is invalid.", m.index ?? 0);
127
+ }
128
+ }
129
+ // store rune conflict: $count vs rune
130
+ for (const m of source.matchAll(/\$\w+\s*[,;\)\]]/g)) {
131
+ const name = m[0].replace(/[^$\w]/g, "");
132
+ if (/^\$(state|derived|effect|props|bindable|inspect)$/.test(name))
133
+ continue;
134
+ if (source.includes(`$${name.slice(1)}`) && /\bstores?\b|\bwritable\b/.test(source)) {
135
+ // naive
136
+ }
137
+ }
138
+ // non_reactive_update: let mutated but not $state
139
+ const letDecls = [...source.matchAll(/\blet\s+(\w+)\s*=\s*[^;]+/g)];
140
+ for (const decl of letDecls) {
141
+ const varName = decl[1];
142
+ if (!varName || ["count", "value", "data", "props"].includes(varName) && false)
143
+ continue;
144
+ const after = source.slice((decl.index ?? 0) + decl[0].length);
145
+ if (new RegExp(`\\b${varName}\\s*\\+=|\\b${varName}\\s*=\\s*[^=]`).test(after) && !source.includes(`$state`) && !source.includes(`$props`)) {
146
+ // Only report if used in template
147
+ if (isSvelte && source.includes(`{${varName}}`)) {
148
+ report("svelte-5-doctor/non-reactive-update", `let ${varName} reassigned but not $state — template won't update.`, decl.index ?? 0, `let ${varName} = $state(...)`);
149
+ }
150
+ }
151
+ }
152
+ // state_referenced_locally
153
+ for (const m of source.matchAll(/setContext\s*\(\s*["'][^"']+["']\s*,\s*(\w+)\s*\)/g)) {
154
+ const arg = m[1];
155
+ if (arg && source.includes(`$state`))
156
+ report("svelte-5-doctor/state-referenced-locally", `setContext('key', ${arg}) snapshots value — use () => ${arg} getter or createContext.`, m.index ?? 0);
157
+ }
158
+ // snippet rest
159
+ for (const m of source.matchAll(/\{#snippet\s+\w+\s*\([^)]*\.\.\./g))
160
+ report("svelte-5-doctor/snippet-invalid-rest", "Snippet with rest params is invalid.", m.index ?? 0);
161
+ // ── Correctness: effects & derived ──
162
+ for (const m of source.matchAll(/\$effect\s*\(\s*\(\)\s*=>\s*\{[^}]*\b\w+\s*=\s*[^}]*\}/g)) {
163
+ const body = m[0];
164
+ if (/\w+\s*=\s*\w+\s*\*\s*\w+|\w+\s*=\s*\w+\s*\+\s*\w+/.test(body) && !/fetch|setTimeout|addEventListener/.test(body)) {
165
+ report("svelte-5-doctor/no-effect-derived", "Deriving state inside $effect — use $derived instead.", m.index ?? 0, "let x = $derived(y * 2)");
166
+ }
167
+ }
168
+ // effect cleanup
169
+ for (const m of source.matchAll(/\$effect\s*\(\s*\(\)\s*=>\s*\{[^}]*\b(setInterval|setTimeout|addEventListener)\s*\(/g)) {
170
+ const snippet = source.slice(m.index ?? 0, (m.index ?? 0) + 500);
171
+ if (!/return\s*\(\)\s*=>/.test(snippet) && !/return\s*function/.test(snippet)) {
172
+ report("svelte-5-doctor/effect-needs-cleanup", `${m[1]} inside $effect without cleanup — return () => clear...`, m.index ?? 0);
173
+ }
174
+ }
175
+ for (const m of source.matchAll(/\$derived\s*\([^)]*\)\s*\{[^}]*\w+\s*\+=|\$derived\([^)]*=>[^)]*\{[^}]*\w+\+\+/g))
176
+ report("svelte-5-doctor/no-mutate-in-derived", "Mutating state inside $derived is forbidden.", m.index ?? 0);
177
+ // derived simple
178
+ for (const m of source.matchAll(/\$derived\s*\(\s*\w+\s*\)/g))
179
+ report("svelte-5-doctor/no-derived-simple", "Useless $derived wrapping single variable — use directly.", m.index ?? 0);
180
+ // ── Performance ──
181
+ // unkeyed each
182
+ for (const m of source.matchAll(/\{#each\s+[^\}]+ as [^\}]+}/g)) {
183
+ const block = m[0];
184
+ if (!/\(.+\)/.test(block))
185
+ report("svelte-5-doctor/no-index-as-key", "{#each} without key — use `{#each items as item (item.id)}`", m.index ?? 0);
186
+ else if (/\(\s*\w+\s*\)/.test(block) && /,\s*i\s*\)/.test(block))
187
+ report("svelte-5-doctor/no-index-as-key", "{#each} using index as key is unstable.", m.index ?? 0);
188
+ }
189
+ // each item mutation
190
+ for (const m of source.matchAll(/\{#each\s+(\w+)\s+as\s+(\w+)[^}]*\}[\s\S]*?bind:value=\{(\w+)\}/g)) {
191
+ const item = m[2];
192
+ if (item === m[3])
193
+ report("svelte-5-doctor/each-item-mutation", `bind:value={${item}} mutates each item directly — use array[index].`, m.index ?? 0);
194
+ }
195
+ // large $state object
196
+ for (const m of source.matchAll(/\$state\s*\(\s*\{[^}]{200,}\}/g))
197
+ report("svelte-5-doctor/perf-avoid-deep-proxy", "Large object with $state proxies deeply — consider $state.raw + reassignment.", m.index ?? 0);
198
+ // inline class
199
+ for (const m of source.matchAll(/\$effect\s*\([^)]*\)\s*=>\s*\{[^}]*new\s+class\b/g))
200
+ report("svelte-5-doctor/perf-avoid-inline-class", "new class inside $effect — hoist to module scope.", m.index ?? 0);
201
+ // layout animation
202
+ for (const m of source.matchAll(/transition:\w+[^}]*width|animate:[^;]*width|style:[^;]*width/g))
203
+ if (/width|height|top|left/.test(m[0]))
204
+ report("svelte-5-doctor/no-layout-animation", "Animating layout properties causes thrash — use transform/opacity.", m.index ?? 0);
205
+ for (const m of source.matchAll(/transition:\s*all\b/g))
206
+ report("svelte-5-doctor/no-transition-all", "transition:all is expensive — specify property.", m.index ?? 0);
207
+ for (const m of source.matchAll(/filter:\s*blur\(\s*(\d+)px\)/g)) {
208
+ const r = Number.parseInt(m[1] ?? "0", 10);
209
+ if (r > 20)
210
+ report("svelte-5-doctor/no-large-animated-blur", `Large blur(${r}px) animation is expensive — reduce radius.`, m.index ?? 0);
211
+ }
212
+ // js perf
213
+ for (const m of source.matchAll(/\.filter\s*\([^)]+\)\s*\.map\s*\(/g))
214
+ report("svelte-5-doctor/js-combine-iterations", "filter().map() does 2 passes — use single loop or flatMap.", m.index ?? 0);
215
+ for (const m of source.matchAll(/for\s*\([^)]+\)\s*\{[^}]*new\s+RegExp\s*\(/g))
216
+ report("svelte-5-doctor/js-hoist-regexp", "RegExp inside loop — hoist.", m.index ?? 0);
217
+ for (const m of source.matchAll(/for\s*\([^)]+\)\s*\{[^}]*new\s+Intl\./g))
218
+ report("svelte-5-doctor/js-hoist-intl", "Intl.* inside loop — hoist.", m.index ?? 0);
219
+ for (const m of source.matchAll(/from\s+["'][^"']*\/index["']|import\s+\*\s+as\s+\w+\s+from\s+["']lodash["']/g))
220
+ report("svelte-5-doctor/no-barrel-import", "Barrel/lodash full import hurts tree-shaking — import specific path.", m.index ?? 0);
221
+ // ── Maintainability ──
222
+ if (lines.length > GIANT_COMPONENT_THRESHOLD_LINES)
223
+ report("svelte-5-doctor/no-giant-component", `Component is ${lines.length} lines (threshold ${GIANT_COMPONENT_THRESHOLD_LINES}) — split via snippets/composition.`, 0);
224
+ // nested snippet: {#snippet} inside {#if} or {#each}
225
+ for (const m of source.matchAll(/\{#if[\s\S]*?\{#snippet|\{#each[\s\S]*?\{#snippet/g))
226
+ report("svelte-5-doctor/no-nested-snippet", "Snippet defined inside markup recreates each render — hoist to top-level.", m.index ?? 0);
227
+ // ── a11y via compiler bridge: also rely on svelte compile warnings ──
228
+ // simple heuristics:
229
+ for (const m of source.matchAll(/<img\b(?![^>]*\balt=)[^>]*>/gi))
230
+ report("svelte-5-doctor/a11y-missing-attribute", "<img> missing alt attribute.", m.index ?? 0);
231
+ for (const m of source.matchAll(/<a\b(?![^>]*\bhref=)[^>]*>/gi))
232
+ report("svelte-5-doctor/a11y-missing-attribute", "<a> missing href.", m.index ?? 0);
233
+ for (const m of source.matchAll(/onclick\s*=\s*\{[^}]+\}(?![^<]*onkeydown)/gi)) {
234
+ // if clickable div without keyboard
235
+ const tagMatch = source.slice(Math.max(0, (m.index ?? 0) - 100), m.index ?? 0).match(/<(\w+)\b[^>]*$/);
236
+ const tag = tagMatch?.[1] ?? "";
237
+ if (tag === "div" || tag === "span")
238
+ report("svelte-5-doctor/a11y-click-events-have-key-events", `<${tag}> with onclick missing keyboard handler.`, m.index ?? 0);
239
+ }
240
+ return diags;
241
+ };
242
+ export const runInspect = async (input) => {
243
+ const started = Date.now();
244
+ const directory = input.directory;
245
+ if (!existsSync(directory) || !statSync(directory).isDirectory()) {
246
+ throw new Error(`Directory not found: ${directory}`);
247
+ }
248
+ const projectInfo = await detectSvelteProject(directory);
249
+ const files = await collectFiles(directory);
250
+ let allDiagnostics = [];
251
+ const skipped = [];
252
+ for (const rel of files) {
253
+ const abs = join(directory, rel);
254
+ let source;
255
+ try {
256
+ source = readFileSync(abs, "utf-8");
257
+ }
258
+ catch {
259
+ skipped.push({ ruleId: "*", reason: `read failed: ${rel}` });
260
+ continue;
261
+ }
262
+ // Bridge svelte compiler warnings (a11y, css_unused_selector, etc.)
263
+ if (rel.endsWith(".svelte")) {
264
+ try {
265
+ const result = compile(source, { filename: rel, generate: "client" });
266
+ for (const w of result.warnings ?? []) {
267
+ const code = w.code ?? "svelte-warning";
268
+ const isA11y = code.startsWith("a11y");
269
+ const isCss = code === "css_unused_selector";
270
+ // Map to our ruleIds when possible
271
+ let ruleId = `svelte/compiler:${code}`;
272
+ let category = "Correctness";
273
+ if (isA11y) {
274
+ ruleId = `svelte-5-doctor/${code.replaceAll("_", "-")}`;
275
+ category = "Accessibility";
276
+ }
277
+ else if (isCss) {
278
+ ruleId = "svelte-5-doctor/css-unused-selector";
279
+ category = "Maintainability";
280
+ }
281
+ else if (code.includes("state") || code.includes("rune"))
282
+ category = "Correctness";
283
+ else if (code.includes("perf"))
284
+ category = "Performance";
285
+ // Already covered by our heuristics? still surface compiler message as additional
286
+ const line = w.start?.line ?? 1;
287
+ const col = w.start?.column ?? 1;
288
+ // Deduplicate if we already reported same line with same code
289
+ const msg = w.message ?? String(w);
290
+ allDiagnostics.push({
291
+ ruleId,
292
+ severity: "warn",
293
+ category,
294
+ message: msg,
295
+ filePath: rel,
296
+ line,
297
+ column: col,
298
+ tags: [code],
299
+ });
300
+ }
301
+ }
302
+ catch (err) {
303
+ const msg = err instanceof Error ? err.message : String(err);
304
+ // Compiler errors become diagnostics
305
+ const lineMatch = msg.match(/:(\d+):(\d+)/);
306
+ allDiagnostics.push({
307
+ ruleId: "svelte-5-doctor/compile-error",
308
+ severity: "error",
309
+ category: "Correctness",
310
+ message: msg.split("\n")[0] ?? msg,
311
+ filePath: rel,
312
+ line: lineMatch ? Number.parseInt(lineMatch[1] ?? "1", 10) : 1,
313
+ column: lineMatch ? Number.parseInt(lineMatch[2] ?? "1", 10) : 1,
314
+ });
315
+ // still run heuristic rules even after compile error — mirrors react-doctor's partial failure handling
316
+ }
317
+ }
318
+ // Heuristic rules (run even on compile error to surface all issues)
319
+ const heuristic = runRulesOnFile(rel, source);
320
+ allDiagnostics.push(...heuristic);
321
+ }
322
+ // Also parse .svelte.js for run via parse (module)
323
+ // svelte/compiler parse for .svelte only; .svelte.js compileModule not needed for heuristics
324
+ // Filter by category if requested
325
+ if (input.categories?.length) {
326
+ const set = new Set(input.categories.map((c) => c.toLowerCase()));
327
+ allDiagnostics = allDiagnostics.filter((d) => set.has(d.category.toLowerCase()));
328
+ }
329
+ // Scope filtering: changed/files — MVP: if scope=changed, no-op (needs git)
330
+ // Keep for API parity with react-doctor.
331
+ const score = calculateScore(allDiagnostics);
332
+ const label = getScoreLabel(score);
333
+ const summary = summarizeDiagnostics(allDiagnostics);
334
+ const jsonReport = {
335
+ schemaVersion: 3,
336
+ score,
337
+ label,
338
+ diagnostics: allDiagnostics,
339
+ skippedCheckReasons: skipped.length ? skipped : undefined,
340
+ summary,
341
+ meta: {
342
+ svelteVersion: projectInfo.svelteVersion,
343
+ scannedAt: new Date().toISOString(),
344
+ directory: relative(process.cwd(), directory) || directory,
345
+ durationMs: Date.now() - started,
346
+ },
347
+ };
348
+ return jsonReport;
349
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Schemas — ported from @react-doctor/core/schemas.ts
3
+ * Design source: react-doctor-source/packages/core/src/schemas.ts
4
+ * Adapted for Svelte 5: categories aligned to Svelte compiler diagnostics.
5
+ */
6
+ export type Severity = "error" | "warn" | "off";
7
+ export type Category = "Security" | "Performance" | "Correctness" | "Accessibility" | "Maintainability" | "Architecture";
8
+ export interface Diagnostic {
9
+ ruleId: string;
10
+ severity: Severity;
11
+ category: Category;
12
+ message: string;
13
+ filePath: string;
14
+ line: number;
15
+ column: number;
16
+ fix?: string;
17
+ tags?: string[];
18
+ }
19
+ export interface SkippedCheckReason {
20
+ ruleId: string;
21
+ reason: string;
22
+ }
23
+ export interface JsonReport {
24
+ schemaVersion: 3;
25
+ score: number;
26
+ label: "Great" | "Needs work" | "Critical";
27
+ diagnostics: Diagnostic[];
28
+ skippedCheckReasons?: SkippedCheckReason[];
29
+ summary: {
30
+ total: number;
31
+ errors: number;
32
+ warnings: number;
33
+ byCategory: Record<Category, number>;
34
+ affectedFiles: number;
35
+ distinctRules: number;
36
+ };
37
+ meta: {
38
+ svelteVersion: string;
39
+ scannedAt: string;
40
+ directory: string;
41
+ durationMs: number;
42
+ };
43
+ }
44
+ export declare const buildDiagnosticIdentity: (d: Diagnostic) => string;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Schemas — ported from @react-doctor/core/schemas.ts
3
+ * Design source: react-doctor-source/packages/core/src/schemas.ts
4
+ * Adapted for Svelte 5: categories aligned to Svelte compiler diagnostics.
5
+ */
6
+ export const buildDiagnosticIdentity = (d) => `${d.ruleId}:${d.filePath}:${d.line}:${d.column}:${d.message}`;