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
{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
`); @@ -65,9 +72,9 @@ describe("production Svelte compiler options", () => { describe("injected component CSS", () => { /** Selectors in a component's production CSS that lack its scoped class. */ async function unscopedSelectors(filename: string): Promise{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", "{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')}
X
"], + ["no-unused-svelte-ignore", "{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}", + "{console.log('X')}
{console.log('X')}
{console.log('X')}
{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}", + "", + "", + "", + "", + "", + "", + "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(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, "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..fc8a30df9 --- /dev/null +++ b/tooling/README.md @@ -0,0 +1,111 @@ +# 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 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 + +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 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-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. 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 + +`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..0ed63304d --- /dev/null +++ b/tooling/svelte-policy.mjs @@ -0,0 +1,300 @@ +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