yuku-analyzer 0.5.32

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,164 @@
1
+ # yuku-analyzer
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.
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.
6
+
7
+ ```bash
8
+ npm install yuku-analyzer yuku-parser
9
+ ```
10
+
11
+ `yuku-parser` is a peer dependency.
12
+
13
+ ## Quick start
14
+
15
+ ```js
16
+ import { Analyzer, SymbolFlags } from "yuku-analyzer";
17
+
18
+ const analyzer = new Analyzer();
19
+
20
+ analyzer.addFile("lib.ts", `export const helper = (x: number) => x * 2;`);
21
+ const main = analyzer.addFile(
22
+ "main.ts",
23
+ `import { helper } from "./lib.ts";
24
+ export const out = helper(21);`,
25
+ );
26
+
27
+ // per-file semantics
28
+ const helperSym = main.rootScope.find("helper");
29
+ console.log(helperSym.has(SymbolFlags.Import)); // true
30
+ console.log(helperSym.references.length); // 1, the call site
31
+
32
+ // cross-file: follow the import to where helper is actually defined
33
+ const def = helperSym.definition();
34
+ console.log(def.module.path); // "lib.ts"
35
+ console.log(def.symbol.has(SymbolFlags.Const)); // true
36
+ ```
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.
41
+
42
+ 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
+
44
+ ## What one `addFile` gives you
45
+
46
+ ```js
47
+ const module = analyzer.addFile("app.tsx", source);
48
+
49
+ module.ast // ESTree / TS-ESTree program
50
+ module.scopes // every lexical scope, as a tree
51
+ module.symbols // every declared binding
52
+ module.references // every identifier use, resolved to its symbol
53
+ module.unresolvedReferences // free names and globals
54
+ module.imports // spec-true import records
55
+ module.exports // spec-true export records
56
+ module.diagnostics // syntax + semantic errors
57
+ ```
58
+
59
+ And node-level queries that work directly on AST nodes:
60
+
61
+ ```js
62
+ module.symbolOf(node) // the symbol a node declares or references
63
+ module.referenceOf(node) // the reference recorded for an identifier
64
+ module.scopeOf(node) // the innermost scope containing the node
65
+ module.resolve("name") // scope-chain lookup, like the engine does at runtime
66
+ ```
67
+
68
+ Node identity is exact: the node you reach by walking `module.ast` and the node a semantic query returns are the same JavaScript object, so `===` always works.
69
+
70
+ ## Walking with semantic context
71
+
72
+ `module.walk` is a typed visitor walk where every handler also receives the current scope, symbol, and reference. No manual scope tracking, ever:
73
+
74
+ ```js
75
+ module.walk({
76
+ Identifier(node, ctx) {
77
+ if (ctx.reference?.isWrite && ctx.symbol?.has(SymbolFlags.Import)) {
78
+ console.log(`${node.name} assigns to an import`);
79
+ }
80
+ },
81
+ FunctionDeclaration: {
82
+ enter(node, ctx) {
83
+ console.log(node.id.name, "declared in a", ctx.scope.kind, "scope");
84
+ },
85
+ },
86
+ });
87
+ ```
88
+
89
+ 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).
90
+
91
+ ## Scanning without building the AST
92
+
93
+ `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:
94
+
95
+ ```js
96
+ const writes = [];
97
+ module.scan({
98
+ Identifier(cursor) {
99
+ if (cursor.reference?.isWrite) writes.push(cursor.reference.name);
100
+ },
101
+ });
102
+ ```
103
+
104
+ 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.
105
+
106
+ ## Closure analysis
107
+
108
+ `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:
109
+
110
+ ```js
111
+ const [fn] = main.findAll("FunctionDeclaration");
112
+ for (const capture of main.capturesOf(fn)) {
113
+ console.log(capture.symbol.name, capture.isWritten ? "(written)" : "");
114
+ }
115
+ ```
116
+
117
+ ## Cross-file analysis
118
+
119
+ The analyzer joins imports to exports across every added file with the spec's ResolveExport semantics: re-export and `export *` chains are followed per name, `default` never travels through `export *`, and a name supplied by multiple `export *` declarations through different bindings is reported as ambiguous.
120
+
121
+ ```js
122
+ // where is this binding actually defined?
123
+ analyzer.definitionOf(symbol); // { module, symbol } or null for external modules
124
+
125
+ // every use across the whole graph, imports followed back
126
+ analyzer.referencesOf(symbol); // [{ module, reference }, ...]
127
+
128
+ module.exportedNames(); // every exported name, `export *` chains included
129
+ module.dependencies; // modules this file imports from
130
+ module.dependents; // modules that import this file
131
+ analyzer.diagnostics; // e.g. "Module './lib.ts' has no export 'helpr'"
132
+ ```
133
+
134
+ 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.
135
+
136
+ 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:
137
+
138
+ ```js
139
+ const analyzer = new Analyzer({
140
+ resolve: (specifier, importerPath) => myResolver(specifier, importerPath),
141
+ });
142
+ ```
143
+
144
+ ## Performance
145
+
146
+ Analysis runs in the native parser pass, so full semantics cost roughly half of parsing time on top of the parse itself. Validated against 55,000+ real-world files.
147
+
148
+ Concretely, on an Apple M-series machine: parsing plus complete semantic analysis of a typical source file lands well under a millisecond, walking sustains tens of millions of nodes per second, and linking a 2,000-module graph takes about a millisecond.
149
+
150
+ ## TypeScript
151
+
152
+ 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:
153
+
154
+ ```ts
155
+ import type { Module, Symbol, Capture } from "yuku-analyzer";
156
+ ```
157
+
158
+ ## Documentation
159
+
160
+ The full documentation, including the architecture, every type, and the design decisions: [yuku.fyi/analyzer](https://yuku.fyi/analyzer).
161
+
162
+ ## License
163
+
164
+ MIT
package/analyzer.js ADDED
@@ -0,0 +1,290 @@
1
+ import { Module, SymbolFlags } from "./module.js";
2
+
3
+ const RESOLVE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js", ".mts", ".mjs", ".cts", ".cjs"];
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).
8
+ const AMBIGUOUS = Symbol("ambiguous");
9
+
10
+ export class Analyzer {
11
+ #modules = new Map();
12
+ #resolve;
13
+ #diagnostics = [];
14
+ #dirty = false;
15
+ #linking = false;
16
+
17
+ constructor(options = {}) {
18
+ this.#resolve = options.resolve ?? defaultResolve(this.#modules);
19
+ }
20
+
21
+ addFile(path, source, options) {
22
+ const module = new Module(this, path, source, options);
23
+ this.#modules.set(path, module);
24
+ this.#dirty = true;
25
+ return module;
26
+ }
27
+
28
+ removeFile(path) {
29
+ const removed = this.#modules.delete(path);
30
+ if (removed) this.#dirty = true;
31
+ return removed;
32
+ }
33
+
34
+ module(path) {
35
+ return this.#modules.get(path);
36
+ }
37
+
38
+ get modules() {
39
+ return this.#modules;
40
+ }
41
+
42
+ get diagnostics() {
43
+ this._ensureLinked();
44
+ return this.#diagnostics;
45
+ }
46
+
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.
51
+ link() {
52
+ this.#dirty = false;
53
+ this.#linking = true;
54
+ this.#diagnostics = [];
55
+ for (const module of this.#modules.values()) {
56
+ module._deps = [];
57
+ module._dependents = [];
58
+ // drop earlier resolutions so removed modules cannot leak through
59
+ for (const record of module.imports) record._resolved = null;
60
+ for (const record of module.exports) record._resolved = null;
61
+ }
62
+ for (const module of this.#modules.values()) {
63
+ for (const record of module.imports) {
64
+ record._resolved = this.#resolveModule(record.specifier, module, false);
65
+ if (record._resolved === null) continue;
66
+ this.#wire(module, record._resolved);
67
+ // namespace and side-effect imports name nothing to validate
68
+ if (record.name !== null) {
69
+ this.#validate(module, record, record.name, "Import");
70
+ }
71
+ }
72
+ for (const record of module.exports) {
73
+ if (record.specifier === null) continue;
74
+ record._resolved = this.#resolveModule(record.specifier, module, false);
75
+ if (record._resolved === null) continue;
76
+ 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
79
+ if (record.fromName !== null) {
80
+ this.#validate(module, record, record.fromName, "Re-export");
81
+ }
82
+ }
83
+ }
84
+ this.#linking = false;
85
+ }
86
+
87
+ // reports a record whose name does not resolve, or resolves
88
+ // ambiguously, against its (already resolved) source module
89
+ #validate(module, record, name, what) {
90
+ const resolution = this.#resolveExport(record._resolved, name, []);
91
+ if (resolution !== null && resolution !== AMBIGUOUS) return;
92
+ const message =
93
+ resolution === null
94
+ ? `Module '${record.specifier}' has no export '${name}'`
95
+ : `${what} '${name}' of module '${record.specifier}' is ambiguous: multiple 'export *' declarations supply it`;
96
+ this.#diagnostics.push({
97
+ severity: "error",
98
+ message,
99
+ module: module.path,
100
+ start: record.node.start,
101
+ end: record.node.end,
102
+ });
103
+ }
104
+
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.
109
+ definitionOf(symbol) {
110
+ this._ensureLinked();
111
+ let module = symbol.module;
112
+ let current = symbol;
113
+ const seen = new Set();
114
+ while ((current.flags & SymbolFlags.Import) !== 0) {
115
+ const key = `${module.path}:${current.id}`;
116
+ if (seen.has(key)) return null;
117
+ seen.add(key);
118
+ const record = module._importOfSymbol(current.id);
119
+ if (record === undefined || record._resolved === null) return null;
120
+ if (record.isNamespace || record.name === null) {
121
+ return { module: record._resolved, symbol: null };
122
+ }
123
+ const resolution = this.#resolveExport(record._resolved, record.name, []);
124
+ if (resolution === null || resolution === AMBIGUOUS) return null;
125
+ if (resolution.namespace) return { module: resolution.module, symbol: null };
126
+ if (resolution.symbol === null) return null;
127
+ module = resolution.module;
128
+ current = resolution.symbol;
129
+ }
130
+ return { module, symbol: current };
131
+ }
132
+
133
+ referencesOf(symbol) {
134
+ this._ensureLinked();
135
+ const origin = this.definitionOf(symbol) ?? { module: symbol.module, symbol };
136
+ if (origin.symbol === null) return [];
137
+ const out = [];
138
+ for (const reference of origin.symbol.references) {
139
+ out.push({ module: origin.module, reference });
140
+ }
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
+ }
154
+ }
155
+ }
156
+ }
157
+ return out;
158
+ }
159
+
160
+ _ensureLinked() {
161
+ if (this.#dirty && !this.#linking) this.link();
162
+ }
163
+
164
+ #resolveModule(specifier, importer, quiet) {
165
+ const resolved = this.#resolve(specifier, importer.path);
166
+ if (resolved === null || resolved === undefined) return null;
167
+ const module = this.#modules.get(resolved);
168
+ if (module === undefined) {
169
+ if (!quiet) {
170
+ this.#diagnostics.push({
171
+ severity: "warning",
172
+ message: `Resolver returned '${resolved}' for '${specifier}' but no such file was added`,
173
+ module: importer.path,
174
+ start: 0,
175
+ end: 0,
176
+ });
177
+ }
178
+ return null;
179
+ }
180
+ return module;
181
+ }
182
+
183
+ #wire(from, to) {
184
+ if (!from._deps.includes(to)) from._deps.push(to);
185
+ if (!to._dependents.includes(from)) to._dependents.push(from);
186
+ }
187
+
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.
199
+ #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
+ for (const entry of resolveSet) {
204
+ if (entry.module === module && entry.exportName === exportName) return null;
205
+ }
206
+ resolveSet.push({ module, exportName });
207
+
208
+ const direct = module._exportMap().get(exportName);
209
+ if (direct !== undefined) {
210
+ if (direct.specifier === null) {
211
+ const local = direct.local;
212
+ if (local !== null && (local.flags & SymbolFlags.Import) !== 0) {
213
+ // export of an imported binding: resolve through the original
214
+ // module (the ParseModule rewrite)
215
+ const record = module._importOfSymbol(local.id);
216
+ if (record !== undefined) {
217
+ const imported =
218
+ record._resolved ?? this.#resolveModule(record.specifier, module, true);
219
+ if (imported === null) return null;
220
+ if (record.isNamespace) return { module: imported, symbol: null, namespace: true };
221
+ return this.#resolveExport(imported, record.name, resolveSet);
222
+ }
223
+ }
224
+ // step 5: the module provides the direct binding
225
+ return { module, symbol: local, namespace: false };
226
+ }
227
+ const next = direct._resolved ?? this.#resolveModule(direct.specifier, module, true);
228
+ 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
231
+ if (direct.isNamespaceReexport) return { module: next, symbol: null, namespace: true };
232
+ return this.#resolveExport(next, direct.fromName, resolveSet);
233
+ }
234
+
235
+ // step 7: "default" is never satisfied through `export *`
236
+ if (exportName === "default") return null;
237
+
238
+ // steps 8-9: identical star bindings collapse, different ones are
239
+ // ambiguous
240
+ let starResolution = null;
241
+ for (const star of module._starExports()) {
242
+ const next = star._resolved ?? this.#resolveModule(star.specifier, module, true);
243
+ if (next === null) continue;
244
+ const resolution = this.#resolveExport(next, exportName, resolveSet);
245
+ if (resolution === AMBIGUOUS) return AMBIGUOUS;
246
+ if (resolution === null) continue;
247
+ if (starResolution === null) {
248
+ starResolution = resolution;
249
+ } else if (
250
+ resolution.module !== starResolution.module ||
251
+ resolution.namespace !== starResolution.namespace ||
252
+ resolution.symbol !== starResolution.symbol
253
+ ) {
254
+ return AMBIGUOUS;
255
+ }
256
+ }
257
+ return starResolution;
258
+ }
259
+ }
260
+
261
+ function defaultResolve(modules) {
262
+ return (specifier, importer) => {
263
+ if (!specifier.startsWith(".")) return null;
264
+ const base = joinPath(dirnameOf(importer), specifier);
265
+ if (modules.has(base)) return base;
266
+ for (const ext of RESOLVE_EXTENSIONS) {
267
+ if (modules.has(base + ext)) return base + ext;
268
+ }
269
+ for (const ext of RESOLVE_EXTENSIONS) {
270
+ const index = `${base}/index${ext}`;
271
+ if (modules.has(index)) return index;
272
+ }
273
+ return null;
274
+ };
275
+ }
276
+
277
+ function dirnameOf(path) {
278
+ const i = path.lastIndexOf("/");
279
+ return i === -1 ? "" : path.slice(0, i);
280
+ }
281
+
282
+ function joinPath(dir, relative) {
283
+ const parts = dir === "" ? [] : dir.split("/");
284
+ for (const part of relative.split("/")) {
285
+ if (part === "" || part === ".") continue;
286
+ if (part === "..") parts.pop();
287
+ else parts.push(part);
288
+ }
289
+ return parts.join("/");
290
+ }
package/binding.js ADDED
@@ -0,0 +1,63 @@
1
+ import { createRequire } from 'node:module';
2
+ import { readFileSync } from 'node:fs';
3
+ import { execSync } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const { platform, arch } = process;
10
+
11
+ const isFileMusl = (f) => f.includes('libc.musl-') || f.includes('ld-musl-');
12
+
13
+ function isMusl() {
14
+ if (platform !== 'linux') return false;
15
+
16
+ try {
17
+ if (readFileSync('/usr/bin/ldd', 'utf-8').includes('musl')) return true;
18
+ } catch {}
19
+
20
+ try {
21
+ const report = typeof process.report?.getReport === 'function'
22
+ ? process.report.getReport()
23
+ : null;
24
+ if (report) {
25
+ const header = typeof report === 'string' ? JSON.parse(report).header : report.header;
26
+ if (header?.glibcVersionRuntime) return false;
27
+ if (Array.isArray(report.sharedObjects) && report.sharedObjects.some(isFileMusl)) return true;
28
+ }
29
+ } catch {}
30
+
31
+ try {
32
+ return execSync('ldd --version', { encoding: 'utf8' }).includes('musl');
33
+ } catch {}
34
+
35
+ return false;
36
+ }
37
+
38
+ function loadBinding() {
39
+ const errors = [];
40
+ const libc = platform === 'linux' ? (isMusl() ? '-musl' : '-gnu') : '';
41
+ const suffix = `${platform}-${arch}${libc}`;
42
+
43
+ try {
44
+ return require(join(__dirname, '@yuku-analyzer', 'binding-' + suffix, 'yuku-analyzer.node'));
45
+ } catch (e) {
46
+ errors.push(e);
47
+ }
48
+
49
+ try {
50
+ return require('@yuku-analyzer/binding-' + suffix + '/yuku-analyzer.node');
51
+ } catch (e) {
52
+ errors.push(e);
53
+ }
54
+
55
+ throw new Error(
56
+ `Failed to load native binding for ${platform}-${arch}.\n` +
57
+ `If this persists, try removing node_modules and reinstalling.\n` +
58
+ errors.map(e => ` - ${e.message}`).join('\n'),
59
+ { cause: errors[errors.length - 1] }
60
+ );
61
+ }
62
+
63
+ export default loadBinding();