From c50d3d3cd828a69a373078bb7a9c65ccdec38391 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 12 Oct 2026 12:47:13 +0000 Subject: [PATCH 1/7] build(deps): adopt native tooling and replace ESLint --- .github/workflows/ci.yml | 2 - .gitignore | 1 + .oxlintrc.json | 9 + .oxlintrc.syntax.json | 242 +++ AGENTS.md | 4 +- CONTRIBUTING.md | 6 + esbuild.config.mjs | 2 - eslint.config.mjs | 188 -- package.json | 43 +- pnpm-lock.yaml | 2238 +++++++++--------------- src/gui/choiceList/ChoiceView.svelte | 4 +- svelte.config.mjs | 5 +- tests/examples/captureInboxGps.test.ts | 2 +- tests/svelteCompilerOptions.test.ts | 7 +- tests/tooling/sveltePolicy.test.ts | 192 ++ tooling/README.md | 109 ++ tooling/oxlint-plugin.mjs | 97 + tooling/svelte-policy.mjs | 217 +++ tsconfig.json | 2 +- 19 files changed, 1773 insertions(+), 1597 deletions(-) create mode 100644 .oxlintrc.json create mode 100644 .oxlintrc.syntax.json delete mode 100644 eslint.config.mjs create mode 100644 tests/tooling/sveltePolicy.test.ts create mode 100644 tooling/README.md create mode 100644 tooling/oxlint-plugin.mjs create mode 100644 tooling/svelte-policy.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a37524ab..d0175a159 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,8 +104,6 @@ jobs: run: pnpm install --frozen-lockfile - name: Run build with lint run: pnpm run build-with-lint - - name: Svelte check - run: pnpm run check # Builds the docs site the way Cloudflare Pages does (docs/.node-version, # frozen docs lockfile) and checks internal links and anchors. Cloudflare diff --git a/.gitignore b/.gitignore index 922aaee73..d72db0a30 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ coverage/ # Obsidian application bundle (only ever extracted transiently for inspection) /app.js +.svelte-check/ diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 000000000..26d30e4c3 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,9 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": [ + "./.oxlintrc.syntax.json" + ], + "options": { + "typeAware": true + } +} diff --git a/.oxlintrc.syntax.json b/.oxlintrc.syntax.json new file mode 100644 index 000000000..14540652d --- /dev/null +++ b/.oxlintrc.syntax.json @@ -0,0 +1,242 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": [], + "categories": { + "correctness": "off" + }, + "options": { + "typeAware": false + }, + "env": { + "builtin": true + }, + "ignorePatterns": [ + "node_modules/**", + "dist/**", + "docs/**", + ".audit/**", + ".obsidian-e2e-vaults/**", + "main.js", + "**/*.d.ts", + ".svelte-check/**" + ], + "overrides": [ + { + "files": [ + "**/*.ts" + ], + "rules": { + "no-array-constructor": "error", + "no-unused-expressions": "error", + "no-unused-vars": [ + "error", + { + "args": "none" + } + ], + "no-prototype-builtins": "off", + "no-empty-function": "off", + "typescript/ban-ts-comment": "off", + "typescript/no-duplicate-enum-values": "error", + "typescript/no-empty-object-type": "error", + "typescript/no-explicit-any": "off", + "typescript/no-extra-non-null-assertion": "error", + "typescript/no-misused-new": "error", + "typescript/no-namespace": "error", + "typescript/no-non-null-asserted-optional-chain": "error", + "typescript/no-require-imports": "error", + "typescript/no-this-alias": "error", + "typescript/no-unnecessary-type-constraint": "error", + "typescript/no-unsafe-declaration-merging": "error", + "typescript/no-unsafe-function-type": "error", + "typescript/no-wrapper-object-types": "error", + "typescript/prefer-as-const": "error", + "typescript/prefer-namespace-keyword": "error", + "typescript/triple-slash-reference": "error", + "typescript/consistent-type-imports": "warn", + "typescript/no-unsafe-assignment": "off", + "typescript/no-unsafe-member-access": "off", + "typescript/no-unsafe-call": "off", + "typescript/no-unsafe-return": "off", + "typescript/no-unsafe-argument": "off", + "typescript/restrict-template-expressions": "off" + }, + "plugins": [ + "typescript" + ], + "env": { + "browser": true, + "node": true + } + }, + { + "files": [ + "*.svelte", + "**/*.svelte" + ], + "rules": { + "no-inner-declarations": "off", + "no-self-assign": "off" + } + }, + { + "files": [ + "**/*.svelte", + "**/*.svelte.ts" + ], + "env": { + "browser": true + } + }, + { + "files": [ + "src/**/*.ts" + ], + "rules": { + "no-useless-escape": "error", + "typescript/no-unnecessary-type-assertion": "error", + "typescript/prefer-promise-reject-errors": "error", + "typescript/unbound-method": "error", + "typescript/no-floating-promises": "error", + "typescript/no-misused-promises": "error", + "typescript/no-deprecated": "error" + }, + "plugins": [ + "typescript" + ] + }, + { + "files": [ + "src/**/*.ts", + "src/**/*.svelte" + ], + "rules": { + "no-console": [ + "error", + { + "allow": [ + "warn", + "error" + ] + } + ] + } + }, + { + "files": [ + "src/gui/components/textareaIndent.ts", + "src/gui/imagePasteHandler.ts" + ], + "rules": { + "typescript/no-deprecated": [ + "error", + { + "allow": [ + { + "from": "lib", + "name": "execCommand" + } + ] + } + ] + }, + "plugins": [ + "typescript" + ] + }, + { + "files": [ + "src/gui/svelte/mountComponent.ts" + ], + "rules": { + "typescript/no-explicit-any": "error" + }, + "plugins": [ + "typescript" + ] + }, + { + "files": [ + "src/**/*.ts", + "src/**/*.svelte" + ], + "rules": { + "no-new-func": "error", + "no-implied-eval": "error" + } + }, + { + "files": [ + "src/**/*.ts" + ], + "rules": { + "no-implied-eval": "off", + "typescript/no-implied-eval": "error" + }, + "plugins": [ + "typescript" + ] + }, + { + "files": [ + "src/utils/userScript.ts" + ], + "rules": { + "no-new-func": "off", + "typescript/no-implied-eval": "off" + }, + "plugins": [ + "typescript" + ] + }, + { + "files": [ + "src/**/*.test.ts" + ], + "rules": { + "no-useless-escape": "off", + "typescript/no-unnecessary-type-assertion": "off", + "typescript/prefer-promise-reject-errors": "off", + "typescript/unbound-method": "off", + "typescript/no-floating-promises": "off", + "typescript/no-misused-promises": "off", + "typescript/no-deprecated": "off", + "no-console": "off", + "no-new-func": "off", + "no-implied-eval": "off", + "typescript/no-implied-eval": "off" + } + }, + { + "files": [ + "src/**/*.ts" + ], + "rules": { + "quickadd/restricted-syntax": "error" + } + }, + { + "files": [ + "src/**/*.test.ts" + ], + "rules": { + "quickadd/restricted-syntax": "off" + } + }, + { + "files": [ + "**/*.svelte.ts" + ], + "rules": { + "quickadd/svelte-runes": "error", + "quickadd/svelte-debug": "warn" + } + } + ], + "jsPlugins": [ + { + "name": "quickadd", + "specifier": "./tooling/oxlint-plugin.mjs" + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index aac76fc64..6dd8d240b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,8 +23,8 @@ QuickAdd is an Obsidian community plugin. Source code lives in `src/`: core logi ## Build, Test, and Development Commands - `pnpm run dev`: watch-mode bundle via `esbuild.config.mjs`, regenerating `main.js` as you edit. - `pnpm run build`: run `tsc --noEmit` then produce the production bundle. -- `pnpm run build-with-lint`: type-check, run ESLint (`pnpm lint`), then produce the production build; use for release packaging. -- `pnpm run lint`: apply ESLint to TypeScript sources to catch type and usage issues. +- `pnpm run build-with-lint`: type-check, run Oxlint and Svelte policy checks (`pnpm lint`), then produce the production build; use for release packaging. +- `pnpm run lint`: run Oxlint, Svelte template policies, and strict Svelte compiler/type checks. - `pnpm run test`: execute Vitest with `--passWithNoTests` for fast local verification. ## Coding Style & Naming Conventions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5b8f1dfd8..b88f2f373 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,7 @@ pnpm install pnpm run dev # rebuild main.js on every change pnpm run test # unit tests pnpm run build # type-check and bundle +pnpm run lint # Oxlint, Svelte template policies, and strict Svelte checks ``` For changes that affect the plugin at runtime, verify the behavior in Obsidian @@ -47,6 +48,11 @@ obsidian vault=dev plugin:reload id=quickadd Keep pull requests narrow. Include generated files such as `main.js` and `styles.css` when the source change updates them. +TypeScript checks use Oxlint. Svelte compiler and type diagnostics fail on +warnings, and a small compiler-AST checker enforces template policies and the +same custom syntax guards. [Tooling checks](tooling/README.md) lists the rules +and the migration from ESLint. + ### Obsidian's plugin review Obsidian reviews each release with ESLint (`eslint-plugin-obsidianmd`) and diff --git a/esbuild.config.mjs b/esbuild.config.mjs index e0148655a..2e358aed1 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -3,7 +3,6 @@ import { readFileSync } from "node:fs"; import esbuild from "esbuild"; import esbuildSvelte from "esbuild-svelte"; import process from "process"; -import sveltePreprocess from "svelte-preprocess"; import { getGitInfo } from "./buildGitInfo.mjs"; import { svelteCompilerOptions } from "./svelteCompilerOptions.mjs"; @@ -66,7 +65,6 @@ const options = Object.freeze({ plugins: [ esbuildSvelte({ compilerOptions: svelteCompilerOptions, - preprocess: sveltePreprocess(), }), ], format: "cjs", diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index 0b8802f61..000000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,188 +0,0 @@ -import typescriptEslint from '@typescript-eslint/eslint-plugin'; -import typescriptParser from '@typescript-eslint/parser'; -import svelte from 'eslint-plugin-svelte'; -import svelteParser from 'svelte-eslint-parser'; -import globals from 'globals'; - -export default [ - { - // Global ignores: a standalone object with only `ignores` applies repo-wide in flat config. - ignores: ['node_modules/**', 'dist/**', 'docs/**', '.audit/**', '.obsidian-e2e-vaults/**', 'main.js', '**/*.d.ts'], - }, - { - files: ['**/*.ts'], - languageOptions: { - parser: typescriptParser, - parserOptions: { - ecmaVersion: 2022, - sourceType: 'module', - }, - globals: { - ...globals.node, - ...globals.browser, - }, - }, - plugins: { - '@typescript-eslint': typescriptEslint, - }, - rules: { - ...typescriptEslint.configs.recommended.rules, - 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': ['error', { args: 'none' }], - '@typescript-eslint/ban-ts-comment': 'off', - 'no-prototype-builtins': 'off', - '@typescript-eslint/no-empty-function': 'off', - '@typescript-eslint/consistent-type-imports': 'warn', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unsafe-assignment': 'off', - '@typescript-eslint/no-unsafe-member-access': 'off', - '@typescript-eslint/no-unsafe-call': 'off', - '@typescript-eslint/no-unsafe-return': 'off', - '@typescript-eslint/no-unsafe-argument': 'off', - '@typescript-eslint/restrict-template-expressions': 'off', - }, - }, - // Lint Svelte 5 components (runes-aware via svelte-eslint-parser). - ...svelte.configs['flat/recommended'], - { - files: ['**/*.svelte', '**/*.svelte.ts'], - languageOptions: { - parser: svelteParser, - parserOptions: { - // Parse `, + 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")); + 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/debug.svelte.ts"), 'const value = 1; $inspect(value);'); + 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 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(output.diagnostics.filter(item => item.code === "quickadd(svelte-runes)")).toHaveLength(1); + expect(output.diagnostics.filter(item => item.code === "quickadd(svelte-debug)")).toHaveLength(1); + expect(output.diagnostics.filter(item => item.filename.includes("valid-") || item.filename.includes("excluded.test.ts"))).toEqual([]); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + 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)); + 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);"); + writeFileSync(join(workspace, "src/misused.ts"), "[1].forEach(async () => {});"); + writeFileSync(join(workspace, "src/timer.ts"), 'const code: string = "alert(1)"; setTimeout(code, 10);'); + writeFileSync(join(workspace, "src/valid.ts"), "void Promise.resolve(1); setTimeout(() => {}, 10);"); + writeFileSync(join(workspace, "src/excluded.test.ts"), "Promise.resolve(1);"); + const result = spawnSync(process.execPath, [join(root, "node_modules/oxlint/bin/oxlint"), "--type-aware", "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 }[] } = JSON.parse(result.stdout); + expect(output.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + "typescript(no-floating-promises)", "typescript(no-misused-promises)", "typescript(no-implied-eval)", + ])); + expect(output.diagnostics.filter(item => item.filename.endsWith("valid.ts") || item.filename.endsWith("excluded.test.ts"))).toEqual([]); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); +}); + +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}"], + ["no-dom-manipulating", '
'], + ["no-dom-manipulating", "
"], + ["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-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-new-func", ""], + ["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}", + "{#if a}A{:else if b}B{/if}", + "{#if a}{#if a}A{/if}{/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" }); + }); + it("rejects malformed components instead of skipping them", () => { + expect(checkSvelte("{#if ready}

X

")).toMatchObject([{ severity: "error" }]); + }); + it("uses compiler warnings for accessibility and unused CSS", () => { + const inaccessible = compile("
{}}>X
", { generate: false, runes: true }); + expect(inaccessible.warnings.map(item => item.code)).toContain("a11y_click_events_have_key_events"); + const unused = compile("

