diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e4251641..049229437 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,6 @@ jobs: release-assets: '["main.js","manifest.json","styles.css"]' verify-commands: | pnpm run lint - pnpm run check pnpm run test notify-name: QuickAdd secrets: diff --git a/.gitignore b/.gitignore index d72db0a30..63aad240e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,6 @@ coverage/ # Obsidian application bundle (only ever extracted transiently for inspection) /app.js + +# Overlays from svelte-check --tsgo or --incremental runs .svelte-check/ diff --git a/.oxlintrc.syntax.json b/.oxlintrc.syntax.json index 14540652d..6afbef6f0 100644 --- a/.oxlintrc.syntax.json +++ b/.oxlintrc.syntax.json @@ -17,8 +17,7 @@ ".audit/**", ".obsidian-e2e-vaults/**", "main.js", - "**/*.d.ts", - ".svelte-check/**" + "**/*.d.ts" ], "overrides": [ { @@ -223,6 +222,14 @@ "quickadd/restricted-syntax": "off" } }, + { + "files": [ + "**/*.ts" + ], + "rules": { + "quickadd/svelte-internal": "error" + } + }, { "files": [ "**/*.svelte.ts" diff --git a/package.json b/package.json index 650079992..943868c8e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "lint": "pnpm run lint:native && pnpm run lint:svelte && pnpm run check", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "build-with-lint": "tsc -noEmit -skipLibCheck && pnpm lint && node esbuild.config.mjs production", - "check": "svelte-check --tsgo --fail-on-warnings", + "check": "svelte-check --fail-on-warnings", "packages:build": "node docs/packages/build.mjs", "obsidian:e2e": "obsidian-e2e run", "provision:e2e-vault": "obsidian-e2e provision", diff --git a/tests/tooling/svelteCheck.test.ts b/tests/tooling/svelteCheck.test.ts new file mode 100644 index 000000000..15be3ce5f --- /dev/null +++ b/tests/tooling/svelteCheck.test.ts @@ -0,0 +1,78 @@ +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import module from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { expect, it } from "vitest"; + +const root = fileURLToPath(new URL("../../", import.meta.url)); + +function tsconfig(include: string[]): string { + return JSON.stringify({ compilerOptions: { target: "ES2022", module: "ESNext", moduleResolution: "bundler", lib: ["DOM", "ES2022"], strict: true, skipLibCheck: true }, include }); +} + +function withWorkspace(files: Record, run: (workspace: string) => void): void { + 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")); + for (const [name, text] of Object.entries(files)) writeFileSync(join(workspace, name), text); + run(workspace); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } +} + +function expectCheckFails(workspace: string, env: NodeJS.ProcessEnv = process.env): string { + const { scripts }: { scripts: { check: string } } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + const [bin, ...args] = scripts.check.split(" "); + expect(bin).toBe("svelte-check"); + const result = spawnSync(process.execPath, [join(root, "node_modules/svelte-check/bin/svelte-check"), ...args, "--output", "machine-verbose"], { cwd: workspace, encoding: "utf8", env, timeout: 30_000 }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stdout + result.stderr).toBe(1); + return result.stdout; +} + +it("fails strict Svelte checking on warnings, invalid handler types, and legacy reactivity", () => { + withWorkspace({ + "tsconfig.json": tsconfig(["*.svelte"]), + "accessibility.svelte": "
{}}>X
", + "unused-css.svelte": "

X

", + "handler.svelte": '', + "legacy.svelte": "{n}", + }, workspace => { + const output = expectCheckFails(workspace); + for (const filename of ["accessibility.svelte", "unused-css.svelte", "handler.svelte", "legacy.svelte"]) expect(output).toContain(filename); + expect(output).toContain("a11y_click_events_have_key_events"); + expect(output).toContain("css_unused_selector"); + expect(output).toContain("legacy_reactive_statement_invalid"); + expect(output).toContain("2322"); + }); +}, 30_000); + +// module.registerHooks, which the preload below uses, arrived in Node 22.15. +it.skipIf(typeof module.registerHooks !== "function")("fails Svelte checking on a type error when the native TypeScript executable cannot run", () => { + withWorkspace({ + "tsconfig.json": tsconfig(["*.ts"]), + "entry.ts": 'export { default as Probe } from "./Probe.svelte";', + "Probe.svelte": '

{n}

