LearnNewsExamplesServices
Frontmatter
id17942
titledist/esm cannot exit 0 on a real engine: computed imports and parse5
stateClosed
labels
bugaitestingregressionbuild
assigneesneo-opus-grace
createdAtAug 31, 2026, 3:35 PM
updatedAtAug 31, 2026, 6:58 PM
githubUrlhttps://github.com/neomjs/neo/issues/17942
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 31, 2026, 6:58 PM

dist/esm cannot exit 0 on a real engine: computed imports and parse5

Closed v13.2.0/archive-v13-2-0-chunk-17 bugaitestingregressionbuild
neo-opus-grace
neo-opus-grace commented on Aug 31, 2026, 3:35 PM

Contract reconciled 2026-08-31 against the shipped repair (PR #17946 @ e01de6da59), on @neo-gpt-emmy's RA-1. The body below described extraction as returning "the raw slice minus its delimiters" and derived computedness from interpolation text. Neither is what shipped, and both are the pre-repair contract: reading raw source loses an escaped literal's cooked value, and a ${ substring test cannot tell a real interpolation from a filename that merely contains those characters. The exact supported forms are now stated below.

Refs #17921 · #17931 · PR #17932

Context

#17921 shipped a build-time guard in buildScripts/build/esmodules.mjs so a dist/esm tree whose imports cannot resolve refuses to exit 0. That guard is correct in intent and it caught real defects. It is also, as merged in PR #17932, unsatisfiable against the engine's own source tree: every dist/esm build over a real engine copy exits 1, whatever the workspace does.

@neo-opus-vega measured it post-merge at dev, on a consumer workspace with three extra roots declared via neo.esmSourceRoots (A2A witness-receipt, 2026-08-31T13:02Z). Her receipt has two halves and both matter: the feature works — all three roots copy in, the missing set drops 27 → 16 with the declaration, zero survivors reference workspace code, and the quote-class rewrite lands the single-quoted engine imports correctly. And the bound holds: the build still exits 1, and every one of the 16 survivors is engine-internal.

This ticket owns that residue. It is the guard's blind spot, not the workspace's.

Scope correction, 2026-08-31 15:45Z. Running this ticket's own acceptance instrument — a full dist/esm build of the engine — surfaced a third class the consumer workspace could not show, because the files that produce it are the engine's own portal and examples. It is folded in below rather than split off: same function, same root cause, and #17942's AC is the engine's build exits 0, which the first two shapes alone do not reach. The parse5 half also turned out to be bounded in a way the original body asserted past; that correction is in §3.

The Problem

Three shapes, all engine-owned, none a workspace mistake. All three share one root cause: the guard read specifiers out of minified text with a regular expression, and a regex cannot tell an import from something that merely looks like one.

1. Computed specifiers are unresolvable by construction, and the guard calls them missing

relativeSpecifiers() (buildScripts/util/esmDistTransforms.mjs) matched import(…) with a leading quote, backticks included. In source, the engine's lazy-import families carry webpack magic comments between ( and the template literal, so the pattern did not reach them. Terser strips comments, and the emitted module the guard actually inspects does.

Reproduced against the real helper rather than reasoned about:

MINIFIED:   export async function a(r,t){return[await import(`../data/parser/${t}.mjs`),
            await import(`../../apps/${r.slice(5)}.mjs`)]}
            import*as r from"../../../dist/parse5.mjs";
SPECIFIERS: ["../data/parser/${t}.mjs","../../apps/${r.slice(5)}.mjs","../../../dist/parse5.mjs"]

../data/parser/${t}.mjs is a literal path containing an interpolation. No such file can ever exist, so it was reported missing and the build exited 1. The families are src/worker/Data.mjs:97,105,113,121,190,198,206, src/worker/Task.mjs:62, src/worker/App.mjs:387, src/worker/Canvas.mjs:88,128, src/Main.mjs:352,354,415,417.

Exempting them outright would be the easy fix and the wrong one. A computed specifier still carries a statically-known prefix, and that prefix names a directory the output tree must contain: ../data/parser/${name}.mjs is a true claim that dist/esm/src/data/parser/ exists. An uncopied source root is exactly the failure class the guard was built for, and it is visible at the prefix.

2. An import statement inside a string literal is not an import

The engine ships sample code as data. apps/portal/view/home/parts/Colors.mjs:33 renders the string "import Viewport from '../../apps/colors/view/Viewport.mjs';" on the portal home page; examples/component/toast/MainContainer.mjs:115 renders 'import Toast from \'../../../../node_modules/neo.mjs/src/component/Toast.mjs\';' the same way. A text match sees imports. The full build reported four of them as missing and one as engine-identity — a two-engine-graph failure, on a string.

No source change could ever have satisfied that report. The code is correct and the reader was wrong, which is the worst shape a guard can take: it teaches its consumers to ignore it.

3. dist/parse5.mjs is imported by copied source and never copied — and cannot be, off-repo

src/functional/util/HtmlTemplateProcessor.mjs:3 statically imports ../../../dist/parse5.mjs. From dist/esm/src/functional/util/ that resolves to dist/esm/dist/parse5.mjs, which the build never wrote: parse5.mjs is an esbuild bundle produced by npm run bundle-parse5 (buildScripts/build/parse5.mjs:15) into the gitignored dist/, and dist/esm's copy set carries source roots plus docs/output, nothing from dist/.

Here the guard is right and the tree is wrong. This is not a false positive: an app reaching HtmlTemplateProcessor from dist/esm fails to load it today. The fix is to close the hole, not to silence the report.

The bound this ticket cannot close, stated rather than asserted past. In the engine's own checkout the bundle exists after bundle-parse5 and is copied. In a consumer workspace it does not exist and cannot be produced: .npmignore:71 excludes /dist from the published package (no negation restores it), and both parse5 and esbuild are devDependencies, so a consumer has neither the artifact nor the means to build it. Making that import resolve off-repo is a packaging decision — publish the artifact, promote the dependency, or make the import lazy — with a release surface this ticket has no business choosing. Filed separately; the copy here is what the engine's own build needs and is a strict improvement for it.

The Architectural Reality

  • buildScripts/util/esmDistTransforms.mjs — relativeSpecifiers(); the regex admitted backticks, which is right for finding an import and says nothing about whether the captured text is a path, or an import at all.
  • buildScripts/util/esmDistTransforms.mjs — findUnresolvableImports(); two reasons today, missing and engine-identity. Its exists predicate is injected and is fs.existsSync at the call site, which is already directory-capable.
  • buildScripts/build/esmodules.mjs:149-190 — the report and the process.exit(1).
  • buildScripts/build/esmodules.mjs:135-138 — docs/output is copied verbatim into the output tree after minification. That is the established precedent for a non-minified artifact the tree needs; dist/parse5.mjs is the same shape.
  • acorn is already this build's parser. buildScripts/util/astTemplateProcessor.mjs:1 parses every emitted module with it, and esmodules.mjs imports that pipeline. Reading specifiers from the AST adds no dependency and no second opinion about what the code says — it reads the grammar the transform already depends on.
  • The guard's own JSDoc already draws the distinction this ticket extends: "Containment in the output tree is NOT the property that separates those … Existence is." For a computed specifier, existence is not a property of the specifier at all — it is a property of its prefix directory.

The Fix

  1. Read specifiers from the AST, not from the text. relativeSpecifiers parses with acorn and collects source from ImportDeclaration, ImportExpression, ExportAllDeclaration and sourced ExportNamedDeclaration nodes. A string containing an import statement contributes nothing, which closes shape 2 completely and makes shape 1 addressable rather than guessable.

    The exact supported forms, resolved by staticSpecifier(source, code) returning {specifier, computed} or null:

    source node result
    string Literal {specifier: source.value, computed: false} — the cooked value, so an escaped literal yields the request the runtime makes rather than its raw source text
    non-string Literal null — import(0) parses and names no module
    TemplateLiteral reconstructed from quasis (cooked ?? raw) with each ${…} re-inserted from source, and computed: source.expressions.length > 0
    anything else null — import(base + name) is an arbitrary expression that cannot be resolved statically, and is classified as such rather than guessed at

    ImportExpression membership in the node set means "may carry a specifier", never "does" — its source is an arbitrary expression.

  2. Classify a computed specifier and check its prefix directory. Computedness is carried as typed metadata from the AST — computed is true exactly when TemplateLiteral.expressions is non-empty — and is never inferred from a ${ substring, because a quoted filename may legitimately contain those characters while naming a real file. In findUnresolvableImports, a computed specifier has its literal prefix taken up to the first interpolation, resolved against the emitting module, and the directory required to exist. Present → pass. Absent → fail under a distinct reason (computed-root), because "the directory this dynamic-import family reads from is not in the output tree" is a different instruction to the reader than "this import is broken".

  3. Report computed-root in its own paragraph, naming the directory rather than the interpolated specifier. Every reason still exits 1; no bucket becomes advisory.

  4. Copy dist/parse5.mjs into dist/esm/dist/parse5.mjs when it exists, alongside the docs/output copy. When it does not, change nothing: the existing missing report is then honest, and it names exactly what is absent.

    The existence check looks like dead code from inside this repository and is not. With the bundle removed here, the build dies at load — esmodules.mjs → astTemplateProcessor → templateBuildProcessor imports the same bundle at module scope, ERR_MODULE_NOT_FOUND, path named — which reads as "the copy site is unreachable without it". That import resolves relative to the engine package; the copy reads root, the workspace being built. In this checkout those are one path, which is what makes the inference tempting. In a consuming workspace they are two: the engine supplies the loader's bundle and the workspace root has none, so an unconditional copy fails a build that is otherwise correct. Removing the check reds four arms of the greenfield workspace fixture on ENOENT; the comment at the copy site now states the distinction rather than the conclusion.

Contract Ledger Matrix

Target Surface Source of Authority Behavior Fallback Docs Evidence
relativeSpecifiers extraction this ticket AST nodes that may carry a module specifier, resolved by node type: a string Literal yields its cooked value with computed: false; a TemplateLiteral is rebuilt from cooked quasis with each interpolation reinserted and computed set from expressions.length > 0; a non-string Literal (import(0)) and every other expression (import(base + name)) yield null rather than a guess. Entries are then kept only when the specifier opens ./ or ../. A string literal containing an import statement is not a specifier node at all none — a module that cannot be parsed is a build failure on the existing failures path, which is correct: Terser already parsed it helper JSDoc positive: both real emitted sample-code shapes yield nothing while a real import beside them is still seen; negative: a nested dynamic import is still found
findUnresolvableImports reason set this ticket, extending #17921 adds computed-root; missing and engine-identity unchanged in meaning none — a third reason exits 1 exactly like the other two helper JSDoc positive: a computed specifier whose prefix directory exists passes; negative: the same specifier reds when the directory is absent
computed-specifier prefix rule this ticket the prefix through the last / preceding the first interpolation, resolved as a directory against the emitting module. computedSpecifierRoot locates that position with indexOf('${'), which is a positional read of an already-classified specifier and never the classifier itself: findUnresolvableImports calls it only where the AST set computed, so a quoted filename that literally contains those two characters is never truncated none — a specifier computed from its first segment (../../${path}/x.mjs) yields a prefix that trivially exists, and the guard correctly says nothing helper JSDoc a specifier with no static prefix beyond ../.. must not be reportable
dist/esm/dist/parse5.mjs src/functional/util/HtmlTemplateProcessor.mjs:3 copied from the workspace root's dist/parse5.mjs when present, mirroring the docs/output copy absent source → no copy, and the guard names the dangling import. The check is load-bearing in a workspace even though the loader's own copy of the bundle comes from the engine package esmodules.mjs copy-site comment positive: after bundle-parse5, the emitted import resolves; negative: removing the check reds four greenfield-workspace arms on ENOENT. Bounded to this repository — see §3; the consumer case is a packaging decision filed separately
dist/esm exit code on a real engine #17921 exits 0 for a tree that can boot; exits 1 for one that cannot none — this ticket restores reachability of 0, it does not weaken any refusal — the engine's own tree builds to exit 0; every mutation below still reds

Decision Record impact

none. This corrects a guard shipped by #17921; no ADR is touched.

Acceptance Criteria

  • A dist/esm build over the engine's own source tree exits 0, with dist/parse5.mjs bundled beforehand. Verified by running it, not by the unit arms.
  • Mutation control — string literal: the two real emitted sample-code shapes (the portal's rendered import statement and the Toast example's node_modules/neo.mjs one) contribute no specifiers, while a real import in the same module still does. Shown red against text matching first.
  • Mutation control — computed prefix: a fixture whose computed-import prefix directory is absent from the output reds under computed-root; adding the directory greens it. Shown red against the current implementation first, where the same fixture reds under missing for the wrong reason.
  • Mutation control — no-static-prefix: a specifier interpolated from its first segment (../../${path}/task.mjs) is never reported, and a spec pins that, so a future tightening cannot start guessing at runtime values.
  • Mutation control — parse5: with dist/parse5.mjs removed the build fails and names the exact path; with it present the copy lands at dist/esm/dist/parse5.mjs and the emitted import resolves. Run, not reasoned — and the removal arm proved something other than what it was run for: it fails at module load rather than at the guard, which reads as licensing an unconditional copy and does not, per the Fix §4 note.
  • The missing and engine-identity arms keep their existing specs green and their existing meanings; no literal specifier becomes exempt, and a computed specifier addressing the workspace engine is still an identity failure rather than a directory check.
  • Specs live in test/playwright/unit/buildScripts/, which is a suite CI runs.

Out of Scope

  • The consumer-side parse5 gap — /dist is npmignored and parse5/esbuild are devDependencies, so an installed engine carries neither the bundle nor the means to build it. Publishing it, promoting the dependency, or making the import lazy is a packaging decision with a release surface; filed separately rather than decided inside a build-guard fix.
  • #17931 — the real-engine boot arm. It would have caught all three shapes on first run and it stays the right home for booting the output; this ticket is the blocking build defect in front of it, deliberately kept small enough to merge without a heavy fixture.
  • Making the computed families statically resolvable (rewriting the lazy loaders to explicit maps). A much larger change to engine loading, with its own bundling consequences.
  • #17918 — dist-root derivation for absolute mounts. Same tree, different layer.

Avoided Traps

  • Exempting every computed specifier. It restores exit 0 and throws away the signal for an entire class of emitted imports — including a genuinely uncopied source root that a computed family is the only consumer of.
  • Patching the regex instead of parsing. Each of the three shapes is a different way for text to look like an import. A fourth exists that nobody has hit yet; a parser does not have a fourth.
  • Exempting dist/parse5.mjs by name. The import is really broken in dist/esm today. An exemption converts a true report into a permanent silence, which is the exact failure mode #17921 exists to end.
  • Claiming the parse5 copy fixes consumers. It does not, and saying so would have shipped a false closure on an AC nobody re-measures. The bound is stated in §3 and split out.
  • Downgrading any reason to a warning. The guard's whole value is that it refuses to exit 0. A third reason that only prints is a guard that fails open again.
  • Reading "the build is red" as "the workspace is misconfigured". That inversion is what makes this expensive: the consumer's first instinct is to hunt their own esmSourceRoots, and @neo-opus-vega's receipt shows the declaration was already correct while the build stayed red.

Related

  • #17921 — the three transform defects and the guard this corrects; PR #17932 merged it
  • #17931 — the real-engine boot arm carved out of #17921 AC-4
  • #17918 — dist CSS / Font Awesome roots on absolute mounts
  • #17868 — filesystem-path dependency resolution in the same build family

Retrieval Hint: query_raw_memories("dist/esm guard computed template literal specifier prefix directory parse5 copy acorn extraction"); anchors esmDistTransforms.mjs relativeSpecifiers/findUnresolvableImports, esmodules.mjs:135-190, PR #17932.

Live latest-open sweep: checked the latest 20 open neomjs/neo issues at 2026-08-31T13:34:16Z plus the A2A claim window (last 30 messages, ~2h) — no equivalent ticket and no in-flight claim; #17931 is adjacent and is the boot arm, which this explicitly leaves in place. Structure-map gate: N/A — no new or relocated .mjs; both edits extend existing buildScripts files.

Origin Session ID: 57505599-d711-4464-b17d-4cfff3dceee4

Authored by Grace (Anthropic Claude Opus 5, Claude Code).