X

", { generate: false, runes: true }); + expect(unused.warnings.map(item => item.code)).toContain("css_unused_selector"); + }); + it("fails strict Svelte checking on warnings, invalid handler types, and legacy reactivity", () => { + const root = fileURLToPath(new URL("../../", import.meta.url)); + const workspace = mkdtempSync(join(tmpdir(), "quickadd-svelte-check-")); + try { + symlinkSync(join(root, "node_modules"), join(workspace, "node_modules"), process.platform === "win32" ? "junction" : "dir"); + copyFileSync(join(root, "svelte.config.mjs"), join(workspace, "svelte.config.mjs")); + writeFileSync(join(workspace, "tsconfig.json"), JSON.stringify({ compilerOptions: { target: "ES2022", module: "ESNext", moduleResolution: "bundler", lib: ["DOM", "ES2022"], strict: true, skipLibCheck: true }, include: ["*.svelte"] })); + writeFileSync(join(workspace, "accessibility.svelte"), "
{}}>X
"); + writeFileSync(join(workspace, "unused-css.svelte"), "

X

"); + writeFileSync(join(workspace, "handler.svelte"), ''); + writeFileSync(join(workspace, "legacy.svelte"), "{n}"); + const manifest: { scripts: { check: string } } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + const result = spawnSync(process.execPath, [join(root, "node_modules/svelte-check/bin/svelte-check"), ...manifest.scripts.check.split(" ").slice(1), "--output", "machine-verbose"], { cwd: workspace, encoding: "utf8", timeout: 30_000 }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(1); + for (const filename of ["accessibility.svelte", "unused-css.svelte", "handler.svelte", "legacy.svelte"]) expect(result.stdout).toContain(filename); + expect(result.stdout).toContain("a11y_click_events_have_key_events"); + expect(result.stdout).toContain("css_unused_selector"); + expect(result.stdout).toContain("legacy_reactive_statement_invalid"); + expect(result.stdout).toContain("2322"); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }, 30_000); +}); diff --git a/tooling/README.md b/tooling/README.md new file mode 100644 index 000000000..f25f08944 --- /dev/null +++ b/tooling/README.md @@ -0,0 +1,109 @@ +# Tooling checks + +`pnpm run lint` runs Oxlint, `svelte-policy.mjs`, and +`svelte-check --tsgo --fail-on-warnings`. `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. + +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 +Oxlint rules execute natively. The TypeScript 6 API package remains for tools +that need it, while compilation and Svelte checking use the native compiler. + +All current components use Svelte 5 runes. Svelte checking now requires runes +mode, and the policy checker rejects `$:` statements and `svelte/store` +imports. Shared `.svelte.ts` modules receive the same import restriction in +Oxlint. This prevents legacy reactive statements and store usage from +returning, rather than maintaining the old store and legacy-reactivity rules. + +## Svelte rule migration + +This migration preserves the custom guards and the listed safety policies. +It does not reproduce every heuristic in `eslint-plugin-svelte/recommended`. +Compiler and type diagnostics are authoritative for syntax, event-handler +types, and accessibility. CSS style suggestions and refactoring suggestions +listed as retired below now depend on review. + +| Former rule | Replacement or disposition | +| --- | --- | +| `comment-directive` | Removed with ESLint's suppression system. Svelte compiler suppressions remain. | +| `system` | Removed with ESLint's parser and processor. | +| `valid-compile` | Strict Svelte checking fails on compiler errors and warnings, including accessibility and unused CSS. | +| `infinite-reactive-loop` | Legacy reactive statements are rejected. This does not statically prove that every `$effect` terminates. | +| `no-immutable-reactive-statements` | Legacy reactive statements are rejected. | +| `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-at-html-tags` | Compiler-AST policy rejects `{@html}`. | +| `require-each-key` | Compiler-AST policy requires a key for every each block. | +| `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-dom-manipulating` | Compiler-AST policy rejects structural or text changes to named `bind:this` elements and allows local shadowing. It does not follow arbitrary aliases. | +| `no-dupe-else-if-blocks` | Compiler-AST policy checks duplicate and covered conditions in else-if chains. | +| `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-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. | +| `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. | +| `no-dupe-style-properties` | Retired. The compiler still rejects duplicate style directives; it does not check conflicts with a style string. | +| `no-shorthand-style-property-overrides` | Retired. CSS shorthand conflicts require review. | +| `no-unknown-style-directive-property` | Retired. CSS property spelling requires review. | +| `no-unnecessary-state-wrap` | Retired refactoring suggestion. | +| `no-useless-children-snippet` | Retired refactoring suggestion. | +| `no-useless-mustaches` | Retired style suggestion. | +| `prefer-writable-derived` | Retired refactoring suggestion. | +| `prefer-svelte-reactivity` | Was disabled before the migration. Immutable Set and Map reassignment remains allowed. | +| `no-export-load-in-svelte-module-in-kit-pages` | Inapplicable. QuickAdd is not a SvelteKit app. | +| `no-navigation-without-resolve` | Inapplicable. QuickAdd is not a SvelteKit app. | +| `valid-prop-names-in-kit-pages` | Inapplicable. QuickAdd is not a SvelteKit app. | + +The component policy checker also applies `no-console`, `no-new-func`, and +literal string-timer guards to template expressions. These expressions are +outside Oxlint's extracted scripts. Type-aware promise, deprecation, and +implied-eval checks still run on TypeScript through Oxlint, with the existing +test and user-script exceptions. + +## 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. + +```sh +pnpm exec vitest run tests/tooling/sveltePolicy.test.ts +pnpm run build-with-lint +pnpm run test +``` + +## Follow-up refinements + +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. +- Remove the TypeScript 6 compatibility package when its consumers support + the native compiler's APIs. +- Measure the final verification pipeline. Earlier evaluation timings predate + the custom-rule port and the removal of ESLint. diff --git a/tooling/oxlint-plugin.mjs b/tooling/oxlint-plugin.mjs new file mode 100644 index 000000000..fb051c33e --- /dev/null +++ b/tooling/oxlint-plugin.mjs @@ -0,0 +1,97 @@ +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 svelteImportGuards = [ + { + selector: ':matches(ImportDeclaration, ImportExpression, ExportNamedDeclaration, ExportAllDeclaration)[source.value=/^svelte\\/internal(\\/|$)/]', + message: "Use Svelte's public API instead of svelte/internal.", + }, + { + 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 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-runes": { + meta: { type: "problem", schema: [] }, + create(context) { + return createGuards(context, svelteImportGuards); + }, + }, + "svelte-debug": { + meta: { type: "problem", schema: [] }, + create(context) { + return createGuards(context, svelteDebugGuards); + }, + }, + }, +}; diff --git a/tooling/svelte-policy.mjs b/tooling/svelte-policy.mjs new file mode 100644 index 000000000..05f488fd8 --- /dev/null +++ b/tooling/svelte-policy.mjs @@ -0,0 +1,217 @@ +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 specialElements = new Set(["head", "body", "window", "document", "element", "options"]); +const ignoredKeys = new Set(["loc", "name_loc", "metadata", "css"]); + +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; + visit(node, ancestors); + 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": return patternNames(node.argument); + case "AssignmentPattern": return patternNames(node.left); + case "ArrayPattern": return node.elements.flatMap(patternNames); + case "ObjectPattern": return node.properties.flatMap(property => patternNames(property.type === "RestElement" ? 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 (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 isShadowed(name, ancestors) { + for (const ancestor of ancestors) { + if (ancestor.params?.some(param => patternNames(param).includes(name))) return true; + if (ancestor.type === "CatchClause" && patternNames(ancestor.param).includes(name)) return true; + if (ancestor.type === "EachBlock" && (ancestor.index === name || patternNames(ancestor.context).includes(name))) return true; + if (["ForStatement", "ForInStatement", "ForOfStatement"].includes(ancestor.type)) { + const declaration = ancestor.init ?? ancestor.left; + if (declaration?.type === "VariableDeclaration" && declaration.declarations.some(item => patternNames(item.id).includes(name))) return true; + } + if (ancestor.type === "BlockStatement" && ancestor.body.some(statement => + statement.type === "VariableDeclaration" && statement.declarations.some(item => patternNames(item.id).includes(name)) + || ["FunctionDeclaration", "ClassDeclaration"].includes(statement.type) && statement.id?.name === name + )) return true; + } + return false; +} + +function propertyName(node) { + return node?.computed ? node.property?.value : node?.property?.name; +} + +function fingerprint(node) { + if (node?.type === "LogicalExpression" && ["&&", "||"].includes(node.operator)) { + return `${node.operator}:${[fingerprint(node.left), fingerprint(node.right)].sort().join(":")}`; + } + return JSON.stringify(node, (name, value) => ["start", "end", "loc", "raw"].includes(name) ? undefined : value); +} + +function splitCondition(node, operator) { + return node.type === "LogicalExpression" && node.operator === operator + ? [...splitCondition(node.left, operator), ...splitCondition(node.right, operator)] + : [node]; +} + +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; + } + 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(fingerprint)); + for (const condition of previous) { + const priorTerms = splitCondition(condition, "||").map(term => splitCondition(term, "&&").map(fingerprint)); + 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 boundElements = new Set(); + walk(ast.fragment, node => { + if (!["RegularElement", "SvelteElement"].includes(node.type)) return; + for (const attribute of node.attributes) { + if (attribute.type === "BindDirective" && attribute.name === "this" && attribute.expression?.type === "Identifier") boundElements.add(attribute.expression.name); + } + }); + walk(ast, (node, ancestors) => { + const [parent] = ancestors; + 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}:${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.has(node.object.name) && !isShadowed(node.object.name, ancestors)) { + 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" && !["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") report(node, "no-new-func", "Do not construct code from strings."); + if (node.type === "CallExpression" && ["setTimeout", "setInterval", "execScript"].includes(node.callee.name ?? propertyName(node.callee)) && ["Literal", "TemplateLiteral"].includes(node.arguments[0]?.type)) 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; +} diff --git a/tsconfig.json b/tsconfig.json index d13370a8f..79f4c9c64 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,5 +22,5 @@ "verbatimModuleSyntax": true }, "include": ["**/*.ts"], - "exclude": ["node_modules", "docs"] + "exclude": ["node_modules", "docs", ".svelte-check"] } From 84e976a3fb0b31945fc15b88c5176819de10a7ac Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 12 Oct 2026 13:03:33 +0000 Subject: [PATCH 2/7] fix(tooling): respect expression scope and condition effects --- tests/tooling/sveltePolicy.test.ts | 13 +++++++++++++ tooling/README.md | 2 +- tooling/svelte-policy.mjs | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/tooling/sveltePolicy.test.ts b/tests/tooling/sveltePolicy.test.ts index bdb01ecf1..786d018bd 100644 --- a/tests/tooling/sveltePolicy.test.ts +++ b/tests/tooling/sveltePolicy.test.ts @@ -111,12 +111,16 @@ describe("Svelte template policies", () => { ["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}"], ["no-dom-manipulating", '
'], ["no-dom-manipulating", "
"], ["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-object-in-text-mustaches", "

