yuku-analyzer 0.5.36 → 0.5.38
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 +10 -25
- package/analyzer.js +46 -50
- package/decode.js +344 -442
- package/engine.js +1 -8
- package/index.d.ts +5 -34
- package/module.js +5 -79
- package/package.json +14 -14
package/README.md
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# yuku-analyzer
|
|
2
2
|
|
|
3
|
-
Full semantic analysis for JavaScript and TypeScript: scopes, symbols, resolved references, closures, and cross-file module linking. Powered by [Yuku](https://github.com/yuku-toolchain/yuku)
|
|
3
|
+
Full semantic analysis for JavaScript and TypeScript: scopes, symbols, resolved references, closures, and cross-file module linking, computed natively in Zig and queried as plain JavaScript objects. Powered by [Yuku](https://github.com/yuku-toolchain/yuku).
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**No single library gives you all of this.** Scopes and resolved references mean `eslint-scope` or `@typescript-eslint/scope-manager`. Cross-file go-to-definition means the TypeScript compiler or `ts-morph`. A parser sits under both. `yuku-analyzer` is all of them in one native pass behind one API.
|
|
6
|
+
|
|
7
|
+
**At native speed.** Up to ~15× faster per file than `eslint-scope`, `@typescript-eslint/scope-manager`, and `@babel/traverse`, with zero per-query cost after the single native call. Stitch those separate tools together yourself and the gap only widens: each re-walks the AST, you re-parse to resolve across files, and you keep the indexes between them in sync by hand. `yuku-analyzer` pays all of that once, in Zig.
|
|
6
8
|
|
|
7
9
|
```bash
|
|
8
|
-
npm install yuku-analyzer
|
|
10
|
+
npm install yuku-analyzer
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
`yuku-parser` is a peer dependency.
|
|
12
|
-
|
|
13
13
|
## Quick start
|
|
14
14
|
|
|
15
15
|
```js
|
|
@@ -35,9 +35,7 @@ console.log(def.module.path); // "lib.ts"
|
|
|
35
35
|
console.log(def.symbol.has(SymbolFlags.Const)); // true
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
##
|
|
39
|
-
|
|
40
|
-
JavaScript tooling that needs real semantics has had two options: track scopes by hand during a walk (fragile, incomplete, and re-implemented in every tool), or embed a full language service (heavy). `yuku-analyzer` is the missing middle: the exact scope and binding model of a production compiler, exposed to JavaScript as a small, fast object graph.
|
|
38
|
+
## How it works
|
|
41
39
|
|
|
42
40
|
Nothing semantic is reimplemented in JavaScript. The binder, scope tree, reference resolution, and module records are computed by the same well-tested native analyzer that powers the rest of Yuku, then shipped to JavaScript as one compact buffer that the JS side only decodes, through lazy zero-copy views. That handoff is usually where native tooling stalls: either every query pays an FFI round trip, or the semantics get a hand-written JS twin that slowly drifts. Here there is one implementation and one crossing, so it cannot drift, and the corner cases arrive already correct: catch-clause scope sharing, named function expression scopes, `var` hoist targets, TS declaration merging, value space versus type space, and write detection through destructuring patterns.
|
|
43
41
|
|
|
@@ -89,21 +87,6 @@ module.walk({
|
|
|
89
87
|
|
|
90
88
|
The walk mutates in place: `ctx.replace(node)`, `ctx.remove()`, `ctx.insertBefore(node)`, `ctx.insertAfter(node)`, plus `ctx.skip()` and `ctx.stop()`. Transform the AST during the walk and print it with [`yuku-codegen`](https://www.npmjs.com/package/yuku-codegen).
|
|
91
89
|
|
|
92
|
-
## Scanning without building the AST
|
|
93
|
-
|
|
94
|
-
`module.scan` visits the parsed node records directly in the binary buffer. Nothing is materialized unless you ask for it, and symbols and references resolve in index space:
|
|
95
|
-
|
|
96
|
-
```js
|
|
97
|
-
const writes = [];
|
|
98
|
-
module.scan({
|
|
99
|
-
Identifier(cursor) {
|
|
100
|
-
if (cursor.reference?.isWrite) writes.push(cursor.reference.name);
|
|
101
|
-
},
|
|
102
|
-
});
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
This answers questions like "every write to an imported binding across 10,000 files" without constructing a single AST object. For semantic queries like this, scanning runs about 4x faster than the equivalent walk, since it never builds the AST. Scan to find, walk to change.
|
|
106
|
-
|
|
107
90
|
## Closure analysis
|
|
108
91
|
|
|
109
92
|
`capturesOf` reports the free variables of any function: every outer binding it closes over, with the capturing reference sites and whether the function writes to the binding. Shadowing and aliasing are handled by the resolved reference table, not by name matching:
|
|
@@ -132,7 +115,9 @@ module.dependents; // modules that import this file
|
|
|
132
115
|
analyzer.diagnostics; // e.g. "Module './lib.ts' has no export 'helpr'"
|
|
133
116
|
```
|
|
134
117
|
|
|
135
|
-
Linking is automatic: every cross-file surface relinks on demand after files change. Call `analyzer.link()` explicitly if you want to control when the work happens.
|
|
118
|
+
Linking is automatic: every cross-file surface relinks on demand after files change. Re-adding a path replaces its module with a new object and relinks, and removing it relinks too. Call `analyzer.link()` explicitly if you want to control when the work happens.
|
|
119
|
+
|
|
120
|
+
Module records and linking model ECMAScript and TypeScript module syntax, so CommonJS `require` and `module.exports` are ordinary code rather than records and take no part in linking. Per-file scopes, symbols, and references are still computed for CommonJS sources.
|
|
136
121
|
|
|
137
122
|
Module resolution is pluggable. The default resolves relative specifiers among added files with standard extension and index probing. Pass your own resolver for anything else:
|
|
138
123
|
|
|
@@ -150,7 +135,7 @@ Concretely, on an Apple M-series machine: parsing plus complete semantic analysi
|
|
|
150
135
|
|
|
151
136
|
## TypeScript
|
|
152
137
|
|
|
153
|
-
Everything is fully typed. Visitor handlers receive exact node types,
|
|
138
|
+
Everything is fully typed. Visitor handlers receive exact node types, and the semantic surface (`Module`, `Scope`, `Symbol`, `Reference`, `Import`, `Export`, `Capture`) is exported:
|
|
154
139
|
|
|
155
140
|
```ts
|
|
156
141
|
import type { Module, Symbol, Capture } from "yuku-analyzer";
|
package/analyzer.js
CHANGED
|
@@ -2,9 +2,7 @@ import { Module, SymbolFlags } from "./module.js";
|
|
|
2
2
|
|
|
3
3
|
const RESOLVE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js", ".mts", ".mjs", ".cts", ".cjs"];
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
// than one `export *` declaration supplies the name through different
|
|
7
|
-
// bindings. distinct from null (not found / circular).
|
|
5
|
+
// name supplied ambiguously by multiple export * (ResolveExport, 16.2.1.7.2.2)
|
|
8
6
|
const AMBIGUOUS = Symbol("ambiguous");
|
|
9
7
|
|
|
10
8
|
export class Analyzer {
|
|
@@ -13,6 +11,10 @@ export class Analyzer {
|
|
|
13
11
|
#diagnostics = [];
|
|
14
12
|
#dirty = false;
|
|
15
13
|
#linking = false;
|
|
14
|
+
// origin defining symbol -> importing local symbols that resolve to it,
|
|
15
|
+
// across every module. built once per link so referencesOf is a lookup
|
|
16
|
+
// rather than a per-call walk over every import in the graph.
|
|
17
|
+
#importersByOrigin = new Map();
|
|
16
18
|
|
|
17
19
|
constructor(options = {}) {
|
|
18
20
|
this.#resolve = options.resolve ?? defaultResolve(this.#modules);
|
|
@@ -44,10 +46,7 @@ export class Analyzer {
|
|
|
44
46
|
return this.#diagnostics;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
//
|
|
48
|
-
// added module, wires the dependency graph, and reports binding
|
|
49
|
-
// validation failures as diagnostics rather than throwing. unresolvable
|
|
50
|
-
// specifiers are skipped so a partial graph still links.
|
|
49
|
+
// static spec linking, reports failures as diagnostics rather than throwing
|
|
51
50
|
link() {
|
|
52
51
|
this.#dirty = false;
|
|
53
52
|
this.#linking = true;
|
|
@@ -74,18 +73,40 @@ export class Analyzer {
|
|
|
74
73
|
record._resolved = this.#resolveModule(record.specifier, module, false);
|
|
75
74
|
if (record._resolved === null) continue;
|
|
76
75
|
this.#wire(module, record._resolved);
|
|
77
|
-
//
|
|
78
|
-
// `export *` conflicts are deferred to use sites, per spec
|
|
76
|
+
// export * as ns resolves trivially, bare export * conflicts deferred to use sites
|
|
79
77
|
if (record.fromName !== null) {
|
|
80
78
|
this.#validate(module, record, record.fromName, "Re-export");
|
|
81
79
|
}
|
|
82
80
|
}
|
|
83
81
|
}
|
|
82
|
+
this.#indexImporters();
|
|
84
83
|
this.#linking = false;
|
|
85
84
|
}
|
|
86
85
|
|
|
87
|
-
//
|
|
88
|
-
//
|
|
86
|
+
// buckets every importing local under the origin symbol it resolves to,
|
|
87
|
+
// computing each definitionOf once. referencesOf then reads the bucket
|
|
88
|
+
// instead of re-walking the whole import graph on every call.
|
|
89
|
+
#indexImporters() {
|
|
90
|
+
this.#importersByOrigin = new Map();
|
|
91
|
+
for (const module of this.#modules.values()) {
|
|
92
|
+
for (const record of module.imports) {
|
|
93
|
+
const local = record.local;
|
|
94
|
+
if (local === null) continue;
|
|
95
|
+
const definition = this.definitionOf(local);
|
|
96
|
+
// a symbol object is unique to its (module, id), so the origin
|
|
97
|
+
// symbol alone keys the bucket; the module is implied.
|
|
98
|
+
if (definition === null || definition.symbol === null) continue;
|
|
99
|
+
let importers = this.#importersByOrigin.get(definition.symbol);
|
|
100
|
+
if (importers === undefined) {
|
|
101
|
+
importers = [];
|
|
102
|
+
this.#importersByOrigin.set(definition.symbol, importers);
|
|
103
|
+
}
|
|
104
|
+
importers.push(local);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// report a record whose name fails to resolve or resolves ambiguously
|
|
89
110
|
#validate(module, record, name, what) {
|
|
90
111
|
const resolution = this.#resolveExport(record._resolved, name, []);
|
|
91
112
|
if (resolution !== null && resolution !== AMBIGUOUS) return;
|
|
@@ -102,10 +123,7 @@ export class Analyzer {
|
|
|
102
123
|
});
|
|
103
124
|
}
|
|
104
125
|
|
|
105
|
-
//
|
|
106
|
-
// ResolveExport (InitializeEnvironment step 7.c). namespace
|
|
107
|
-
// resolutions yield { module, symbol: null }; unresolved or ambiguous
|
|
108
|
-
// chains yield null.
|
|
126
|
+
// follow an import binding to its defining module and symbol (ResolveExport, InitializeEnvironment 7.c)
|
|
109
127
|
definitionOf(symbol) {
|
|
110
128
|
this._ensureLinked();
|
|
111
129
|
let module = symbol.module;
|
|
@@ -138,19 +156,14 @@ export class Analyzer {
|
|
|
138
156
|
for (const reference of origin.symbol.references) {
|
|
139
157
|
out.push({ module: origin.module, reference });
|
|
140
158
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
for (const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
definition.module === origin.module
|
|
150
|
-
) {
|
|
151
|
-
for (const reference of record.local.references) {
|
|
152
|
-
out.push({ module, reference });
|
|
153
|
-
}
|
|
159
|
+
const importers = this.#importersByOrigin.get(origin.symbol);
|
|
160
|
+
if (importers !== undefined) {
|
|
161
|
+
for (const local of importers) {
|
|
162
|
+
// a self-import resolving back to the origin module is already
|
|
163
|
+
// covered by the direct references above
|
|
164
|
+
if (local.module === origin.module) continue;
|
|
165
|
+
for (const reference of local.references) {
|
|
166
|
+
out.push({ module: local.module, reference });
|
|
154
167
|
}
|
|
155
168
|
}
|
|
156
169
|
}
|
|
@@ -185,21 +198,8 @@ export class Analyzer {
|
|
|
185
198
|
if (!to._dependents.includes(from)) to._dependents.push(from);
|
|
186
199
|
}
|
|
187
200
|
|
|
188
|
-
// ResolveExport
|
|
189
|
-
// name to the binding that defines it, following named re-export and
|
|
190
|
-
// `export *` chains. returns { module, symbol, namespace } (the
|
|
191
|
-
// spec's ResolvedBinding, with namespace: true for its namespace
|
|
192
|
-
// [[BindingName]]), null when unresolvable or circular, or AMBIGUOUS.
|
|
193
|
-
//
|
|
194
|
-
// local and indirect entries live in one name-keyed map (_exportMap);
|
|
195
|
-
// duplicate export names are an early error, so the spec's step 5/6
|
|
196
|
-
// partition cannot change the outcome. ParseModule step 10.a.ii
|
|
197
|
-
// (rewriting an export of an imported binding through its original
|
|
198
|
-
// module) is applied at resolution time instead of collection time.
|
|
201
|
+
// ResolveExport, 16.2.1.7.2.2
|
|
199
202
|
#resolveExport(module, exportName, resolveSet) {
|
|
200
|
-
// step 3: a repeated (module, exportName) pair is a circular import
|
|
201
|
-
// request. the pair key still permits re-entering a module for a
|
|
202
|
-
// different name, which renaming re-export chains legitimately do.
|
|
203
203
|
for (const entry of resolveSet) {
|
|
204
204
|
if (entry.module === module && entry.exportName === exportName) return null;
|
|
205
205
|
}
|
|
@@ -210,8 +210,7 @@ export class Analyzer {
|
|
|
210
210
|
if (direct.specifier === null) {
|
|
211
211
|
const local = direct.local;
|
|
212
212
|
if (local !== null && (local.flags & SymbolFlags.Import) !== 0) {
|
|
213
|
-
// export of an imported binding
|
|
214
|
-
// module (the ParseModule rewrite)
|
|
213
|
+
// export of an imported binding resolves through the original module (ParseModule 10.a.ii)
|
|
215
214
|
const record = module._importOfSymbol(local.id);
|
|
216
215
|
if (record !== undefined) {
|
|
217
216
|
const imported =
|
|
@@ -221,22 +220,19 @@ export class Analyzer {
|
|
|
221
220
|
return this.#resolveExport(imported, record.name, resolveSet);
|
|
222
221
|
}
|
|
223
222
|
}
|
|
224
|
-
// step 5: the module provides the direct binding
|
|
225
223
|
return { module, symbol: local, namespace: false };
|
|
226
224
|
}
|
|
227
225
|
const next = direct._resolved ?? this.#resolveModule(direct.specifier, module, true);
|
|
228
226
|
if (next === null) return null;
|
|
229
|
-
//
|
|
230
|
-
// follow the chain under the source name
|
|
227
|
+
// namespace re-export binds the namespace, named ones follow the chain
|
|
231
228
|
if (direct.isNamespaceReexport) return { module: next, symbol: null, namespace: true };
|
|
232
229
|
return this.#resolveExport(next, direct.fromName, resolveSet);
|
|
233
230
|
}
|
|
234
231
|
|
|
235
|
-
//
|
|
232
|
+
// default never crosses export *
|
|
236
233
|
if (exportName === "default") return null;
|
|
237
234
|
|
|
238
|
-
//
|
|
239
|
-
// ambiguous
|
|
235
|
+
// identical star bindings collapse, otherwise ambiguous
|
|
240
236
|
let starResolution = null;
|
|
241
237
|
for (const star of module._starExports()) {
|
|
242
238
|
const next = star._resolved ?? this.#resolveModule(star.specifier, module, true);
|