diff --git a/.oxlintrc.syntax.json b/.oxlintrc.syntax.json index 6afbef6f0..99476c882 100644 --- a/.oxlintrc.syntax.json +++ b/.oxlintrc.syntax.json @@ -227,7 +227,7 @@ "**/*.ts" ], "rules": { - "quickadd/svelte-internal": "error" + "quickadd/no-svelte-internal": "error" } }, { @@ -235,15 +235,16 @@ "**/*.svelte.ts" ], "rules": { - "quickadd/svelte-runes": "error", - "quickadd/svelte-debug": "warn" + "quickadd/no-svelte-store": "error", + "quickadd/runes-only": "error", + "quickadd/no-inspect": "warn" } } ], "jsPlugins": [ { "name": "quickadd", - "specifier": "./tooling/oxlint-plugin.mjs" + "specifier": "svelte-policy/oxlint" } ] } diff --git a/package.json b/package.json index 00ccba0f3..e672324d5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "test:e2e": "vitest run --config vitest.e2e.config.mts", "test:e2e:orb": ".agents/run-e2e", "lint:native": "oxlint -c .oxlintrc.syntax.json src tests && oxlint --type-aware --ignore-pattern '**/*.test.ts' src", - "lint:svelte": "node tooling/svelte-policy.mjs", + "lint:svelte": "svelte-policy", "lint:tsconfig": "tsc --noEmit --noCheck" }, "keywords": [], @@ -34,7 +34,6 @@ "@vitest/coverage-v8": "5.0.3", "esbuild": "^0.28.2", "esbuild-svelte": "^0.9.5", - "esquery": "^1.7.0", "jsdom": "^30.1.2", "moment": "2.31.0", "obsidian": "1.14.4", @@ -43,6 +42,7 @@ "oxlint-tsgolint": "7.0.2004", "svelte": "^5.57.2", "svelte-check": "4.7.6", + "svelte-policy": "^0.1.0", "tslib": "^2.8.1", "typescript": "npm:@typescript/typescript6@6.0.2", "vite": "^8.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cec84ef4..1da4226e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,9 +57,6 @@ importers: esbuild-svelte: specifier: ^0.9.5 version: 0.9.5(esbuild@0.28.2)(svelte@5.57.2(@typescript-eslint/types@8.71.1)) - esquery: - specifier: ^1.7.0 - version: 1.7.0 jsdom: specifier: ^30.1.2 version: 30.1.2 @@ -84,6 +81,9 @@ importers: svelte-check: specifier: 4.7.6 version: 4.7.6(patch_hash=3eddedcd07511c7eb7e4a26f4caeb62396acf984b2300019329f14f3d2c0cf74)(@typescript/typescript6@6.0.2)(picomatch@4.0.7)(svelte@5.57.2(@typescript-eslint/types@8.71.1)) + svelte-policy: + specifier: ^0.1.0 + version: 0.1.0(svelte@5.57.2(@typescript-eslint/types@8.71.1)) tslib: specifier: ^2.8.1 version: 2.8.1 @@ -1757,6 +1757,13 @@ packages: peerDependencies: svelte: '>=3.23.0 || ^5.0.0-next.0' + svelte-policy@0.1.0: + resolution: {integrity: sha512-3BP+Gm91/eQCElMzm0vNBzhaKVQHWWvlY1r/tWDcqrAkl79i3pi1P+zpucSfVrKffKFKUzec2QdzWXwt0okD6g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.57.0 + svelte@5.57.2: resolution: {integrity: sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==} engines: {node: '>=18'} @@ -3167,6 +3174,12 @@ snapshots: dependencies: svelte: 5.57.2(@typescript-eslint/types@8.71.1) + svelte-policy@0.1.0(svelte@5.57.2(@typescript-eslint/types@8.71.1)): + dependencies: + esquery: 1.7.0 + svelte: 5.57.2(@typescript-eslint/types@8.71.1) + zimmerframe: 1.1.5 + svelte@5.57.2(@typescript-eslint/types@8.71.1): dependencies: '@jridgewell/remapping': 2.3.5 diff --git a/svelte-policy.json b/svelte-policy.json new file mode 100644 index 000000000..f341ab9a2 --- /dev/null +++ b/svelte-policy.json @@ -0,0 +1,52 @@ +{ + "rules": { + "no-svelte-store": "error", + "runes-only": "error", + "no-console": { + "severity": "error", + "message": "Use the logger utilities instead of console methods." + } + }, + "restrictedSyntax": [ + { + "selector": "CallExpression[callee.type=\"MemberExpression\"][callee.property.name=\"createElement\"]", + "message": "Use Obsidian DOM helpers; use the owner document window for detached elements." + }, + { + "selector": "CallExpression[callee.property.name=\"createDocumentFragment\"]", + "message": "Use createFragment(), or the owner document window for a specific realm." + }, + { + "selector": "CallExpression:matches([callee.name=\"createEl\"], [callee.property.name=\"createEl\"])[arguments.0.value=/^(div|span)$/]", + "message": "Use createDiv() or createSpan() for these tags." + }, + { + "selector": "BinaryExpression[operator=\"instanceof\"][right.name=\"HTMLElement\"]", + "message": "Use node.instanceOf(HTMLElement) for cross-window checks." + }, + { + "selector": "CallExpression[callee.type=\"MemberExpression\"][callee.property.name=\"setDynamicTooltip\"]", + "message": "Slider values are shown inline; setDynamicTooltip has no effect." + }, + { + "selector": "Literal[regex.pattern=/\\(\\?<[=!]/]", + "message": "No regex lookbehind: iOS before 16.4 cannot parse it, and Obsidian runs on iOS 15.6, so QuickAdd would not load at all." + }, + { + "selector": "AssignmentExpression[left.object.type=\"MemberExpression\"][left.object.computed=false][left.object.property.name=\"style\"][right.type=\"Literal\"]", + "message": "Put a static style in styles.css; set a computed one with setCssStyles, or a CSS variable with setCssProps." + }, + { + "selector": "CallExpression[callee.property.name=\"setProperty\"][callee.object.property.name=\"style\"][arguments.1.type=\"Literal\"]", + "message": "Put a static style in styles.css; set a computed one with setCssStyles, or a CSS variable with setCssProps." + }, + { + "selector": "CallExpression[callee.property.name=\"setAttribute\"][arguments.0.value=\"style\"][arguments.1.type=\"Literal\"]", + "message": "Put a static style in styles.css; set a computed one with setCssStyles, or a CSS variable with setCssProps." + }, + { + "selector": "CallExpression[callee.property.name=/^setCss(Props|Styles)$/] > ObjectExpression > Property[key.type=\"Literal\"][key.value=/^(?!--)/]", + "message": "Put a static style in styles.css; set a computed one with setCssStyles, or a CSS variable with setCssProps." + } + ] +} diff --git a/tests/tooling/sveltePolicy.test.ts b/tests/tooling/sveltePolicy.test.ts index 464102a2c..c253ad284 100644 --- a/tests/tooling/sveltePolicy.test.ts +++ b/tests/tooling/sveltePolicy.test.ts @@ -3,10 +3,12 @@ import { copyFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSyn import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { checkSvelte } from "svelte-policy"; import { compile } from "svelte/compiler"; import { describe, expect, it } from "vitest"; -import { checkSvelte } from "../../tooling/svelte-policy.mjs"; +const root = fileURLToPath(new URL("../../", import.meta.url)); +const component = join(root, "src/Probe.svelte"); const forbidden = { "create-element": 'document.createElement("div");', "create-fragment": "document.createDocumentFragment();", @@ -26,36 +28,21 @@ const permitted = { "valid-dynamic-style": 'el.style.color = el.dataset.color ?? "";', "valid-forward-regex": "void /a(?=b)/;", }; -const contexts = { - script: (statement: string) => ``, - template: (statement: string) => ``, -}; +const template = (statement: string) => ``; describe("custom syntax guards without ESLint", () => { - for (const [context, component] of Object.entries(contexts)) { - it.each(Object.entries(forbidden))(`rejects %s in a Svelte ${context}`, (_name, statement) => { - const diagnostics = checkSvelte(component(statement)); - expect(diagnostics.filter(item => item.rule === "restricted-syntax")).toHaveLength(1); - }); - it.each(Object.entries(permitted))(`accepts %s in a Svelte ${context}`, (_name, statement) => { - expect(checkSvelte(component(statement))).toEqual([]); - }); - } - it("rejects the same ten TypeScript violations, accepts valid code, and excludes tests in Oxlint", () => { - const root = fileURLToPath(new URL("../../", import.meta.url)); const workspace = mkdtempSync(join(tmpdir(), "quickadd-lint-")); try { mkdirSync(join(workspace, "src")); - mkdirSync(join(workspace, "tooling")); - copyFileSync(join(root, ".oxlintrc.syntax.json"), join(workspace, ".oxlintrc.syntax.json")); - copyFileSync(join(root, "tooling/oxlint-plugin.mjs"), join(workspace, "tooling/oxlint-plugin.mjs")); + for (const file of [".oxlintrc.syntax.json", "svelte-policy.json"]) copyFileSync(join(root, file), join(workspace, file)); symlinkSync(join(root, "node_modules"), join(workspace, "node_modules"), process.platform === "win32" ? "junction" : "dir"); for (const [name, statement] of Object.entries({ ...forbidden, ...permitted })) { writeFileSync(join(workspace, `src/${name}.ts`), `export function probe(el: HTMLElement): void { ${statement} }`); } writeFileSync(join(workspace, "src/excluded.test.ts"), 'document.createElement("div");'); writeFileSync(join(workspace, "src/state.svelte.ts"), 'export { writable } from "svelte/store";'); + writeFileSync(join(workspace, "src/dispatcher.svelte.ts"), 'import { createEventDispatcher } from "svelte"; export const dispatch = createEventDispatcher();'); writeFileSync(join(workspace, "src/debug.svelte.ts"), 'const value = 1; $inspect(value);'); writeFileSync(join(workspace, "src/internal.ts"), 'import "svelte/internal/disclose-version";'); writeFileSync(join(workspace, "src/internal.svelte.ts"), 'export * from "svelte/internal/client";'); @@ -63,17 +50,19 @@ describe("custom syntax guards without ESLint", () => { const result = spawnSync(process.execPath, [join(root, "node_modules/oxlint/bin/oxlint"), "-c", ".oxlintrc.syntax.json", "src", "--format", "json"], { cwd: workspace, encoding: "utf8", timeout: 30_000 }); expect(result.error).toBeUndefined(); expect(result.status, result.stderr).toBe(1); - const output: { diagnostics: { filename: string; code: string; message: string }[] } = JSON.parse(result.stdout); + const output: { diagnostics: { filename: string; code: string; message: string; severity: string }[] } = JSON.parse(result.stdout); const custom = output.diagnostics.filter(item => item.code === "quickadd(restricted-syntax)"); expect(custom).toHaveLength(10); for (const [name, statement] of Object.entries(forbidden)) { const diagnostic = custom.find(item => item.filename.endsWith(`${name}.ts`)); expect(diagnostic).toBeDefined(); - expect(diagnostic?.message).toBe(checkSvelte(contexts.template(statement))[0].message); + expect(diagnostic?.message).toBe(checkSvelte(template(statement), component)[0].message); } - expect(output.diagnostics.filter(item => item.code === "quickadd(svelte-runes)").map(item => basename(item.filename))).toEqual(["state.svelte.ts"]); - expect(output.diagnostics.filter(item => item.code === "quickadd(svelte-debug)")).toHaveLength(1); - expect(output.diagnostics.filter(item => item.code === "quickadd(svelte-internal)").map(item => basename(item.filename)).sort()).toEqual(["internal.svelte.ts", "internal.test.ts", "internal.ts"]); + const reported = (code: string) => output.diagnostics.filter(item => item.code === code).map(item => basename(item.filename)).sort(); + expect(reported("quickadd(no-svelte-store)")).toEqual(["state.svelte.ts"]); + expect(reported("quickadd(runes-only)")).toEqual(["dispatcher.svelte.ts"]); + expect(output.diagnostics.filter(item => item.code === "quickadd(no-inspect)").map(item => [basename(item.filename), item.severity])).toEqual([["debug.svelte.ts", "warning"]]); + expect(reported("quickadd(no-svelte-internal)")).toEqual(["internal.svelte.ts", "internal.test.ts", "internal.ts"]); expect(output.diagnostics.filter(item => item.filename.includes("valid-") || item.filename.includes("excluded.test.ts"))).toEqual([]); } finally { rmSync(workspace, { recursive: true, force: true }); @@ -81,12 +70,10 @@ describe("custom syntax guards without ESLint", () => { }); it("retains type-aware promise and string-timer checks through the inherited Oxlint config", () => { - const root = fileURLToPath(new URL("../../", import.meta.url)); const workspace = mkdtempSync(join(tmpdir(), "quickadd-typed-lint-")); try { mkdirSync(join(workspace, "src")); - mkdirSync(join(workspace, "tooling")); - for (const file of [".oxlintrc.json", ".oxlintrc.syntax.json", "tooling/oxlint-plugin.mjs"]) copyFileSync(join(root, file), join(workspace, file)); + for (const file of [".oxlintrc.json", ".oxlintrc.syntax.json", "svelte-policy.json"]) copyFileSync(join(root, file), join(workspace, file)); symlinkSync(join(root, "node_modules"), join(workspace, "node_modules"), process.platform === "win32" ? "junction" : "dir"); writeFileSync(join(workspace, "tsconfig.json"), JSON.stringify({ compilerOptions: { target: "ES2022", module: "ESNext", moduleResolution: "bundler", lib: ["DOM", "ES2022"], skipLibCheck: true }, include: ["src/**/*.ts"] })); writeFileSync(join(workspace, "src/floating.ts"), "Promise.resolve(1);"); @@ -109,134 +96,17 @@ describe("custom syntax guards without ESLint", () => { }); describe("Svelte template policies", () => { - const invalid = [ - ["no-at-html-tags", "{@html html}"], - ["require-each-key", "{#each items as item}

{item}

{/each}"], - ["valid-each-key", "{#each items as item (42)}

{item}

{/each}"], - ["valid-each-key", "{#each items as item (obj.item)}

{item}

{/each}"], - ["valid-each-key", "{#each items as item ((item => item)(1))}

{item}

{/each}"], - ["valid-each-key", "{#each items as item ((function item() { return 42; })())}

{item}

{/each}"], - ["valid-each-key", "{#each items as item ((function item() { return item; })())}

{item}

{/each}"], - ["valid-each-key", "{#each items as item ((class item {}))}

{item}

{/each}"], - ["valid-each-key", "{#each items as item ((function key() { if (true) { var item = 42; } return item; })())}

{item}

{/each}"], - ["no-dom-manipulating", '
'], - ["no-dom-manipulating", "
"], - ["no-dom-manipulating", "
"], - ["no-dupe-directives", "
"], - ["no-dupe-directives", ""], - ["no-dupe-directives", ""], - ["no-dupe-directives", ""], - ["no-dupe-directives", ""], - ["no-dupe-else-if-blocks", "{#if a || b}A{:else if b && c}B{/if}"], - ["no-dupe-else-if-blocks", "{#if a}A{:else if b}B{:else if a || b}C{/if}"], - ["no-dupe-else-if-blocks", "{#if status === 'ready'}A{:else if status === 'ready'}B{/if}"], - ["no-dupe-else-if-blocks", "{#if value === 1n}A{:else if value === 1n}B{/if}"], - ["no-object-in-text-mustaches", "

{{name: 'value'}}

"], - ["no-raw-special-elements", ""], - ["no-at-debug-tags", "{@debug value}"], - ["no-inspect", ""], - ["runes-only", ""], - ["restricted-syntax", ''], - ["restricted-syntax", ''], - ["restricted-syntax", ''], - ["restricted-syntax", ''], - ["no-console", ""], - ["no-console", ""], - ["no-console", "{#await value then console}

{console.log('X')}

{:catch error}

{console.log('X')}

{/await}"], - ["no-console", "{#each console.log('X') as console (console.id)}

{console.id}

{/each}"], - ["no-console", "{#each items as console (console.id)}

{console.id}

{:else}

{console.log('X')}

{/each}"], - ["no-console", "{#each items as item, console (item.id)}

{item.id}

{:else}

{console.log('X')}

{/each}"], - ["no-console", ""], - ["no-console", ""], - ["no-console", "

{console.log('X')}

"], - ["no-console", ""], - ["no-console", ""], - ["no-console", ""], - ["no-new-func", ""], - ["no-new-func", ""], - ["no-implied-eval", ""], - ["no-implied-eval", ""], - ["no-implied-eval", ""], - ["no-implied-eval", ""], - ["no-implied-eval", ""], - ["no-implied-eval", ""], - ["no-unused-svelte-ignore", "

X

"], - ["no-unused-svelte-ignore", "
{}}>X
"], - ]; - it.each(invalid)("rejects %s: %s", (rule, source) => { - expect(checkSvelte(source).map(item => item.rule)).toContain(rule); - }); it.each([ - "{#each items as item (item.id)}

{item.name}

{/each}", - "{#each items as {id, name} (id)}

{name}

{/each}", - "{#each items as item, i (i)}

{item}

{/each}", - "{#each items as item ((function key() { return item.id; })())}

{item}

{/each}", - "{#each items as item ((function key() { function inner() { var item = 42; } return item.id; })())}

{item}

{/each}", - "{#if a}A{:else if b}B{/if}", - "{#if a}{#if a}A{/if}{/if}", - "{#if next()}A{:else if next()}B{/if}", - "{#if a && next()}A{:else if a && next()}B{/if}", - "{#if object.ready}A{:else if object.ready}B{/if}", - "{#if i++}A{:else if i++}B{/if}", - "{#if (a = next())}A{:else if (a = next())}B{/if}", - "{#if a < b}A{:else if a < b}B{/if}", - "{#if a}A{:else if next()}B{:else if a}C{/if}", - "", - "", - "", - "", - "
", - "", - "", - "", - "{#if value === 1n}A{:else if value === 1}B{/if}", - "
", - "
", - "
", - "
", - "
", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "

{console.log('X')}

", - "

{console.log('X')}

", - "

{console.log('X')}

", - "", - "", - "", - "", - "", - "", - "{#each entries as console (console.id)}

{console.log('X')}

{/each}", - "{#snippet render(console)}

{console.log('X')}

{/snippet}", - "{#await value then console}

{console.log('X')}

{/await}", - "{#await value}

Waiting

{:catch Function}

{Function('value')}

{/await}", - "{#if ready}{@const console = logger}

{console.log('X')}

{/if}", - "", - "", - "", - "", - "", - "", - "
{}}>X
", - "
{}}>X
", - ])("accepts valid template code: %s", source => { - expect(checkSvelte(source)).toEqual([]); - }); - it("reports template expressions at their original line and column", () => { - const [diagnostic] = checkSvelte("

Text

\n", "Probe.svelte"); - expect(diagnostic).toMatchObject({ filename: "Probe.svelte", line: 2, column: 24, rule: "restricted-syntax" }); + ["no-svelte-store", ''], + ["runes-only", ""], + ["runes-only", ''], + ])("rejects %s: %s", (rule, source) => { + expect(checkSvelte(source, component).map(item => item.rule)).toContain(rule); }); - it("rejects malformed components instead of skipping them", () => { - expect(checkSvelte("{#if ready}

X

")).toMatchObject([{ severity: "error" }]); + it("rejects console methods in templates with the logger message", () => { + expect(checkSvelte("", component)).toMatchObject([ + { rule: "no-console", message: "Use the logger utilities instead of console methods." }, + ]); }); it("uses compiler warnings for accessibility and unused CSS", () => { const inaccessible = compile("
{}}>X
", { generate: false, runes: true }); diff --git a/tooling/README.md b/tooling/README.md index eec4a5d50..6189a35c5 100644 --- a/tooling/README.md +++ b/tooling/README.md @@ -1,17 +1,21 @@ # Tooling checks -`pnpm run lint` runs Oxlint, `svelte-policy.mjs`, `tsc --noEmit --noCheck`, -and `svelte-check --tsgo-experimental-api --fail-on-warnings` in parallel. +`pnpm run lint` runs Oxlint, the +[`svelte-policy`](https://github.com/chhoumann/svelte-policy) component +checker, `tsc --noEmit --noCheck`, and +`svelte-check --tsgo-experimental-api --fail-on-warnings` in parallel. pnpm runs at most four scripts at once, so a fifth `lint:` script would wait for a free slot. `pnpm run build-with-lint` runs the same checks before producing the application bundle. CI also runs the policy tests through `pnpm run test`. Oxlint checks TypeScript and extracted Svelte scripts. Its JavaScript plugin -API cannot inspect Svelte template expressions, so `svelte-policy.mjs` uses -the Svelte compiler's AST for complete components. Both tools use the ten -original selectors and messages exported by `oxlint-plugin.mjs`. `esquery` -matches these selectors without an ESLint runtime. +API cannot inspect Svelte template expressions, so the `svelte-policy` +checker uses the Svelte compiler's AST for complete components. The package +also provides the Oxlint plugin, which `.oxlintrc.syntax.json` loads under +the `quickadd` prefix. `svelte-policy.json` gives both tools the ten original +selectors and messages. For components, it also turns on the store and runes +rules and sets the `no-console` message. Oxlint's JavaScript plugin API is alpha and does not have a semver guarantee. The dependency is pinned. The selectors execute in JavaScript, while built-in @@ -31,8 +35,8 @@ type check. All current components use Svelte 5 runes. Svelte checking and production compilation require runes mode. The policy checker rejects `$:` statements -and `svelte/store` imports. Shared `.svelte.ts` modules receive the same import -restriction in Oxlint. These checks prevent legacy reactive statements and +and `svelte/store` imports. Shared `.svelte.ts` modules receive the same +restrictions in Oxlint. These checks prevent legacy reactive statements and store usage from returning. ## Svelte rule migration @@ -53,22 +57,22 @@ listed as retired below now depend on review. | `no-reactive-functions` | Legacy reactive statements are rejected. | | `no-reactive-literals` | Legacy reactive statements are rejected. | | `no-reactive-reassign` | Legacy reactive statements are rejected. | -| `no-store-async` | Legacy store imports are rejected in component code. | -| `require-store-reactive-access` | Legacy store imports are rejected in component code. Custom store objects are not inferred by the new checker. | -| `require-event-dispatcher-types` | Shared selector rejects named dispatcher imports, including aliases. Use callback props. Namespace and dynamic access are not traced. | +| `no-store-async` | `no-svelte-store` rejects legacy store imports in component code. | +| `require-store-reactive-access` | `no-svelte-store` rejects legacy store imports in component code. Custom store objects are not inferred by the new checker. | +| `require-event-dispatcher-types` | `runes-only` rejects named dispatcher imports, including aliases. Use callback props. Namespace and dynamic access are not traced. | | `no-at-html-tags` | Compiler-AST policy rejects `{@html}`. | -| `require-each-key` | Compiler-AST policy requires a key for every each block. | +| `require-each-key` | Compiler-AST policy requires a key for every each block with an item. | | `valid-each-key` | Compiler-AST policy requires the key to reference the block's item or index. This is not a uniqueness proof. | | `no-at-debug-tags` | Compiler-AST policy reports a warning. | -| `no-inspect` | Shared selector reports a warning in components and shared `.svelte.ts` modules. | +| `no-inspect` | `no-inspect` reports a warning in components and shared `.svelte.ts` modules. | | `no-dom-manipulating` | Compiler-AST policy rejects structural or text changes to the named binding used by `bind:this`. It distinguishes module, instance, and local scopes but does not follow arbitrary aliases. | | `no-dupe-else-if-blocks` | Compiler-AST policy checks duplicate and covered conditions using identifiers, literals, Boolean operators, and strict equality. It skips chains containing calls, property reads, mutations, or coercive operators because their values can change between evaluations. | | `no-dupe-on-directives` | Compiler-AST policy rejects identical event directives. | | `no-dupe-use-directives` | Compiler-AST policy rejects identical action directives. | -| `no-not-function-handler` | Svelte type checking verifies event-handler types. | +| `no-not-function-handler` | Compiler-AST policy rejects a literal, object, array, or class as an `on*` attribute or `on:` directive value, directly or through a `const`. Svelte type checking also verifies event-handler types. | | `no-object-in-text-mustaches` | Compiler-AST policy rejects object and function literals in text interpolation. | | `no-raw-special-elements` | Compiler-AST policy requires the `svelte:` prefix for special elements. | -| `no-svelte-internal` | Shared selector rejects imports and re-exports from Svelte internals in components and all TypeScript files. | +| `no-svelte-internal` | `no-svelte-internal` rejects imports and re-exports from Svelte internals in components and all TypeScript files. | | `no-unused-svelte-ignore` | Compiler-AST policy compares suppressed warnings with compiler diagnostics for the target element. | | `no-inner-declarations` | Removed. Svelte scripts are modules, and block-scoped functions were already allowed. | | `no-unused-props` | Removed. This rule required TypeScript parser services that the remaining ESLint fallback did not enable. | @@ -92,13 +96,33 @@ methods on unshadowed `window`, `globalThis`, `self`, and `global` objects. Type-aware promise, deprecation, and implied-eval checks still run on TypeScript through Oxlint, with the existing test and user-script exceptions. +The package closes gaps in the checker this migration first added: + +- `svelte-ignore` codes may be dashed, comma-separated, or on the next line, + and a markup comment applies to the next node past any text. +- `svelte-ignore` comments in legacy components are checked. +- Object, array, function, and class literals are rejected in attribute + values that mix text and mustaches. +- Each blocks without an item no longer need a key. +- `no-not-function-handler` is back as a compiler-AST rule. +- Member names written as backtick strings are resolved for + `no-implied-eval` and `no-dom-manipulating`. +- A `svelte-ignore` without a code is reported, and script `svelte-ignore` + comments are checked. +- Ignore codes are read per compile mode, including Svelte's legacy code + replacements. + ## Verification -`tests/tooling/sveltePolicy.test.ts` checks all ten forbidden examples in -TypeScript, component scripts, and inline template handlers. It also checks -valid examples, the test-file exclusion, matching diagnostics between Oxlint -and the component checker, and template policy failures. Temporary fixtures -are private and removed after the test. `tests/tooling/svelteCheck.test.ts` +`tests/tooling/sveltePolicy.test.ts` checks QuickAdd's wiring of the package. +Oxlint with `.oxlintrc.syntax.json` reports all ten forbidden examples in +TypeScript with the component checker's messages, the store, dispatcher, and +`$inspect` rules in shared `.svelte.ts` modules, and the internals guard in +every TypeScript file. Valid examples and test files pass the syntax guards. +The component checker applies the store, runes, and `no-console` settings +from `svelte-policy.json`. The package's own tests cover the policies, +including every fixture this file held before. Temporary fixtures are +private and removed after the test. `tests/tooling/svelteCheck.test.ts` requires `pnpm run check`'s command to fail on compiler warnings, legacy reactivity, type errors, components that do not parse, imports of missing components, and a native compiler that cannot start or crashes. It also @@ -115,13 +139,10 @@ pnpm run test These refinements follow the tooling migration: -- Type the component checker against Svelte's official AST types and simplify - the scope and condition analysis. Keep the existing valid and forbidden - fixtures as upgrade checks. - Reconsider the blanket `svelte/store` ban. Stores remain supported in Svelte 5, and removing ESLint does not require banning them. - Review the retired CSS checks and decide which merit separate enforcement. - Move checks to upstream Oxlint or Svelte implementations when they cover - template expressions, reducing the code maintained here. + template expressions, reducing the code maintained in `svelte-policy`. - Remove the TypeScript 6 compatibility package when svelte-check no longer loads the `typescript` package. diff --git a/tooling/oxlint-plugin.mjs b/tooling/oxlint-plugin.mjs deleted file mode 100644 index fb9d3a574..000000000 --- a/tooling/oxlint-plugin.mjs +++ /dev/null @@ -1,108 +0,0 @@ -export const guards = [ - { - "selector": "CallExpression[callee.type=\"MemberExpression\"][callee.property.name=\"createElement\"]", - "message": "Use Obsidian DOM helpers; use the owner document window for detached elements." - }, - { - "selector": "CallExpression[callee.property.name=\"createDocumentFragment\"]", - "message": "Use createFragment(), or the owner document window for a specific realm." - }, - { - "selector": "CallExpression:matches([callee.name=\"createEl\"], [callee.property.name=\"createEl\"])[arguments.0.value=/^(div|span)$/]", - "message": "Use createDiv() or createSpan() for these tags." - }, - { - "selector": "BinaryExpression[operator=\"instanceof\"][right.name=\"HTMLElement\"]", - "message": "Use node.instanceOf(HTMLElement) for cross-window checks." - }, - { - "selector": "CallExpression[callee.type=\"MemberExpression\"][callee.property.name=\"setDynamicTooltip\"]", - "message": "Slider values are shown inline; setDynamicTooltip has no effect." - }, - { - "selector": "Literal[regex.pattern=/\\(\\?<[=!]/]", - "message": "No regex lookbehind: iOS before 16.4 cannot parse it, and Obsidian runs on iOS 15.6, so QuickAdd would not load at all." - }, - { - "selector": "AssignmentExpression[left.object.type=\"MemberExpression\"][left.object.computed=false][left.object.property.name=\"style\"][right.type=\"Literal\"]", - "message": "Put a static style in styles.css; set a computed one with setCssStyles, or a CSS variable with setCssProps." - }, - { - "selector": "CallExpression[callee.property.name=\"setProperty\"][callee.object.property.name=\"style\"][arguments.1.type=\"Literal\"]", - "message": "Put a static style in styles.css; set a computed one with setCssStyles, or a CSS variable with setCssProps." - }, - { - "selector": "CallExpression[callee.property.name=\"setAttribute\"][arguments.0.value=\"style\"][arguments.1.type=\"Literal\"]", - "message": "Put a static style in styles.css; set a computed one with setCssStyles, or a CSS variable with setCssProps." - }, - { - "selector": "CallExpression[callee.property.name=/^setCss(Props|Styles)$/] > ObjectExpression > Property[key.type=\"Literal\"][key.value=/^(?!--)/]", - "message": "Put a static style in styles.css; set a computed one with setCssStyles, or a CSS variable with setCssProps." - } -]; - -export const svelteInternalGuards = [ - { - selector: ':matches(ImportDeclaration, ImportExpression, ExportNamedDeclaration, ExportAllDeclaration)[source.value=/^svelte\\/internal(\\/|$)/]', - message: "Use Svelte's public API instead of svelte/internal.", - }, -]; - -export const svelteRunesGuards = [ - { - selector: ':matches(ImportDeclaration, ImportExpression, ExportNamedDeclaration, ExportAllDeclaration)[source.value="svelte/store"]', - message: "Use Svelte runes for component state instead of legacy stores.", - }, - { - selector: 'ImportDeclaration[source.value="svelte"] > ImportSpecifier[imported.name="createEventDispatcher"]', - message: "Use callback props instead of createEventDispatcher.", - }, -]; - -export const svelteImportGuards = [...svelteInternalGuards, ...svelteRunesGuards]; - -export const svelteDebugGuards = [ - { - selector: 'CallExpression:matches([callee.name="$inspect"], [callee.object.name="$inspect"])', - message: "Remove $inspect before shipping.", - rule: "no-inspect", - severity: "warning", - }, -]; - -function createGuards(context, checks) { - return Object.fromEntries(checks.map(({ selector, message }) => [ - selector, - (node) => context.report({ node, message }), - ])); -} - -export default { - meta: { name: "quickadd" }, - rules: { - "restricted-syntax": { - meta: { type: "problem", schema: [] }, - create(context) { - return createGuards(context, guards); - }, - }, - "svelte-internal": { - meta: { type: "problem", schema: [] }, - create(context) { - return createGuards(context, svelteInternalGuards); - }, - }, - "svelte-runes": { - meta: { type: "problem", schema: [] }, - create(context) { - return createGuards(context, svelteRunesGuards); - }, - }, - "svelte-debug": { - meta: { type: "problem", schema: [] }, - create(context) { - return createGuards(context, svelteDebugGuards); - }, - }, - }, -}; diff --git a/tooling/svelte-policy.mjs b/tooling/svelte-policy.mjs deleted file mode 100644 index 0ed63304d..000000000 --- a/tooling/svelte-policy.mjs +++ /dev/null @@ -1,300 +0,0 @@ -import { readFileSync, readdirSync } from "node:fs"; -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import esquery from "esquery"; -import { compile, parse } from "svelte/compiler"; -import { guards, svelteDebugGuards, svelteImportGuards } from "./oxlint-plugin.mjs"; - -const selectors = [...guards, ...svelteImportGuards, ...svelteDebugGuards].map(check => ({ - ...check, - query: esquery.parse(check.selector), -})); -const domMethods = new Set([ - "appendChild", "insertBefore", "normalize", "removeChild", "replaceChild", - "after", "append", "before", "insertAdjacentElement", "insertAdjacentHTML", - "insertAdjacentText", "prepend", "remove", "replaceChildren", "replaceWith", -]); -const domProperties = new Set(["textContent", "innerHTML", "outerHTML", "innerText", "outerText"]); -const timerFunctions = new Set(["setTimeout", "setInterval", "execScript"]); -const globalObjects = new Set(["window", "globalThis", "self", "global"]); -const specialElements = new Set(["head", "body", "window", "document", "element", "options"]); -const ignoredKeys = new Set(["loc", "name_loc", "metadata", "css"]); -const functionTypes = new Set(["FunctionExpression", "FunctionDeclaration", "ArrowFunctionExpression"]); -const hoistedBindings = new WeakMap(); - -function walk(node, visit, ancestors = []) { - if (!node || typeof node !== "object") return; - if (Array.isArray(node)) { - for (const item of node) walk(item, visit, ancestors); - return; - } - if (typeof node.type !== "string") return; - if (visit(node, ancestors) === false) return; - for (const [key, child] of Object.entries(node)) { - if (!ignoredKeys.has(key)) walk(child, visit, [node, ...ancestors]); - } -} - -function patternNames(node) { - if (!node) return []; - switch (node.type) { - case "Identifier": return [node.name]; - case "RestElement": - case "SpreadElement": return patternNames(node.argument); - case "AssignmentPattern": return patternNames(node.left); - case "ArrayPattern": - case "ArrayExpression": return node.elements.flatMap(patternNames); - case "ObjectPattern": - case "ObjectExpression": return node.properties.flatMap(property => patternNames(["RestElement", "SpreadElement"].includes(property.type) ? property.argument : property.value)); - default: return []; - } -} - -function references(node) { - const names = new Set(); - walk(node, (child, ancestors) => { - const [parent] = ancestors; - if (child.type !== "Identifier") return; - if (["FunctionExpression", "ClassExpression"].includes(parent?.type) && parent.id === child) return; - if (parent?.type === "MemberExpression" && parent.property === child && !parent.computed) return; - if (parent?.type === "Property" && parent.key === child && !parent.computed && !parent.shorthand) return; - if (isShadowed(child.name, ancestors)) return; - names.add(child.name); - }); - return names; -} - -function declaresName(statement, name) { - const declaration = statement.declaration ?? statement; - switch (declaration.type) { - case "VariableDeclaration": return declaration.kind !== "var" && declaration.declarations.some(item => patternNames(item.id).includes(name)); - case "FunctionDeclaration": - case "ClassDeclaration": return declaration.id?.name === name; - case "ImportDeclaration": return declaration.specifiers.some(item => item.local.name === name); - case "SnippetBlock": return declaration.expression.name === name; - default: return false; - } -} - -function hoistedVarNames(scope) { - if (hoistedBindings.has(scope)) return hoistedBindings.get(scope); - const names = new Set(); - walk(scope, node => { - if (node !== scope && (functionTypes.has(node.type) || ["ClassExpression", "ClassDeclaration", "StaticBlock"].includes(node.type))) return false; - if (node.type === "VariableDeclaration" && node.kind === "var") { - for (const declaration of node.declarations) { - for (const name of patternNames(declaration.id)) names.add(name); - } - } - }); - hoistedBindings.set(scope, names); - return names; -} - -function bindingScope(name, ancestors) { - for (const ancestor of ancestors) { - if (["FunctionExpression", "ClassExpression"].includes(ancestor.type) && ancestor.id?.name === name) return ancestor; - if (ancestor.params?.some(param => patternNames(param).includes(name))) return ancestor; - if (functionTypes.has(ancestor.type) && ancestors.includes(ancestor.body) && hoistedVarNames(ancestor.body).has(name)) return ancestor; - if (ancestor.type === "CatchClause" && patternNames(ancestor.param).includes(name)) return ancestor; - if (ancestor.type === "EachBlock" && (ancestors.includes(ancestor.body) || ancestors.includes(ancestor.key)) - && (ancestor.index === name || patternNames(ancestor.context).includes(name))) return ancestor; - if (ancestor.type === "SnippetBlock" && ancestor.parameters.some(param => patternNames(param).includes(name))) return ancestor; - if (ancestor.type === "AwaitBlock" && ancestors.includes(ancestor.then) && patternNames(ancestor.value).includes(name)) return ancestor.then; - if (ancestor.type === "AwaitBlock" && ancestors.includes(ancestor.catch) && patternNames(ancestor.error).includes(name)) return ancestor.catch; - if (ancestors.includes(ancestor.fragment) && ancestor.attributes?.some(attribute => attribute.type === "LetDirective" - && (attribute.expression ? patternNames(attribute.expression).includes(name) : attribute.name === name))) return ancestor.fragment; - if (ancestor.type === "SwitchStatement" && ancestors.some(child => ancestor.cases.includes(child)) - && ancestor.cases.some(branch => branch.consequent.some(statement => declaresName(statement, name)))) return ancestor; - if (["ForStatement", "ForInStatement", "ForOfStatement"].includes(ancestor.type)) { - const declaration = ancestor.init ?? ancestor.left; - if (declaration?.type === "VariableDeclaration" && declaresName(declaration, name)) return ancestor; - } - if (["BlockStatement", "Program", "StaticBlock"].includes(ancestor.type) && ancestor.body.some(statement => declaresName(statement, name))) return ancestor; - if (["Program", "StaticBlock"].includes(ancestor.type) && hoistedVarNames(ancestor).has(name)) return ancestor; - if (ancestor.type === "Fragment" && ancestor.nodes.some(statement => declaresName(statement, name))) return ancestor; - } - return undefined; -} - -function isShadowed(name, ancestors) { - return bindingScope(name, ancestors) !== undefined; -} - -function propertyName(node) { - return node?.computed ? node.property?.value : node?.property?.name; -} - -function globalIdentifier(node, ancestors) { - return node.type === "Identifier" && !isShadowed(node.name, ancestors); -} - -function globalTimer(callee, ancestors) { - if (callee.type === "Identifier") return timerFunctions.has(callee.name) && globalIdentifier(callee, ancestors); - return callee.type === "MemberExpression" && globalObjects.has(callee.object.name) - && globalIdentifier(callee.object, ancestors) && timerFunctions.has(propertyName(callee)); -} - -function fingerprint(node) { - return JSON.stringify(node, (name, value) => { - if (["start", "end", "loc", "raw"].includes(name)) return undefined; - return typeof value === "bigint" ? { bigint: value.toString() } : value; - }); -} - -function conditionFingerprint(node) { - if (node?.type === "LogicalExpression" && ["&&", "||"].includes(node.operator)) { - return `${node.operator}:${[conditionFingerprint(node.left), conditionFingerprint(node.right)].sort().join(":")}`; - } - return fingerprint(node); -} - -function splitCondition(node, operator) { - return node.type === "LogicalExpression" && node.operator === operator - ? [...splitCondition(node.left, operator), ...splitCondition(node.right, operator)] - : [node]; -} - -function stableCondition(node) { - switch (node.type) { - case "Identifier": - case "Literal": return true; - case "UnaryExpression": return node.operator === "!" && stableCondition(node.argument); - case "LogicalExpression": return ["&&", "||"].includes(node.operator) && stableCondition(node.left) && stableCondition(node.right); - case "BinaryExpression": return ["===", "!=="].includes(node.operator) && stableCondition(node.left) && stableCondition(node.right); - default: return false; - } -} - -function coveredCondition(node, ancestors) { - let current = node; - const previous = []; - for (let index = 0; index < ancestors.length; index += 2) { - const [fragment, block] = ancestors.slice(index, index + 2); - if (!current.elseif || fragment?.type !== "Fragment" || block?.type !== "IfBlock" || block.alternate !== fragment) break; - previous.push(block.test); - current = block; - } - if (![node.test, ...previous].every(stableCondition)) return false; - const candidates = node.test.type === "LogicalExpression" && node.test.operator === "&&" - ? [...splitCondition(node.test, "&&"), node.test] : [node.test]; - return candidates.some(candidate => { - let terms = splitCondition(candidate, "||").map(term => splitCondition(term, "&&").map(conditionFingerprint)); - for (const condition of previous) { - const priorTerms = splitCondition(condition, "||").map(term => splitCondition(term, "&&").map(conditionFingerprint)); - terms = terms.filter(term => !priorTerms.some(prior => prior.every(part => term.includes(part)))); - } - return terms.length === 0; - }); -} - -function checkIgnores(ast, source, filename, report) { - const directives = []; - walk(ast.fragment, (node, [parent]) => { - if (node.type !== "Comment" || !node.data.trim().startsWith("svelte-ignore ")) return; - const index = parent.nodes.indexOf(node); - const target = parent.nodes.slice(index + 1).find(item => item.type !== "Comment" && !(item.type === "Text" && !item.data.trim())); - const codes = node.data.trim().slice("svelte-ignore ".length).split("(")[0].match(/[a-z][a-z_0-9]*/g) ?? []; - directives.push({ node, target, codes }); - }); - if (!directives.length) return; - let unignored = source; - for (const { node } of [...directives].reverse()) { - unignored = unignored.slice(0, node.start) + source.slice(node.start, node.end).replace(/[^\n]/g, " ") + unignored.slice(node.end); - } - let warnings; - try { - warnings = compile(unignored, { filename, runes: true, generate: false }).warnings; - } catch { - // Strict svelte-check reports compiler errors before suppression checks can run. - return; - } - for (const { node, target, codes } of directives) { - for (const code of codes) { - if (!target || !warnings.some(warning => warning.code === code && warning.start.character >= target.start && warning.start.character < target.end)) report(node, "no-unused-svelte-ignore", `Remove the unused svelte-ignore for ${code}.`); - } - } -} - -export function checkSvelte(source, filename = "Component.svelte") { - const diagnostics = []; - function report(node, rule, message, severity = "error") { - const offset = node.start ?? 0; - const before = source.slice(0, offset); - diagnostics.push({ filename, line: before.split("\n").length, column: offset - before.lastIndexOf("\n"), rule, message, severity }); - } - let ast; - try { - ast = parse(source, { modern: true, filename }); - } catch (error) { - report({ start: error.position?.[0] }, error.code ?? "parse", error.message); - return diagnostics; - } - checkIgnores(ast, source, filename, report); - const componentScopes = [ast.instance?.content, ast.module?.content].filter(Boolean); - const boundElements = new Map(); - walk(ast.fragment, (node, ancestors) => { - if (!["RegularElement", "SvelteElement"].includes(node.type)) return; - for (const attribute of node.attributes) { - if (attribute.type !== "BindDirective" || attribute.name !== "this" || attribute.expression?.type !== "Identifier") continue; - const name = attribute.expression.name; - const scopes = boundElements.get(name) ?? new Set(); - scopes.add(bindingScope(name, [node, ...ancestors, ...componentScopes])); - boundElements.set(name, scopes); - } - }); - walk(ast, (node, ancestors) => { - const [parent] = ancestors; - const globalScopes = ancestors.includes(ast.module?.content) ? [node, ...ancestors] : [node, ...ancestors, ...componentScopes]; - for (const check of selectors) { - if (esquery.matches(node, check.query, ancestors, { fallback: "iteration" })) report(node, check.rule ?? "restricted-syntax", check.message, check.severity ?? "error"); - } - if (node.type === "HtmlTag") report(node, "no-at-html-tags", "Render text or a Svelte component instead of raw HTML."); - if (node.type === "DebugTag") report(node, "no-at-debug-tags", "Remove debugging tags before shipping.", "warning"); - if (node.type === "LabeledStatement" && node.label.name === "$") report(node, "runes-only", "Use $derived or $effect instead of legacy $: statements."); - if (node.type === "EachBlock") { - if (!node.key) report(node, "require-each-key", "Give each block a stable key."); - else { - const names = [...patternNames(node.context), node.index]; - if (!names.some(name => references(node.key).has(name))) report(node.key, "valid-each-key", "The key must reference this block's item or index."); - } - } - if (node.type === "IfBlock" && node.elseif && coveredCondition(node, ancestors)) report(node.test, "no-dupe-else-if-blocks", "This condition is already covered by an earlier branch."); - if (node.type === "ExpressionTag" && parent?.type === "Fragment" && ["ObjectExpression", "ArrayExpression", "ArrowFunctionExpression", "FunctionExpression", "ClassExpression"].includes(node.expression.type)) report(node, "no-object-in-text-mustaches", "Interpolate a display value instead of an object or function."); - if (node.type === "RegularElement" && specialElements.has(node.name)) report(node, "no-raw-special-elements", `Use for this special element.`); - if (node.attributes) { - const directives = new Set(); - for (const attribute of node.attributes) { - if (!["OnDirective", "UseDirective"].includes(attribute.type)) continue; - const key = `${attribute.type}:${attribute.name}:${[...(attribute.modifiers ?? [])].sort().join(",")}:${fingerprint(attribute.expression)}`; - if (directives.has(key)) report(attribute, "no-dupe-directives", "Remove the duplicate event or action directive."); - directives.add(key); - } - } - if (node.type === "MemberExpression" && node.object.type === "Identifier" && boundElements.get(node.object.name)?.has(bindingScope(node.object.name, globalScopes))) { - const name = propertyName(node); - const target = parent?.type === "ChainExpression" ? ancestors[1] : parent; - const callee = parent?.type === "ChainExpression" ? parent : node; - if (target?.type === "CallExpression" && target.callee === callee && domMethods.has(name) || target?.type === "AssignmentExpression" && target.left === node && domProperties.has(name)) report(node, "no-dom-manipulating", "Let Svelte update bound elements instead of changing their DOM structure or text directly."); - } - if (node.type === "MemberExpression" && node.object.name === "console" && globalIdentifier(node.object, globalScopes) && !["warn", "error"].includes(propertyName(node))) report(node, "no-console", "Use the logger utilities instead of console methods."); - if (["CallExpression", "NewExpression"].includes(node.type) && node.callee.name === "Function" && globalIdentifier(node.callee, globalScopes)) report(node, "no-new-func", "Do not construct code from strings."); - if (node.type === "CallExpression" && globalTimer(node.callee, globalScopes) && (typeof node.arguments[0]?.value === "string" || node.arguments[0]?.type === "TemplateLiteral")) report(node, "no-implied-eval", "Pass a callback instead of a code string."); - }); - return diagnostics; -} - -function componentFiles(directory) { - return readdirSync(directory, { withFileTypes: true }).flatMap(entry => { - const path = resolve(directory, entry.name); - return entry.isDirectory() ? componentFiles(path) : path.endsWith(".svelte") ? [path] : []; - }).sort(); -} - -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - const diagnostics = componentFiles("src").flatMap(filename => checkSvelte(readFileSync(filename, "utf8"), filename)); - for (const diagnostic of diagnostics) console.log(`${diagnostic.filename}:${diagnostic.line}:${diagnostic.column} ${diagnostic.severity} ${diagnostic.rule}: ${diagnostic.message}`); - const errors = diagnostics.filter(item => item.severity === "error").length; - console.log(`Svelte policies: ${errors} errors, ${diagnostics.length - errors} warnings.`); - process.exitCode = errors ? 1 : 0; -}