feat: add export origin analysis - #229
Conversation
* perf(lexer): skip identifier and number runs On the 3,057 KiB sample corpus, Wasm drops from 15,356.31 to 13,010.46 us per sweep on Node 18.20.8 (-15.28%) and from 11,724.34 to 9,856.69 us on Node 24.18.0 (-15.93%). The asm.js build drops from 15,934.34 to 12,945.57 us on Node 24.18.0 (-18.76%). The fast path adds 379 raw / 105 gzip bytes to the full Wasm binary. Measurements used 1,500 warmup sweeps followed by 9 interleaved trials of 50 sweeps, dropping the best and worst trials.
953f241 to
6811b35
Compare
lexer.js reimplemented the WebAssembly lexer in hand-written JS, kept in sync by hand, but was unreachable through the package `exports` (only `.` and `./js` are exposed - the wasm and asm.js builds) and `chomp test` never ran it, so it could drift silently. The asm.js build already covers the no-WebAssembly case, and the removed `if (!js)` test guards now run unconditionally, matching their existing behavior under the wasm and asm suites.
6811b35 to
7982cd7
Compare
|
I've pushed two maintainer commits to this branch:
I also updated the PR description — the "binding capture is gated" paragraph described a previous revision, so it now reflects the unconditional inline collection. |
|
On the allocation design: I don't think the That removes the Beyond that, I want to keep the v2 allocation model as a hard constraint: static allocation with known limits and a known memory layout — no dynamic growth, no bail-and-retry. So The remaining per-record cost then looks right to me: +8 bytes on It does require the clause reader to be callable outside the main loop (save/restore |
A dynamic import whose entire argument is a template literal with substitutions
returned n: undefined, so a consumer resolving the specifier (a bundler or glob
importer) had nothing to work with. It now reports the static skeleton as a
glob with each ${...} collapsed to a single "*": import(`./locales/${x}.js`)
yields "./locales/*.js".
Only a lone template literal qualifies. A template concatenated with anything
else (import(`a` + b)) has no static skeleton and still returns undefined.
Fixes: guybedford#137
The glob walker that builds a dynamic-import template skeleton skips over
strings, nested templates, and comments inside each ${...} substitution, but it
cannot tell a regex literal from division without the main parser's token
context. A regex carrying a "}" closed the substitution early and emitted a
wrong specifier instead of bailing: import(`a${ /x}y/g }b`) reported "a*y/g }b"
rather than undefined.
skipInterpolation now flags a bare "/" (one that does not open a // or /*
comment) and the three decoders drop n to undefined rather than guess. This
over-bails the rare division case (import(`a${ b/c }d`)), which is acceptable:
a missing glob is recoverable, a wrong one is not.
Fixes: guybedford#137
The interpolated-template glob walked each ${...} substitution with a
hand-rolled scanner in all three decoders. None of them could tell a regex
literal from division without token context, so a regex carrying a "}" closed
the substitution early: import(`a${ /x}y/g }b`) reported "a*y/g }b". The prior
fix bailed to undefined on any bare "/", which also dropped legitimate division
(import(`a${ b/c }d`)).
The parser already resolves regex vs division for the whole source and descends
into ${ ... } for nested-import detection, so it now records each top-level
substitution's end on the dynamic import (struct TemplateSpan). The decoders
splice a "*" per span and jump the body, dropping their skipInterpolation /
skipQuoted / skipComment scanners and the interpolationError bail. Both
ambiguous cases now resolve correctly: "a*b" and "a*d".
Fixes: guybedford#137
…ects ## Summary Interpolated template glob tracking leaked into minimal builds and diverged between decoders. Missing parser spans could evaluate a substitution, reload an exhausted span list into a loop, or lose the outer glob around a nested import. ## Why Keep span tracking behind LEXER_MIN, use parser-recorded spans as the substitution boundary, and copy static source without eval. The full Wasm and asm.js builds now agree while minimal output stays unchanged. Static parts remain raw source, so escapes are not cooked and a literal * stays literal. ## Test plan - chomp test Fixes: guybedford#137
## Summary - Add `StaticReexportStar` as import type 8 for `export * from`. - Report the matching `*` export with a span that correlates to the import. - Cover full and minimal builds without reading fields omitted by the minimal API. ## Why Star re-exports are dependency edges and exports, but the lexer only exposed their specifier as a plain static import. Consumers had to recover the missing distinction by parsing the source again. ## Test plan - `chomp test:wasm test:minimal:wasm` Refs: guybedford#76 Refs: nodejs/import-in-the-middle#259
… name
A detached `export { x }` (no `from` clause) emits an identical export
record whether `x` is a locally-declared binding or one introduced by an
`import`. A consumer that needs to resolve re-exported imports from the
leaf module's namespace (import-in-the-middle) cannot tell the two apart
and is forced onto a slow fallback.
Record the local binding names an import introduces - named specifiers
(the `as` target, or the imported name when there is no `as`), the
default binding, and a `* as ns` namespace - and, when a detached export
resolves to one of them, report it with no local name (`ln === undefined`,
`ls === le === -1`), exactly as `export { x } from` already does. A
genuine local keeps its name.
The match is by name only: a lexer cannot do scope analysis, so an import
that follows the export (single pass) or a same-named local that shadows
an import is not resolved - the same assumption the `export { x } from`
case already makes. The named part of a combined `default, { a }` clause
is not tracked; such a re-export keeps its local name.
This fixes the detached re-export classifier so import bindings with comments after `as` are still recorded by their target name. Without this, `as` itself could be mistaken for an imported binding and later local exports named `as` were reported as re-exports.
The minimal build must retain its existing export records, so it compiles the imported-binding tracking state and parser paths out.
String-named imports such as `import {'a-b' as c}` were mistaken for module specifiers by the generic quote scan. Parse the named clause before reading the module string.
Detached exports can precede their imports, so source-order tracking cannot preserve their module request. Resolve complete module bindings and link reexports to the imports array. On Node 24 / V8 13.6, imports plus a direct export measured 5.08 -> 5.28 us, while 1,000 detached reexports measured 169.3 -> 145.2 us export-first and 166.5 -> 159.0 us import-first (seven trials, drop best and worst). The minimal build remained within noise.
Binding capture was gated on a JS-side prescan of the whole source, so a module's classification depended on its length and both wrappers paid the scan on every parse. On the sample corpus that scan cost more than the lexing it guarded: 722 us of the 878 us angular.js regressed, and effectively all of the asm.js build's. Capturing unconditionally is never slower, because the binding scanner also replaces a tolerant clause loop that called commentWhitespace per character: 1000 imports with direct exports measure 98.9 us captured against 141.7 us through that loop. The gate, eac(), the record re-parse and the loop are gone, and one scanner serves every source. The single scanner exposed four defects the size gate had hidden: 1. `export * from <non-string>` stored through a null import head; in the asm.js build that overwrote a keyword table, silently dropping every import from every later parse in the process. 2. A bare `export *` emitted a reexport record with no import, so both wrappers threw a TypeError instead of a ParseError. 3. An import-clause character that cannot start a binding was re-read forever. 4. `as` directly followed by a comment did not read as a rename. The asm.js analysis arena is now bounded in C and re-parsed into a larger buffer on overflow instead of truncating records silently, which retires the last two full-source scans. Against the previous commit the corpus runs 26% faster and the Wasm binary drops 3197 bytes. The feature now costs 2.3% and 2802 bytes over the unmodified lexer.
`br` is loop invariant, so testing it on every character of a comment body only costs. Comment-heavy sources gain the most: angular.js drops 7.8%, 8.6% and 8.9% across three runs on Node 24.18.0 (best of 21 interleaved trials), for 37 bytes of Wasm.
Export origin analysis needs to know which import a re-exported local name is
bound by, and storing a record per binding during the parse bills every module
that imports names. The answer is only read for detached exports — `export
{ a }` where `a` came from an import — which most modules do not have. The
bindings are still in the source, so a module that ends the parse with an
unresolved export re-reads its import clauses in the finalize pass and resolves
the names against the export table as it goes; a module without one never reads
a clause twice.
`ImportBinding` and its list are gone, along with the per-binding allocation
and its capacity check.
This speeds up every module with a named import clause. Only the finalize pass
resolves bindings, and only when a detached export exists to consume them, so
the main loop needs the position of the closing brace and nothing else. A scan
stepping over the four constructs able to hide a brace — string specifiers,
line comments, block comments and `\u{...}` escapes — replaces the specifier
reader there, and one entry point picks between the two so they cannot
disagree about where a clause ends.
Wasm, min of 21 runs in one process: imports without detached exports -10.4%,
detached export-first -6.3%, detached import-first -6.0%, a module ending in a
clause export -4.0%, angular.js -4.2%.
Open addressed inserts probe for a free slot, so every export sharing a local
name walks past all the earlier ones and a clause builds its table in quadratic
time. Exporting one local under 200,000 names — `export { a as x0, a as x1 }`,
valid and reachable from a generator — took 10.7 seconds where the same count
of distinct names took 11 milliseconds. Bundlers and CDNs run this parser on
untrusted input.
Chaining the bucket makes an insert an unconditional prepend with no comparison
at all, and one walk resolves every export a name covers. The same 200,000
aliases now take 17 milliseconds, and a corpus A/B is unchanged.
`ensureAnalysisCapacity()` inlines into every record allocation, and carrying the page-grow branch along costs the allocators more than the bounds test it guards. Record-dense sources measured 9.8% slower than an unchecked allocator; with the grow path out of line they are within noise of it at 0.5%, so the arena keeps its bound without the records paying for it.
A module with a clause export and no import clause — `const a = 1; export
{ a }` — allocates, zeroes, populates and sweeps a hash table that has nothing
to match against, and hashes every pending export name to fill it. Only a
binding introduced by an import clause ever resolves one of those exports.
The clause readers now record that the module has such a binding, and the name
hash moves to the table insert so it happens only where a table exists. That
shape drops from 156.6 ns to 147.2 ns, against 136.2 ns for the same build with
the analysis removed entirely, and the rest of the corpus is unchanged.
Re-reading import clauses in the finalize pass is the dominant cost of the
analysis: 8 of the 12 microseconds a 120-import module spends over a build
without it, and 13 of 15 on a clause-heavy one. Most of those clauses cannot
contain the name being looked for.
An import statement now records the 32-bit set of characters its binding names
are drawn from, accumulated in the brace-skip on code units it already loads. A
pending export name that occurs in a clause necessarily has its first character
in that set, so an import missing every pending first character is skipped
without being read. An escape sets the mask to all ones on either side, because
`\u{61}` and the `a` it denotes share no code unit.
That module goes from 63.4 us to 59.7 us, against 56.2 us for the same build
with the walk removed outright. Sources where every import really does bind an
exported name are unchanged.
…ding export" This reverts commit 96e7c1b. The filter was measured against fixtures carrying 120 and 400 import statements. Real modules that reach the finalize walk carry a median of 2: across 878 ESM files in two node_modules trees, 50 run the walk at all, between them re-reading 186 import clauses, of which the filter skips 34. Parsing that corpus measures 0.4% to 3.3% slower with the filter over four runs, and slower in every one of them on the 93 files that run the walk. The per-code-unit mask accumulation costs more than the clause reads it avoids.
## Summary Export metadata reads the same import records that carry interpolated-template spans. Keep the span fields, readers, and parser recording when export analysis is enabled so dynamic import globs remain available. ## Why The full build needs both metadata sets from one import record. Minimal builds compile the glob fields and readers out. ## Test plan - chomp test
d755568 to
779e5bd
Compare
Summary
Detached exports can precede their imports, so source-order tracking cannot classify them or preserve their module request. This adds a full-build tagged union for direct exports, named and namespace reexports, and bare star reexports. Reexports link to the existing imports array through
fi.The minimal build keeps its v2 output and compiles the analysis path out.
Why
Nothing about a binding is stored while parsing. A module that ends the parse with an unresolved export re-reads its import clauses once in the finalize pass, resolving names against a hash table of the pending exports; a module without one never builds the table and never reads a clause twice. That is the shape suggested in review, and it matches how real code is written: across 878 ESM files in two
node_modulestrees, 84.6% have imports and no clause export and pay nothing for the feature, while the 5.7% that do run the finalize walk carry a median of 2 import statements.Three properties keep the common paths off the analysis:
main.Analysis records live in the existing arena. The reservation is unchanged from
main, and the arena grows only if a parse actually needs more.Parsing those 878 real ESM files through
parse()measures −1.1%, +0.6% and −1.1% againstmainover three runs — no measurable cost — and the 93 files that run the finalize walk land in the same band.Synthetic shapes, median of five ABBA rounds against
mainon Node 24 / V8 13.6:export … fromThe last two rows are the trade this design takes: they carry far more imports per clause export than real modules do, so they pay the clause re-read at a rate real code does not reach. Small modules carry a fixed cost of roughly 20 ns per parse — 187 → 207 ns at the Wasm boundary for
import { a } from 'dep'; export const b = a; export { a as c };.Artifact sizes:
lib/lexer.wasm11,800 → 14,991 bytes anddist/lexer.asm.js28,179 → 35,585. The minimal builds grow by 117 and 248 bytes.Fixes: #76
Refs: nodejs/import-in-the-middle#259