svelte-shaker-engine-scan-native 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # svelte-shaker-engine-scan-native
2
+
3
+ Optional **native (napi) prop scanner** for svelte-shaker — the fast path behind
4
+ ESLint's [`svelte/no-useless-props`](https://github.com/sveltejs/eslint-plugin-svelte).
5
+
6
+ It parses every component with **rsvelte natively, in parallel (rayon)** and walks
7
+ rsvelte's **typed AST directly** — the full-AST `serde_json::Value` (the ~12 MB
8
+ template skeleton) is never built — so the whole-program scan no longer parses in
9
+ JS, ships a serialized AST across the JS boundary, or materializes the AST as JSON
10
+ at all.
11
+
12
+ ```
13
+ buildAnalyzeInputSync (JS crawl: resolve edges, read files)
14
+ │ { files: [{id, code}], edges } (JSON string)
15
+
16
+ scan() ── rayon ─▶ rsvelte parse ─▶ typed-AST walk (no Value)
17
+
18
+ ▼ { fileId: [{name, start, end}] } (JSON string, UTF-16 offsets)
19
+ ```
20
+
21
+ ### Two layers, both exhaustive
22
+
23
+ never-passed needs far less than a full model: `find_never_passed_props` only tests
24
+ `had_spread || explicit.contains(name)`, so a call site is just `{ had_spread,
25
+ explicit: set<name> }` — no literal values, value sets, defaults, `local`, or
26
+ shadow/debug names. So `scan` (`typed_scan.rs`) walks:
27
+
28
+ - The **template** over the typed `TemplateNode` enum. An exhaustive `match` makes a
29
+ forgotten node kind a COMPILE error — a node can never be silently skipped (which
30
+ could drop a call site or an escape and cause a false positive).
31
+ - **JS expressions** (the small instance `<script>` + each embedded template
32
+ expression) via rsvelte's `as_json()`, walked generically — `serde_json` visits
33
+ every child, so escape detection (the one analysis whose incompleteness would
34
+ cause a false positive) is provably exhaustive. rsvelte exposes no typed JS-AST
35
+ walker, and rsvelte_lint itself reads the JS AST as JSON; a hand-rolled per-variant
36
+ JS walker would risk exactly the missed-variant false positives this rule forbids.
37
+
38
+ `scan_via_value` keeps the Value-engine path (serialize each AST and run
39
+ `svelte_shaker_engine::find_never_passed_props`) as the differential **oracle** for
40
+ `scan` and as a drop-in fallback.
41
+
42
+ ## Soundness
43
+
44
+ The output is **pinned byte-for-byte to the JS engine**, two ways:
45
+
46
+ - `tests/native-never-passed.test.ts` pins `scan` (typed) against the TS
47
+ `findNeverPassedProps` — name **and** span, including non-ASCII source (UTF-16
48
+ remap), rename, namespace/barrel edges, spread, and body/snippet cases.
49
+ - The corpus benchmark pins `scan` (typed) === `scan_via_value` (oracle) === the JS
50
+ engine on the full flygate corpus (650 components): same 18 files, same 53 reports,
51
+ zero diffs. The oracle path reuses `svelte_shaker_engine::find_never_passed_props`,
52
+ which is itself pinned to the TS engine by the `wasm-never-passed` test.
53
+
54
+ A parse error on any file yields no model for it (it is silently skipped), so a
55
+ broken file can only ever make the scan **under-report**, never produce a false
56
+ positive.
57
+
58
+ ## Build
59
+
60
+ Local dev / tests build straight off cargo — the loader (`index.cjs`) finds the
61
+ `target/{release,debug}` cdylib automatically:
62
+
63
+ ```sh
64
+ cargo build --release # from this directory
65
+ ```
66
+
67
+ Distribution prebuilds use [`@napi-rs/cli`](https://napi.rs):
68
+
69
+ ```sh
70
+ pnpm build # napi build --platform --release --no-js
71
+ ```
72
+
73
+ ### rsvelte dependency
74
+
75
+ This crate depends on `rsvelte_core`, pinned in `Cargo.toml` to the exact **git
76
+ rev** the scanner is validated against. rsvelte is public, so `cargo` fetches it
77
+ with no credentials. (The CI workflow sets `CARGO_NET_GIT_FETCH_WITH_CLI=true` only
78
+ to skip rsvelte's private ecosystem-test submodules, which `rsvelte_core`'s build
79
+ doesn't use.)
80
+
81
+ For local dev against a side-by-side `rsvelte` checkout, override without editing
82
+ `Cargo.toml` via an uncommitted `.cargo/config.toml`:
83
+
84
+ ```toml
85
+ [patch."https://github.com/baseballyama/rsvelte"]
86
+ rsvelte_core = { path = "../../../../rsvelte/crates/rsvelte_core" }
87
+ ```
88
+
89
+ ## Publishing
90
+
91
+ The package bundles every platform's `.node` and is published by the
92
+ `prebuild-native-scanner.yml` workflow (build matrix → one `npm publish`). rsvelte is
93
+ public and publishing is **tokenless via npm Trusted Publishers (OIDC)** — no repo
94
+ secret at all, exactly like the main `svelte-shaker` release.
95
+
96
+ **One-time bootstrap** (Trusted Publishers can only be configured on a package that
97
+ already exists): publish `0.1.0` once manually from a local checkout, then register
98
+ the repo + workflow as a Trusted Publisher.
99
+
100
+ ```sh
101
+ # from packages/svelte-shaker/engine-scan-native, logged in to npm:
102
+ pnpm install --ignore-workspace
103
+ pnpm exec napi build --platform --release --no-js --output-dir . # builds this host's .node
104
+ npm publish --access public # publishes 0.1.0
105
+ # then: npmjs.com/package/svelte-shaker-engine-scan-native/access -> add Trusted Publisher
106
+ # (repo baseballyama/svelte-shaker, workflow prebuild-native-scanner.yml)
107
+ ```
108
+
109
+ (The bootstrap publish only contains this host's binary; the next workflow run
110
+ republishes a bumped version with all 5 platforms.) From then on, run the workflow
111
+ (`workflow_dispatch` with `publish: true`, or push a
112
+ `svelte-shaker-engine-scan-native@<version>` tag) and it publishes tokenlessly.
113
+
114
+ Consumers get the speedup automatically once it is installed (e.g. as an optional
115
+ dependency); the ESLint rule loads it when present and falls back to the JS/WASM
116
+ engine otherwise.
117
+
118
+ ## Performance
119
+
120
+ On the flygate corpus (650 components, Apple Silicon, release build), warm, full
121
+ `scan` (input JSON in -> report JSON out):
122
+
123
+ | path | median | min |
124
+ | --------------------------- | ------ | ------ |
125
+ | **`scan` (typed, default)** | **~57 ms** | **~49 ms** |
126
+ | `scan_via_value` (oracle) | ~297 ms | ~277 ms |
127
+
128
+ vs. the JS path (svelte/compiler parse + JS analyze) at ~680 ms. The typed path is
129
+ ~5× faster than the Value path and hits the ~50 ms target, with byte-identical
130
+ results. (`scanProfile()` returns the typed-vs-Value split.)
131
+
132
+ The escape-detection parent-context subtlety — a top-level `{X}` counts as a
133
+ value-use only because its parent is an `ExpressionTag` — is handled in
134
+ `expression_escapes`: each embedded expression is walked with its root treated as a
135
+ value position (it always sits in one in the template), matching the engine's
136
+ whole-tree walk. The corpus oracle confirms this is exact.
137
+
138
+ ## Resident daemon (`ScanDaemon`) — incremental re-scan
139
+
140
+ For an editor / LSP, parsing dominates a scan (~37 ms of the ~41 ms), so the daemon
141
+ caches each file's lightweight model (props, escapes, call sites — no AST) and
142
+ re-parses only what changed:
143
+
144
+ ```js
145
+ const d = new addon.ScanDaemon();
146
+ d.init(JSON.stringify({ files, edges })); // full scan once at startup
147
+ // on edit — pass only the changed files + the full current edges:
148
+ d.update(JSON.stringify({ files: [changed], edges, removed: [deletedId] }));
149
+ ```
150
+
151
+ A single-file edit re-scans in **~1.3 ms** (vs ~41 ms cold) on the 650-component
152
+ corpus, and the result is **byte-identical to a cold `scan`** (`init === scan`,
153
+ `update === scan(edited)` — pinned by `tests/native-daemon.test.ts`). It is sound to
154
+ re-parse only the changed files because a file's edges (`from == id`) derive solely
155
+ from its own imports, so an unchanged file's model can never go stale. Output keys
156
+ are sorted by file id, so cold and incremental scans agree exactly.
package/index.cjs ADDED
@@ -0,0 +1,87 @@
1
+ // Loader for the native prop-scanner addon.
2
+ //
3
+ // Resolves the N-API binary across three layouts, in order:
4
+ // 1. A prebuilt, platform-named `.node` next to this file (the PUBLISHED
5
+ // layout produced by `@napi-rs/cli`, e.g. `*.darwin-arm64.node`).
6
+ // 2. Any `*.node` next to this file (defensive: a differently-named prebuild).
7
+ // 3. The in-repo `cargo build` output under `target/{release,debug}` — copied
8
+ // to a `.node` alongside this loader on demand — so tests and local dev work
9
+ // straight off a `cargo build` with no `napi build` / publish step.
10
+ //
11
+ // The single export is `{ scan }`, matching the `#[napi] fn scan` in `src/lib.rs`.
12
+
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+
16
+ const { platform, arch } = process;
17
+
18
+ /** The bare cargo cdylib filename for this platform (renamed to `.node` for require). */
19
+ const DYLIB = {
20
+ darwin: 'libsvelte_shaker_engine_scan_native.dylib',
21
+ linux: 'libsvelte_shaker_engine_scan_native.so',
22
+ win32: 'svelte_shaker_engine_scan_native.dll',
23
+ }[platform];
24
+
25
+ function tryRequire(p) {
26
+ try {
27
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
28
+ return require(p);
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function loadAddon() {
35
+ // 1) Prebuilt platform-named `.node` (published layout).
36
+ const prebuilt = path.join(
37
+ __dirname,
38
+ `svelte-shaker-engine-scan-native.${platform}-${arch}.node`,
39
+ );
40
+ if (fs.existsSync(prebuilt)) {
41
+ const mod = tryRequire(prebuilt);
42
+ if (mod) return mod;
43
+ }
44
+
45
+ // 2) In-repo cargo output (dev/test): copy `target/<profile>/<dylib>` to a
46
+ // `.node` and load. Checked BEFORE generic siblings so a fresh `cargo build`
47
+ // always wins over a stale `local-*.node` copy from an earlier build.
48
+ if (DYLIB) {
49
+ for (const profile of ['release', 'debug']) {
50
+ const dylib = path.join(__dirname, 'target', profile, DYLIB);
51
+ if (!fs.existsSync(dylib)) continue;
52
+ const nodeCopy = path.join(__dirname, `local-${profile}.node`);
53
+ // Refresh the copy only when the build is newer, so a rebuild is picked up
54
+ // by the next fresh process without re-copying on every load.
55
+ try {
56
+ const srcMtime = fs.statSync(dylib).mtimeMs;
57
+ const dstMtime = fs.existsSync(nodeCopy) ? fs.statSync(nodeCopy).mtimeMs : -1;
58
+ if (srcMtime > dstMtime) fs.copyFileSync(dylib, nodeCopy);
59
+ } catch {
60
+ /* fall through to require attempt */
61
+ }
62
+ const mod = tryRequire(nodeCopy);
63
+ if (mod) return mod;
64
+ }
65
+ }
66
+
67
+ // 3) Any other `*.node` next to this file (defensive: a differently-named prebuild).
68
+ let siblings = [];
69
+ try {
70
+ siblings = fs.readdirSync(__dirname);
71
+ } catch {
72
+ /* no dir listing */
73
+ }
74
+ for (const file of siblings) {
75
+ if (file.endsWith('.node')) {
76
+ const mod = tryRequire(path.join(__dirname, file));
77
+ if (mod) return mod;
78
+ }
79
+ }
80
+
81
+ throw new Error(
82
+ `svelte-shaker-engine-scan-native: no native binary found for ${platform}-${arch} ` +
83
+ `(looked for a prebuilt .node and a target/{release,debug} build)`,
84
+ );
85
+ }
86
+
87
+ module.exports = loadAddon();
package/index.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Scan a whole resolved program for never-passed props (typed path — the default).
3
+ *
4
+ * `inputJson` is the JSON of `{ files: { id: string; code: string }[]; edges:
5
+ * ResolvedEdge[] }` — the output of svelte-shaker's `buildAnalyzeInputSync` crawl
6
+ * (resolution already done). Returns the JSON of `{ [fileId: string]: { name:
7
+ * string; start: number; end: number }[] }` with UTF-16 offsets, keys sorted by
8
+ * file id — the same shape as the WASM `find_never_passed_props_json` and the TS
9
+ * `findNeverPassedProps`.
10
+ *
11
+ * Synchronous; requires Node >= 22.12.
12
+ */
13
+ export declare function scan(inputJson: string): string;
14
+
15
+ /**
16
+ * The Value-engine oracle / fallback: serializes each AST to the rsvelte JSON shape
17
+ * and runs the validated `find_never_passed_props`. Output is identical to {@link
18
+ * scan}; kept as the differential reference and a drop-in fallback.
19
+ */
20
+ export declare function scanViaValue(inputJson: string): string;
21
+
22
+ /** Profiling helper: returns `{ typedMs, valueMs, files }` for the same input. */
23
+ export declare function scanProfile(inputJson: string): string;
24
+
25
+ /**
26
+ * In-memory scan state for incremental re-scans (editor / LSP). Construct once,
27
+ * `init` with the full program, then `update` per change set — `update` re-parses
28
+ * only the changed files and re-runs the cheap whole-program assembly over the
29
+ * cached models, so a single-file edit re-scans in ~1 ms instead of a full scan,
30
+ * byte-identical to a cold {@link scan}.
31
+ */
32
+ export declare class ScanDaemon {
33
+ constructor();
34
+ /** Full scan: parse every file and cache its model. Same input as {@link scan}. */
35
+ init(inputJson: string): string;
36
+ /**
37
+ * Incremental re-scan. `inputJson` is `{ files: { id, code }[]; edges:
38
+ * ResolvedEdge[]; removed?: string[] }` — `files` are the changed/added files,
39
+ * `edges` the full current edge set, `removed` the deleted file ids.
40
+ */
41
+ update(inputJson: string): string;
42
+ }
Binary file
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "svelte-shaker-engine-scan-native",
3
+ "version": "0.1.0",
4
+ "description": "Native (napi) prop-scanner for svelte-shaker: parses with rsvelte and runs the validated never-passed-props engine in-process.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/baseballyama/svelte-shaker.git",
9
+ "directory": "packages/svelte-shaker/engine-scan-native"
10
+ },
11
+ "files": [
12
+ "index.cjs",
13
+ "index.d.ts",
14
+ "*.node"
15
+ ],
16
+ "main": "index.cjs",
17
+ "types": "index.d.ts",
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "build": "napi build --platform --release --no-js --output-dir .",
23
+ "build:debug": "napi build --platform --no-js --output-dir ."
24
+ },
25
+ "devDependencies": {
26
+ "@napi-rs/cli": "^2.18.4"
27
+ },
28
+ "napi": {
29
+ "binaryName": "svelte-shaker-engine-scan-native",
30
+ "targets": [
31
+ "x86_64-apple-darwin",
32
+ "aarch64-apple-darwin",
33
+ "x86_64-unknown-linux-gnu",
34
+ "aarch64-unknown-linux-gnu",
35
+ "x86_64-pc-windows-msvc"
36
+ ]
37
+ },
38
+ "engines": {
39
+ "node": ">=22.12"
40
+ }
41
+ }