Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
11 changes: 9 additions & 2 deletions .oxlintrc.syntax.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@
".audit/**",
".obsidian-e2e-vaults/**",
"main.js",
"**/*.d.ts",
".svelte-check/**"
"**/*.d.ts"
],
"overrides": [
{
Expand Down Expand Up @@ -223,6 +222,14 @@
"quickadd/restricted-syntax": "off"
}
},
{
"files": [
"**/*.ts"
],
"rules": {
"quickadd/svelte-internal": "error"
}
},
{
"files": [
"**/*.svelte.ts"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
78 changes: 78 additions & 0 deletions tests/tooling/svelteCheck.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>, 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": "<div onclick={() => {}}>X</div>",
"unused-css.svelte": "<p>X</p><style>.unused { color: red; }</style>",
"handler.svelte": '<script lang="ts">const {handler}: {handler: number} = $props();</script><button onclick={handler}>X</button>',
"legacy.svelte": "<script>let n = 1; $: n = n + 1;</script>{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": '<script lang="ts">const n: number = "wrong";</script><p>{n}</p>',
// 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);
34 changes: 7 additions & 27 deletions tests/tooling/sveltePolicy.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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 });
Expand Down Expand Up @@ -240,28 +244,4 @@ describe("Svelte template policies", () => {
const unused = compile("<p>X</p><style>.unused { color: red; }</style>", { 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"), "<div onclick={() => {}}>X</div>");
writeFileSync(join(workspace, "unused-css.svelte"), "<p>X</p><style>.unused { color: red; }</style>");
writeFileSync(join(workspace, "handler.svelte"), '<script lang="ts">const {handler}: {handler: number} = $props();</script><button onclick={handler}>X</button>');
writeFileSync(join(workspace, "legacy.svelte"), "<script>let n = 1; $: n = n + 1;</script>{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);
});
19 changes: 11 additions & 8 deletions tooling/README.md
Original file line number Diff line number Diff line change
@@ -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`.

Expand All @@ -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
Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -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
```
Expand All @@ -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.
15 changes: 13 additions & 2 deletions tooling/oxlint-plugin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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"])',
Expand All @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@
"verbatimModuleSyntax": true
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "docs", ".svelte-check"]
"exclude": ["node_modules", "docs"]
}
Loading