{{name: 'value'}}

"], ["no-raw-special-elements", ""], ["no-at-debug-tags", "{@debug value}"], @@ -139,11 +143,20 @@ describe("Svelte template policies", () => { "{#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}", "{#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}", "", "
", "
", + "
", "
", "", "", diff --git a/tooling/README.md b/tooling/README.md index f25f08944..e7423d6c0 100644 --- a/tooling/README.md +++ b/tooling/README.md @@ -49,7 +49,7 @@ listed as retired below now depend on review. | `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-dom-manipulating` | Compiler-AST policy rejects structural or text changes to named `bind:this` elements and allows local shadowing. It does not follow arbitrary aliases. | -| `no-dupe-else-if-blocks` | Compiler-AST policy checks duplicate and covered conditions in else-if chains. | +| `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. | diff --git a/tooling/svelte-policy.mjs b/tooling/svelte-policy.mjs index 05f488fd8..87b702c3d 100644 --- a/tooling/svelte-policy.mjs +++ b/tooling/svelte-policy.mjs @@ -48,6 +48,7 @@ function references(node) { 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; @@ -58,6 +59,7 @@ function references(node) { function isShadowed(name, ancestors) { for (const ancestor of ancestors) { + if (["FunctionExpression", "ClassExpression"].includes(ancestor.type) && ancestor.id?.name === name) return true; if (ancestor.params?.some(param => patternNames(param).includes(name))) return true; if (ancestor.type === "CatchClause" && patternNames(ancestor.param).includes(name)) return true; if (ancestor.type === "EachBlock" && (ancestor.index === name || patternNames(ancestor.context).includes(name))) return true; @@ -90,6 +92,17 @@ function splitCondition(node, 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 = []; @@ -99,6 +112,7 @@ function coveredCondition(node, ancestors) { 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 => { From 4bb67df3b4b9cfac357610777a909fbb01117321 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 12 Oct 2026 13:17:01 +0000 Subject: [PATCH 3/7] fix(tooling): distinguish local bindings from browser globals --- tests/tooling/sveltePolicy.test.ts | 28 +++++++++++++++++++ tooling/README.md | 8 +++--- tooling/svelte-policy.mjs | 43 ++++++++++++++++++++++++------ 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/tests/tooling/sveltePolicy.test.ts b/tests/tooling/sveltePolicy.test.ts index 786d018bd..33087de50 100644 --- a/tests/tooling/sveltePolicy.test.ts +++ b/tests/tooling/sveltePolicy.test.ts @@ -131,8 +131,16 @@ describe("Svelte template policies", () => { ["restricted-syntax", ''], ["restricted-syntax", ''], ["no-console", ""], + ["no-console", ""], + ["no-console", "{#await value then console}

{console.log('X')}

{:catch error}

{console.log('X')}

{/await}"], ["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
"], ]; @@ -160,6 +168,26 @@ describe("Svelte template policies", () => { "
", "", "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "{#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 => { diff --git a/tooling/README.md b/tooling/README.md index e7423d6c0..5614c0d81 100644 --- a/tooling/README.md +++ b/tooling/README.md @@ -73,9 +73,11 @@ listed as retired below now depend on review. The component policy checker also applies `no-console`, `no-new-func`, and literal string-timer guards to template expressions. These expressions are -outside Oxlint's extracted scripts. Type-aware promise, deprecation, and -implied-eval checks still run on TypeScript through Oxlint, with the existing -test and user-script exceptions. +outside Oxlint's extracted scripts. These guards respect local script and +template bindings. String-timer checks apply to global timer functions and +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. ## Verification diff --git a/tooling/svelte-policy.mjs b/tooling/svelte-policy.mjs index 87b702c3d..dc88d448d 100644 --- a/tooling/svelte-policy.mjs +++ b/tooling/svelte-policy.mjs @@ -15,6 +15,8 @@ const domMethods = new Set([ "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"]); @@ -57,20 +59,33 @@ function references(node) { return names; } +function declaresName(statement, name) { + const declaration = statement.declaration ?? statement; + switch (declaration.type) { + case "VariableDeclaration": return 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 isShadowed(name, ancestors) { for (const ancestor of ancestors) { if (["FunctionExpression", "ClassExpression"].includes(ancestor.type) && ancestor.id?.name === name) return true; if (ancestor.params?.some(param => patternNames(param).includes(name))) return true; if (ancestor.type === "CatchClause" && patternNames(ancestor.param).includes(name)) return true; if (ancestor.type === "EachBlock" && (ancestor.index === name || patternNames(ancestor.context).includes(name))) return true; + if (ancestor.type === "SnippetBlock" && ancestor.parameters.some(param => patternNames(param).includes(name))) return true; + if (ancestor.type === "AwaitBlock" && ancestors.includes(ancestor.then) && patternNames(ancestor.value).includes(name)) return true; + if (ancestor.type === "AwaitBlock" && ancestors.includes(ancestor.catch) && patternNames(ancestor.error).includes(name)) return true; if (["ForStatement", "ForInStatement", "ForOfStatement"].includes(ancestor.type)) { const declaration = ancestor.init ?? ancestor.left; if (declaration?.type === "VariableDeclaration" && declaration.declarations.some(item => patternNames(item.id).includes(name))) return true; } - if (ancestor.type === "BlockStatement" && ancestor.body.some(statement => - statement.type === "VariableDeclaration" && statement.declarations.some(item => patternNames(item.id).includes(name)) - || ["FunctionDeclaration", "ClassDeclaration"].includes(statement.type) && statement.id?.name === name - )) return true; + if (["BlockStatement", "Program"].includes(ancestor.type) && ancestor.body.some(statement => declaresName(statement, name))) return true; + if (ancestor.type === "Fragment" && ancestor.nodes.some(statement => declaresName(statement, name))) return true; } return false; } @@ -79,6 +94,16 @@ 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) { if (node?.type === "LogicalExpression" && ["&&", "||"].includes(node.operator)) { return `${node.operator}:${[fingerprint(node.left), fingerprint(node.right)].sort().join(":")}`; @@ -168,6 +193,7 @@ export function checkSvelte(source, filename = "Component.svelte") { return diagnostics; } checkIgnores(ast, source, filename, report); + const componentScopes = [ast.instance?.content, ast.module?.content].filter(Boolean); const boundElements = new Set(); walk(ast.fragment, node => { if (!["RegularElement", "SvelteElement"].includes(node.type)) return; @@ -177,6 +203,7 @@ export function checkSvelte(source, filename = "Component.svelte") { }); walk(ast, (node, ancestors) => { const [parent] = ancestors; + const globalScopes = ancestors.includes(ast.module?.content) ? ancestors : [...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"); } @@ -202,15 +229,15 @@ export function checkSvelte(source, filename = "Component.svelte") { directives.add(key); } } - if (node.type === "MemberExpression" && node.object.type === "Identifier" && boundElements.has(node.object.name) && !isShadowed(node.object.name, ancestors)) { + if (node.type === "MemberExpression" && node.object.type === "Identifier" && boundElements.has(node.object.name) && !isShadowed(node.object.name, ancestors.filter(ancestor => ancestor.type !== "Program"))) { 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" && !["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") report(node, "no-new-func", "Do not construct code from strings."); - if (node.type === "CallExpression" && ["setTimeout", "setInterval", "execScript"].includes(node.callee.name ?? propertyName(node.callee)) && ["Literal", "TemplateLiteral"].includes(node.arguments[0]?.type)) report(node, "no-implied-eval", "Pass a callback instead of a code string."); + 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; } From b59a25242c3350b8aebe58bb518861cbe97032fe Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 12 Oct 2026 13:22:22 +0000 Subject: [PATCH 4/7] build: require runes mode in production Svelte compilation --- svelteCompilerOptions.mjs | 1 + tests/svelteCompilerOptions.test.ts | 8 ++++++++ tooling/README.md | 10 +++++----- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/svelteCompilerOptions.mjs b/svelteCompilerOptions.mjs index c9cf5e912..8413c83dc 100644 --- a/svelteCompilerOptions.mjs +++ b/svelteCompilerOptions.mjs @@ -18,6 +18,7 @@ import { VERSION } from "svelte/compiler"; // which Obsidian replaces on update. /** @type {import("svelte/compiler").CompileOptions} */ export const svelteCompilerOptions = { + runes: true, css: "injected", cssHash: ({ hash, filename }) => `svelte-${hash(filename + VERSION + readFileSync(filename, "utf8"))}`, diff --git a/tests/svelteCompilerOptions.test.ts b/tests/svelteCompilerOptions.test.ts index ee806ad50..ef9c18c23 100644 --- a/tests/svelteCompilerOptions.test.ts +++ b/tests/svelteCompilerOptions.test.ts @@ -37,6 +37,14 @@ describe("production Svelte compiler options", () => { return { styleId: injected[1], css: injected[2] }; } + it("rejects legacy reactivity in the production compiler", () => { + const source = "

{count}

"; + writeFileSync(filename, source); + expect(() => compile(source, { ...svelteCompilerOptions, filename })).toThrowError( + expect.objectContaining({ code: "legacy_reactive_statement_invalid" }), + ); + }); + it("gives a component a new style id when its CSS changes", () => { const before = build(`

Hi

`); const after = build(`

Hi

`); diff --git a/tooling/README.md b/tooling/README.md index 5614c0d81..01b6ddde2 100644 --- a/tooling/README.md +++ b/tooling/README.md @@ -16,11 +16,11 @@ The dependency is pinned. The selectors execute in JavaScript, while built-in Oxlint rules execute natively. The TypeScript 6 API package remains for tools that need it, while compilation and Svelte checking use the native compiler. -All current components use Svelte 5 runes. Svelte checking now requires runes -mode, and the policy checker rejects `$:` statements and `svelte/store` -imports. Shared `.svelte.ts` modules receive the same import restriction in -Oxlint. This prevents legacy reactive statements and store usage from -returning, rather than maintaining the old store and legacy-reactivity rules. +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 +store usage from returning. ## Svelte rule migration From 6d0b30effd85d70399d05e8fa442d1435a205bc1 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 12 Oct 2026 13:51:40 +0000 Subject: [PATCH 5/7] fix(tooling): resolve Svelte bindings by owning scope --- tests/tooling/sveltePolicy.test.ts | 22 +++++++++ tooling/README.md | 2 +- tooling/svelte-policy.mjs | 79 +++++++++++++++++++++--------- 3 files changed, 80 insertions(+), 23 deletions(-) diff --git a/tests/tooling/sveltePolicy.test.ts b/tests/tooling/sveltePolicy.test.ts index 33087de50..248846c39 100644 --- a/tests/tooling/sveltePolicy.test.ts +++ b/tests/tooling/sveltePolicy.test.ts @@ -114,8 +114,10 @@ describe("Svelte template policies", () => { ["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-else-if-blocks", "{#if a || b}A{:else if b && c}B{/if}"], @@ -133,6 +135,15 @@ describe("Svelte template policies", () => { ["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", ""], @@ -152,6 +163,7 @@ describe("Svelte template policies", () => { "{#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}", @@ -165,6 +177,7 @@ describe("Svelte template policies", () => { "
", "
", "
", + "
", "
", "", "", @@ -172,6 +185,15 @@ describe("Svelte template policies", () => { "", "", "", + "", + "", + "", + "", + "", + "

{console.log('X')}

", + "

{console.log('X')}

", + "

{console.log('X')}

", + "", "", "", "", diff --git a/tooling/README.md b/tooling/README.md index 01b6ddde2..fc8a30df9 100644 --- a/tooling/README.md +++ b/tooling/README.md @@ -48,7 +48,7 @@ listed as retired below now depend on review. | `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-dom-manipulating` | Compiler-AST policy rejects structural or text changes to named `bind:this` elements and allows local shadowing. It does not follow arbitrary aliases. | +| `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. | diff --git a/tooling/svelte-policy.mjs b/tooling/svelte-policy.mjs index dc88d448d..54a276442 100644 --- a/tooling/svelte-policy.mjs +++ b/tooling/svelte-policy.mjs @@ -19,6 +19,8 @@ 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; @@ -27,7 +29,7 @@ function walk(node, visit, ancestors = []) { return; } if (typeof node.type !== "string") return; - visit(node, ancestors); + if (visit(node, ancestors) === false) return; for (const [key, child] of Object.entries(node)) { if (!ignoredKeys.has(key)) walk(child, visit, [node, ...ancestors]); } @@ -37,10 +39,13 @@ function patternNames(node) { if (!node) return []; switch (node.type) { case "Identifier": return [node.name]; - case "RestElement": return patternNames(node.argument); + case "RestElement": + case "SpreadElement": return patternNames(node.argument); case "AssignmentPattern": return patternNames(node.left); - case "ArrayPattern": return node.elements.flatMap(patternNames); - case "ObjectPattern": return node.properties.flatMap(property => patternNames(property.type === "RestElement" ? property.argument : property.value)); + 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 []; } } @@ -62,7 +67,7 @@ function references(node) { function declaresName(statement, name) { const declaration = statement.declaration ?? statement; switch (declaration.type) { - case "VariableDeclaration": return declaration.declarations.some(item => patternNames(item.id).includes(name)); + 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); @@ -71,23 +76,49 @@ function declaresName(statement, name) { } } -function isShadowed(name, ancestors) { +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 true; - if (ancestor.params?.some(param => patternNames(param).includes(name))) return true; - if (ancestor.type === "CatchClause" && patternNames(ancestor.param).includes(name)) return true; - if (ancestor.type === "EachBlock" && (ancestor.index === name || patternNames(ancestor.context).includes(name))) return true; - if (ancestor.type === "SnippetBlock" && ancestor.parameters.some(param => patternNames(param).includes(name))) return true; - if (ancestor.type === "AwaitBlock" && ancestors.includes(ancestor.then) && patternNames(ancestor.value).includes(name)) return true; - if (ancestor.type === "AwaitBlock" && ancestors.includes(ancestor.catch) && patternNames(ancestor.error).includes(name)) return true; + 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" && declaration.declarations.some(item => patternNames(item.id).includes(name))) return true; + if (declaration?.type === "VariableDeclaration" && declaresName(declaration, name)) return ancestor; } - if (["BlockStatement", "Program"].includes(ancestor.type) && ancestor.body.some(statement => declaresName(statement, name))) return true; - if (ancestor.type === "Fragment" && ancestor.nodes.some(statement => declaresName(statement, name))) return true; + 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 false; + return undefined; +} + +function isShadowed(name, ancestors) { + return bindingScope(name, ancestors) !== undefined; } function propertyName(node) { @@ -194,16 +225,20 @@ export function checkSvelte(source, filename = "Component.svelte") { } checkIgnores(ast, source, filename, report); const componentScopes = [ast.instance?.content, ast.module?.content].filter(Boolean); - const boundElements = new Set(); - walk(ast.fragment, node => { + 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") boundElements.add(attribute.expression.name); + 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) ? ancestors : [...ancestors, ...componentScopes]; + 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"); } @@ -229,7 +264,7 @@ export function checkSvelte(source, filename = "Component.svelte") { directives.add(key); } } - if (node.type === "MemberExpression" && node.object.type === "Identifier" && boundElements.has(node.object.name) && !isShadowed(node.object.name, ancestors.filter(ancestor => ancestor.type !== "Program"))) { + 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; From 074f57e8db5bfbb375903ee2a5e481c7fd5c94ed Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 12 Oct 2026 13:58:05 +0000 Subject: [PATCH 6/7] fix(tooling): preserve directive handler identity --- tests/tooling/sveltePolicy.test.ts | 6 ++++++ tooling/svelte-policy.mjs | 14 +++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/tooling/sveltePolicy.test.ts b/tests/tooling/sveltePolicy.test.ts index 248846c39..283e503b1 100644 --- a/tests/tooling/sveltePolicy.test.ts +++ b/tests/tooling/sveltePolicy.test.ts @@ -120,6 +120,8 @@ describe("Svelte template policies", () => { ["no-dom-manipulating", "
"], ["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}"], @@ -174,6 +176,10 @@ describe("Svelte template policies", () => { "{#if a < b}A{:else if a < b}B{/if}", "{#if a}A{:else if next()}B{:else if a}C{/if}", "", + "", + "", + "", + "
", "
", "
", "
", diff --git a/tooling/svelte-policy.mjs b/tooling/svelte-policy.mjs index 54a276442..d946b219c 100644 --- a/tooling/svelte-policy.mjs +++ b/tooling/svelte-policy.mjs @@ -136,10 +136,14 @@ function globalTimer(callee, ancestors) { } function fingerprint(node) { + return JSON.stringify(node, (name, value) => ["start", "end", "loc", "raw"].includes(name) ? undefined : value); +} + +function conditionFingerprint(node) { if (node?.type === "LogicalExpression" && ["&&", "||"].includes(node.operator)) { - return `${node.operator}:${[fingerprint(node.left), fingerprint(node.right)].sort().join(":")}`; + return `${node.operator}:${[conditionFingerprint(node.left), conditionFingerprint(node.right)].sort().join(":")}`; } - return JSON.stringify(node, (name, value) => ["start", "end", "loc", "raw"].includes(name) ? undefined : value); + return fingerprint(node); } function splitCondition(node, operator) { @@ -172,9 +176,9 @@ function coveredCondition(node, ancestors) { 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(fingerprint)); + let terms = splitCondition(candidate, "||").map(term => splitCondition(term, "&&").map(conditionFingerprint)); for (const condition of previous) { - const priorTerms = splitCondition(condition, "||").map(term => splitCondition(term, "&&").map(fingerprint)); + 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; @@ -259,7 +263,7 @@ export function checkSvelte(source, filename = "Component.svelte") { const directives = new Set(); for (const attribute of node.attributes) { if (!["OnDirective", "UseDirective"].includes(attribute.type)) continue; - const key = `${attribute.type}:${attribute.name}:${fingerprint(attribute.expression)}`; + 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); } From c7ec79f27940bfba74454a0c289d137f6e75c231 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 12 Oct 2026 14:06:13 +0000 Subject: [PATCH 7/7] fix(tooling): serialize BigInt expression literals --- tests/tooling/sveltePolicy.test.ts | 6 ++++++ tooling/svelte-policy.mjs | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/tooling/sveltePolicy.test.ts b/tests/tooling/sveltePolicy.test.ts index 283e503b1..f41362796 100644 --- a/tests/tooling/sveltePolicy.test.ts +++ b/tests/tooling/sveltePolicy.test.ts @@ -122,9 +122,11 @@ describe("Svelte template policies", () => { ["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}"], @@ -180,6 +182,10 @@ describe("Svelte template policies", () => { "", "", "
", + "", + "", + "", + "{#if value === 1n}A{:else if value === 1}B{/if}", "
", "
", "
", diff --git a/tooling/svelte-policy.mjs b/tooling/svelte-policy.mjs index d946b219c..0ed63304d 100644 --- a/tooling/svelte-policy.mjs +++ b/tooling/svelte-policy.mjs @@ -136,7 +136,10 @@ function globalTimer(callee, ancestors) { } function fingerprint(node) { - return JSON.stringify(node, (name, value) => ["start", "end", "loc", "raw"].includes(name) ? undefined : value); + 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) {