Refs #17942 · #17921
Context
Split out of #17942, which fixes the dist/esm resolution guard so the engine's own build can exit 0. One of the findings that guard surfaced does not close inside that ticket, and #17942 states the bound rather than asserting past it:
src/functional/util/HtmlTemplateProcessor.mjs:3 statically imports ../../../dist/parse5.mjs. #17942 copies that bundle into dist/esm/dist/parse5.mjs when it is present, which is the whole fix in this repository. In an installed engine it is never present, and cannot be produced.
The Problem
Three facts, each measured at dev, that compose into an unresolvable import for every consumer:
| fact |
evidence |
dist/ is excluded from the published package |
.npmignore:71 — /dist, with no negation restoring any part of it (grep '^!' .npmignore lists only apps/** entries) |
| the bundle is a build artifact, not a source file |
buildScripts/build/parse5.mjs:15 writes it via esbuild from node_modules/parse5/dist/index.js; dist/ is gitignored |
| a consumer cannot run that build |
parse5 and esbuild are both devDependencies, so an installed engine has neither the artifact nor its inputs |
So a workspace that installs neo.mjs and runs dist/esm copies HtmlTemplateProcessor.mjs into its output, and that file imports a bundle nothing can put there. Before #17921 the build exited 0 over the dangling import and the failure waited until a runtime that reached the template processor; after it, the build reds. The guard changed when it is discovered, not whether it is broken.
It is worse than an unresolvable emitted import, and this was measured rather than predicted. Removing dist/parse5.mjs and running the build does not produce a guard report at all — the process dies before it starts:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '…/dist/parse5.mjs'
imported from …/buildScripts/util/templateBuildProcessor.mjs
esmodules.mjs imports astTemplateProcessor, which imports templateBuildProcessor, which imports the bundle at module scope. So the bundle is a hard prerequisite of running the dist/esm build, not merely of the tree it emits — and an installed engine has no copy of it. Any option below must satisfy the build-time consumer inside the installed package as well as the runtime one.
This was invisible until a full-engine build ran. @neo-opus-vega's consumer-workspace receipt (#17942 context) counted it among the survivors; the engine-side build reproduces it from the other direction.
The Architectural Reality
src/functional/util/HtmlTemplateProcessor.mjs:3 — the consumer of the artifact, and the only src/ file that reaches outside the source roots.
buildScripts/util/templateBuildProcessor.mjs:2 imports the same bundle, but that is a build-time consumer inside this repository, where it exists. The runtime consumer is the one with no path to it.
buildScripts/build/parse5.mjs / package.json bundle-parse5 — the producer.
buildScripts/build/all.mjs:135 bundles parse5 as part of build-all, so the engine's own pipeline always has it.
- This is the same family as #17868 (dependencies resolved by filesystem path rather than module resolution) — a source file reaching a location instead of a package.
The Fix
The decision is which of these, and it has a release surface, which is why it is not folded into #17942:
- Publish the artifact. Add a negation for
dist/parse5.mjs in .npmignore, and ensure the release build produces it before packing. Smallest change; adds ~170 KB to the tarball and puts a build output in the published tree, which interacts with #17238.
- Promote the dependency and import the package. Move
parse5 to dependencies and have HtmlTemplateProcessor import it by package name, leaving the bundle to build tooling. Correct by module resolution; adds a runtime dependency to every consumer and changes what the browser has to resolve.
- Make the import lazy and optional.
HtmlTemplateProcessor obtains parse5 on first use and fails with a diagnostic when absent, so a workspace that never uses html templates is unaffected. Largest behavioural change, and it makes a capability conditional that is currently unconditional.
A fourth option — leave it and exempt the specifier — is rejected below.
Decision (2026-08-31): Option 1 — publish the artifact. Options 2 and 3 are rejected on measurement, not preference.
Option 2 (promote `parse5` to `dependencies`, import by package name) — REJECTED, infeasible without a new capability. `parse5@8.0.1` is `type: module`, and its internal modules import `entities/decode` and `entities/escape` — Node exports-map subpaths, which no browser resolves without an import map. `dist/esm` rewrites path-shaped `node_modules/…` specifiers (`esmDistTransforms.mjs#rewriteImportPaths`); it does not resolve exports maps, emit import maps, or copy arbitrary packages. So option 2 is not a `package.json` edit — it is "teach the engine exports-map resolution", which is a different ticket and a much larger one. This is also precisely why the esbuild bundle exists: `buildScripts/build/parse5.mjs` runs with `bundle: true`, inlining that subtree into one browser-resolvable file.
Option 3 (lazy/optional runtime import) — REJECTED, insufficient. It addresses the runtime consumer and leaves the build-time one broken. `buildScripts/util/templateBuildProcessor.mjs:2` imports the bundle at module scope (its own comment reads `// parse5 is bundled and available`), and `esmodules.mjs` → `astTemplateProcessor` → `templateBuildProcessor` means an installed engine dies with `ERR_MODULE_NOT_FOUND` before the build starts. Making that lazy too is strictly more change than option 1 for the same outcome, and it would make a build prerequisite conditional.
Option 1 measurements. The artifact is 163 KB (`dist/parse5.mjs`, measured — the ~170 KB above was an estimate). `npm pack --dry-run` is the authoritative read of what ships, and it confirms the premise from the packing tool rather than from `.npmignore`: of 5329 packed files, the only `parse5` entry is `buildScripts/build/parse5.mjs` (784 bytes — the producer, whose inputs a consumer does not have). The bundle itself is absent.
Why option 1 is the right shape and not merely the smallest: it is the only option that satisfies the build-time and runtime consumers with one change, because both want the same file at the same path. The objection — a build output in the published tree, interacting with `#17238` — is real but narrow: this is one deterministic 163 KB file reproducible from a pinned devDependency, not the generated-history problem `#17238` owns.
Contract Ledger Matrix
Added 2026-08-31 on @neo-gpt-emmy's RA-2 (PR #17957): the published package is a consumed surface and owed one.
| Target Surface |
Source of Authority |
Behavior |
Fallback |
Docs |
Evidence |
dist/parse5.mjs — the published package path |
this ticket |
present in every published tarball; the file HtmlTemplateProcessor imports by relative path and templateBuildProcessor imports at module scope |
none — its absence is what this ticket fixes, and the guard below makes it loud instead of silent |
.npmignore comment naming both consumers |
npm pack --dry-run --json lists it; same-tree delta is exactly +1 |
| release producer |
buildScripts/release/publish.mjs:146 → buildScripts/build/all.mjs:135 |
npm publish runs build-all, which bundles parse5 before packing |
none — unchanged by this ticket, verified by reading both call sites rather than assumed |
— |
the two call sites |
.npmignore inclusion rule |
this ticket |
/dist/* excludes the tree's contents; !/dist/parse5.mjs re-includes exactly one file |
a directory-shaped /dist rule silently defeats the negation — an ignore rule on a directory is not reversible |
inline comment stating that, beside the /apps/**/* precedent it copies |
reverting to /dist reds the guard |
| required-entry guard |
this ticket |
REQUIRED_ENTRIES / findMissingEntries in check-package-contents.mjs fail the pack when a required artifact is absent |
the forbidden-prefix half is unchanged; the two run independently |
script JSDoc explaining why presence needs its own gate |
four unit arms plus an end-to-end entrypoint mutation |
| CI fidelity |
this ticket |
the check job bundles parse5 before packing, so the tarball under test is release-shaped |
without it the job can only ever test the exclusion half, never presence |
workflow step comment |
the job red before the step, green after |
| consumer validation |
#17931 |
an installed engine builds dist/esm, boots, and parses a template through HtmlTemplateProcessor |
none — it is the only fixture that can observe this |
#17931's own ACs |
scope-transferred there; not claimed here |
Acceptance Criteria
Out of Scope
- #17942 — the guard corrections and the in-repo copy. This ticket does not change that copy; it decides what a consumer gets.
- #17238 — the pack-size consequence of what ships. Relevant input to option 1, not this ticket's decision.
- #17931 — the boot arm. It would exercise this, but a consumer-install fixture is a different shape from a workspace fixture.
Avoided Traps
- Exempting the specifier in the guard. It is a real broken import in the output tree. Silencing it restores the pre-#17921 state, where the failure simply waited for a runtime.
- Copying the bundle from
node_modules/neo.mjs/dist/. Measured: that directory is not published, so the copy has no source. A fallback that reads a path nothing writes is a silent no-op.
- Treating this as a
dist/esm defect. Webpack builds resolve it today because they bundle from this repository; the gap is what an installed engine carries, so a dist/esm-only fix would leave the same hole for any other consumer of src/.
Related
- #17942 — the guard fix that surfaced this and states the bound
- #17921 / PR #17932 — the guard itself
- #17868 — filesystem-path dependency resolution, same family
- #17238 — published pack size
- #7140 — the original parse5 bundling decision
Retrieval Hint: query_raw_memories("parse5 bundle npmignore consumer dist/esm HtmlTemplateProcessor unresolvable"); anchors .npmignore:71, buildScripts/build/parse5.mjs:15, src/functional/util/HtmlTemplateProcessor.mjs:3.
Live latest-open sweep: checked the latest 20 open neomjs/neo issues at 2026-08-31T13:44:43Z plus a parse5 search over all states (#11163 and #7140 are the closed worktree-bootstrap and original-bundling tickets, neither covering the published package) and the A2A claim window (last 12 messages) — no equivalent ticket and no in-flight claim. Structure-map gate: N/A — no new or relocated .mjs.
Origin Session ID: 57505599-d711-4464-b17d-4cfff3dceee4
Authored by Grace (Anthropic Claude Opus 5, Claude Code).
Refs #17942 · #17921
Context
Split out of #17942, which fixes the
dist/esmresolution guard so the engine's own build can exit 0. One of the findings that guard surfaced does not close inside that ticket, and #17942 states the bound rather than asserting past it:src/functional/util/HtmlTemplateProcessor.mjs:3statically imports../../../dist/parse5.mjs. #17942 copies that bundle intodist/esm/dist/parse5.mjswhen it is present, which is the whole fix in this repository. In an installed engine it is never present, and cannot be produced.The Problem
Three facts, each measured at
dev, that compose into an unresolvable import for every consumer:dist/is excluded from the published package.npmignore:71—/dist, with no negation restoring any part of it (grep '^!' .npmignorelists onlyapps/**entries)buildScripts/build/parse5.mjs:15writes it via esbuild fromnode_modules/parse5/dist/index.js;dist/is gitignoredparse5andesbuildare both devDependencies, so an installed engine has neither the artifact nor its inputsSo a workspace that installs
neo.mjsand runsdist/esmcopiesHtmlTemplateProcessor.mjsinto its output, and that file imports a bundle nothing can put there. Before #17921 the build exited 0 over the dangling import and the failure waited until a runtime that reached the template processor; after it, the build reds. The guard changed when it is discovered, not whether it is broken.It is worse than an unresolvable emitted import, and this was measured rather than predicted. Removing
dist/parse5.mjsand running the build does not produce a guard report at all — the process dies before it starts:esmodules.mjsimportsastTemplateProcessor, which importstemplateBuildProcessor, which imports the bundle at module scope. So the bundle is a hard prerequisite of running thedist/esmbuild, not merely of the tree it emits — and an installed engine has no copy of it. Any option below must satisfy the build-time consumer inside the installed package as well as the runtime one.This was invisible until a full-engine build ran. @neo-opus-vega's consumer-workspace receipt (#17942 context) counted it among the survivors; the engine-side build reproduces it from the other direction.
The Architectural Reality
src/functional/util/HtmlTemplateProcessor.mjs:3— the consumer of the artifact, and the onlysrc/file that reaches outside the source roots.buildScripts/util/templateBuildProcessor.mjs:2imports the same bundle, but that is a build-time consumer inside this repository, where it exists. The runtime consumer is the one with no path to it.buildScripts/build/parse5.mjs/package.jsonbundle-parse5— the producer.buildScripts/build/all.mjs:135bundles parse5 as part ofbuild-all, so the engine's own pipeline always has it.The Fix
The decision is which of these, and it has a release surface, which is why it is not folded into #17942:
dist/parse5.mjsin.npmignore, and ensure the release build produces it before packing. Smallest change; adds ~170 KB to the tarball and puts a build output in the published tree, which interacts with #17238.parse5todependenciesand haveHtmlTemplateProcessorimport it by package name, leaving the bundle to build tooling. Correct by module resolution; adds a runtime dependency to every consumer and changes what the browser has to resolve.HtmlTemplateProcessorobtains parse5 on first use and fails with a diagnostic when absent, so a workspace that never useshtmltemplates is unaffected. Largest behavioural change, and it makes a capability conditional that is currently unconditional.A fourth option — leave it and exempt the specifier — is rejected below.
Decision (2026-08-31): Option 1 — publish the artifact. Options 2 and 3 are rejected on measurement, not preference.
Option 2 (promote `parse5` to `dependencies`, import by package name) — REJECTED, infeasible without a new capability. `parse5@8.0.1` is `type: module`, and its internal modules import `entities/decode` and `entities/escape` — Node exports-map subpaths, which no browser resolves without an import map. `dist/esm` rewrites path-shaped `node_modules/…` specifiers (`esmDistTransforms.mjs#rewriteImportPaths`); it does not resolve exports maps, emit import maps, or copy arbitrary packages. So option 2 is not a `package.json` edit — it is "teach the engine exports-map resolution", which is a different ticket and a much larger one. This is also precisely why the esbuild bundle exists: `buildScripts/build/parse5.mjs` runs with `bundle: true`, inlining that subtree into one browser-resolvable file.
Option 3 (lazy/optional runtime import) — REJECTED, insufficient. It addresses the runtime consumer and leaves the build-time one broken. `buildScripts/util/templateBuildProcessor.mjs:2` imports the bundle at module scope (its own comment reads `// parse5 is bundled and available`), and `esmodules.mjs` → `astTemplateProcessor` → `templateBuildProcessor` means an installed engine dies with `ERR_MODULE_NOT_FOUND` before the build starts. Making that lazy too is strictly more change than option 1 for the same outcome, and it would make a build prerequisite conditional.
Option 1 measurements. The artifact is 163 KB (`dist/parse5.mjs`, measured — the ~170 KB above was an estimate). `npm pack --dry-run` is the authoritative read of what ships, and it confirms the premise from the packing tool rather than from `.npmignore`: of 5329 packed files, the only `parse5` entry is `buildScripts/build/parse5.mjs` (784 bytes — the producer, whose inputs a consumer does not have). The bundle itself is absent.
Why option 1 is the right shape and not merely the smallest: it is the only option that satisfies the build-time and runtime consumers with one change, because both want the same file at the same path. The objection — a build output in the published tree, interacting with `#17238` — is real but narrow: this is one deterministic 163 KB file reproducible from a pinned devDependency, not the generated-history problem `#17238` owns.
Contract Ledger Matrix
Added 2026-08-31 on @neo-gpt-emmy's RA-2 (PR #17957): the published package is a consumed surface and owed one.
dist/parse5.mjs— the published package pathHtmlTemplateProcessorimports by relative path andtemplateBuildProcessorimports at module scope.npmignorecomment naming both consumersnpm pack --dry-run --jsonlists it; same-tree delta is exactly +1buildScripts/release/publish.mjs:146→buildScripts/build/all.mjs:135npm publishrunsbuild-all, which bundles parse5 before packing.npmignoreinclusion rule/dist/*excludes the tree's contents;!/dist/parse5.mjsre-includes exactly one file/distrule silently defeats the negation — an ignore rule on a directory is not reversible/apps/**/*precedent it copies/distreds the guardREQUIRED_ENTRIES/findMissingEntriesincheck-package-contents.mjsfail the pack when a required artifact is absentdist/esm, boots, and parses a template throughHtmlTemplateProcessorAcceptance Criteria
A consumer-shaped fixture — an engine installed as a package, not this checkout — buildsScope-transferred to #17931, which owns the installed-engine fixture this requires; it could never be satisfied from here.dist/esmand the emittedHtmlTemplateProcessorimport resolves.Scope-transferred to #17931, same reason — it needs that fixture's output to run in.HtmlTemplateProcessorloads and parses a template in that consumer output.checkPackageContents.spec.mjsalready owns that surface — so a future.npmignoreedit cannot silently drop it again.templateBuildProcessor.mjs) is unaffected either way.Out of Scope
Avoided Traps
node_modules/neo.mjs/dist/. Measured: that directory is not published, so the copy has no source. A fallback that reads a path nothing writes is a silent no-op.dist/esmdefect. Webpack builds resolve it today because they bundle from this repository; the gap is what an installed engine carries, so adist/esm-only fix would leave the same hole for any other consumer ofsrc/.Related
Retrieval Hint:
query_raw_memories("parse5 bundle npmignore consumer dist/esm HtmlTemplateProcessor unresolvable"); anchors.npmignore:71,buildScripts/build/parse5.mjs:15,src/functional/util/HtmlTemplateProcessor.mjs:3.Live latest-open sweep: checked the latest 20 open
neomjs/neoissues at 2026-08-31T13:44:43Z plus aparse5search over all states (#11163 and #7140 are the closed worktree-bootstrap and original-bundling tickets, neither covering the published package) and the A2A claim window (last 12 messages) — no equivalent ticket and no in-flight claim. Structure-map gate: N/A — no new or relocated.mjs.Origin Session ID: 57505599-d711-4464-b17d-4cfff3dceee4
Authored by Grace (Anthropic Claude Opus 5, Claude Code).