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 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), written in Zig.
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
- The usual options are a hand-rolled scope tracker (fragile, per-file, re-bugged in every tool) or the TypeScript compiler (correct, but hundreds of milliseconds per file and a compiler-sized dependency). This is the fast path between them. One native call per file, then every query is plain JavaScript with zero per-query FFI cost, sub-millisecond on a typical file.
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 yuku-parser
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
- ## Why this exists
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, AST types come from `yuku-parser`, and the semantic surface (`Module`, `Scope`, `Symbol`, `Reference`, `Import`, `Export`, `Capture`) is exported:
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
- // the third resolution outcome of ResolveExport (16.2.1.7.2.2): more
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
- // the static half of spec linking. resolves every module request to an
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
- // `export * as ns` (fromName null) trivially resolves and bare
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
- // reports a record whose name does not resolve, or resolves
88
- // ambiguously, against its (already resolved) source module
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
- // follows an import binding to its defining module and symbol via
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
- for (const module of this.#modules.values()) {
142
- if (module === origin.module) continue;
143
- for (const record of module.imports) {
144
- if (record.local === null) continue;
145
- const definition = this.definitionOf(record.local);
146
- if (
147
- definition !== null &&
148
- definition.symbol === origin.symbol &&
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(exportName, resolveSet), 16.2.1.7.2.2: resolves a
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: resolve through the original
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
- // step 6: `export * as ns` binds the namespace, named re-exports
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
- // step 7: "default" is never satisfied through `export *`
232
+ // default never crosses export *
236
233
  if (exportName === "default") return null;
237
234
 
238
- // steps 8-9: identical star bindings collapse, different ones are
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);