', + // The native compiler's launcher resolves its platform package at startup, so this + // reproduces an install without that package in every Node process the check starts. + "no-native-typescript.mjs": [ + 'import { registerHooks } from "node:module";', + 'registerHooks({ resolve: (specifier, context, next) => { if (specifier.startsWith("@typescript/typescript-")) throw new Error(`Cannot find package ${specifier}`); return next(specifier, context); } });', + ].join("\n"), + }, workspace => { + const preload = `--import ${pathToFileURL(join(workspace, "no-native-typescript.mjs")).href}`; + const env = { ...process.env, NODE_OPTIONS: [process.env.NODE_OPTIONS, preload].filter(Boolean).join(" ") }; + + const nativeTsc = spawnSync(process.execPath, [join(root, "node_modules/@typescript/native/bin/tsc"), "--version"], { cwd: workspace, encoding: "utf8", env, timeout: 30_000 }); + expect(nativeTsc.status).not.toBe(0); + expect(nativeTsc.stderr).toContain("Unable to resolve @typescript/typescript-"); + + const output = expectCheckFails(workspace, env); + expect(output).toContain('"Probe.svelte"'); + expect(output).toContain('"code":2322'); + }); +}, 30_000); diff --git a/tests/tooling/sveltePolicy.test.ts b/tests/tooling/sveltePolicy.test.ts index f41362796..464102a2c 100644 --- a/tests/tooling/sveltePolicy.test.ts +++ b/tests/tooling/sveltePolicy.test.ts @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process"; -import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { fileURLToPath } from "node:url"; import { compile } from "svelte/compiler"; import { describe, expect, it } from "vitest"; @@ -57,6 +57,9 @@ describe("custom syntax guards without ESLint", () => { 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);'); + writeFileSync(join(workspace, "src/internal.ts"), 'import "svelte/internal/disclose-version";'); + writeFileSync(join(workspace, "src/internal.svelte.ts"), 'export * from "svelte/internal/client";'); + writeFileSync(join(workspace, "src/internal.test.ts"), 'export { mount } from "svelte/internal/client";'); 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); @@ -68,8 +71,9 @@ describe("custom syntax guards without ESLint", () => { 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-runes)").map(item => basename(item.filename))).toEqual(["state.svelte.ts"]); expect(output.diagnostics.filter(item => item.code === "quickadd(svelte-debug)")).toHaveLength(1); + expect(output.diagnostics.filter(item => item.code === "quickadd(svelte-internal)").map(item => basename(item.filename)).sort()).toEqual(["internal.svelte.ts", "internal.test.ts", "internal.ts"]); expect(output.diagnostics.filter(item => item.filename.includes("valid-") || item.filename.includes("excluded.test.ts"))).toEqual([]); } finally { rmSync(workspace, { recursive: true, force: true }); @@ -240,28 +244,4 @@ describe("Svelte template policies", () => { 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 index fc8a30df9..477713121 100644 --- a/tooling/README.md +++ b/tooling/README.md @@ -1,7 +1,7 @@ # Tooling checks `pnpm run lint` runs Oxlint, `svelte-policy.mjs`, and -`svelte-check --tsgo --fail-on-warnings`. `pnpm run build-with-lint` runs the +`svelte-check --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`. @@ -13,8 +13,9 @@ 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. +Oxlint rules execute natively. `tsc` uses the native compiler. svelte-check +uses the TypeScript 6 API package, because svelte-check 4.7.6 exits 0 under +`--tsgo` when the native compiler cannot start. All current components use Svelte 5 runes. Svelte checking and production compilation require runes mode. The policy checker rejects `$:` statements @@ -55,7 +56,7 @@ listed as retired below now depend on review. | `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-svelte-internal` | Shared selector rejects imports and re-exports from Svelte internals in components and all TypeScript files. | | `no-unused-svelte-ignore` | Compiler-AST policy compares suppressed warnings with compiler diagnostics for the target element. | | `no-inner-declarations` | Removed. Svelte scripts are modules, and block-scoped functions were already allowed. | | `no-unused-props` | Removed. This rule required TypeScript parser services that the remaining ESLint fallback did not enable. | @@ -85,10 +86,12 @@ through Oxlint, with the existing test and user-script exceptions. 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. +are private and removed after the test. `tests/tooling/svelteCheck.test.ts` +requires `pnpm run check`'s command to fail on compiler warnings, legacy +reactivity, and type errors, including when the native compiler cannot start. ```sh -pnpm exec vitest run tests/tooling/sveltePolicy.test.ts +pnpm exec vitest run tests/tooling/sveltePolicy.test.ts tests/tooling/svelteCheck.test.ts pnpm run build-with-lint pnpm run test ``` @@ -105,7 +108,7 @@ These refinements follow the tooling migration: - 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. +- Remove the TypeScript 6 compatibility package when svelte-check runs on the + native compiler's API and fails when that compiler cannot start. - 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 index fb051c33e..fb9d3a574 100644 --- a/tooling/oxlint-plugin.mjs +++ b/tooling/oxlint-plugin.mjs @@ -41,11 +41,14 @@ export const guards = [ } ]; -export const svelteImportGuards = [ +export const svelteInternalGuards = [ { selector: ':matches(ImportDeclaration, ImportExpression, ExportNamedDeclaration, ExportAllDeclaration)[source.value=/^svelte\\/internal(\\/|$)/]', message: "Use Svelte's public API instead of svelte/internal.", }, +]; + +export const svelteRunesGuards = [ { selector: ':matches(ImportDeclaration, ImportExpression, ExportNamedDeclaration, ExportAllDeclaration)[source.value="svelte/store"]', message: "Use Svelte runes for component state instead of legacy stores.", @@ -56,6 +59,8 @@ export const svelteImportGuards = [ }, ]; +export const svelteImportGuards = [...svelteInternalGuards, ...svelteRunesGuards]; + export const svelteDebugGuards = [ { selector: 'CallExpression:matches([callee.name="$inspect"], [callee.object.name="$inspect"])', @@ -81,10 +86,16 @@ export default { return createGuards(context, guards); }, }, + "svelte-internal": { + meta: { type: "problem", schema: [] }, + create(context) { + return createGuards(context, svelteInternalGuards); + }, + }, "svelte-runes": { meta: { type: "problem", schema: [] }, create(context) { - return createGuards(context, svelteImportGuards); + return createGuards(context, svelteRunesGuards); }, }, "svelte-debug": { diff --git a/tsconfig.json b/tsconfig.json index 79f4c9c64..d13370a8f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,5 +22,5 @@ "verbatimModuleSyntax": true }, "include": ["**/*.ts"], - "exclude": ["node_modules", "docs", ".svelte-check"] + "exclude": ["node_modules", "docs"] }