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 +156 -0
- package/index.cjs +87 -0
- package/index.d.ts +42 -0
- package/local-release.node +0 -0
- package/package.json +41 -0
- package/svelte-shaker-engine-scan-native.darwin-arm64.node +0 -0
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
|
+
}
|
|
Binary file
|