LearnNewsExamplesServices
Frontmatter
id17961
titleTwo build programs resolve webpack and parse5 from the consumer''s cwd, so a hoisted install cannot build
stateClosed
labels
bugaiarchitecturebuild
assigneesneo-opus-grace
createdAtAug 31, 2026, 7:12 PM
updatedAtAug 31, 2026, 9:07 PM
githubUrlhttps://github.com/neomjs/neo/issues/17961
authorneo-opus-grace
commentsCount1
parentIssue17868
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 31, 2026, 9:07 PM

Two build programs resolve webpack and parse5 from the consumer's cwd, so a hoisted install cannot build

Closed v13.2.0/archive-v13-2-0-chunk-17 bugaiarchitecturebuild
neo-opus-grace
neo-opus-grace commented on Aug 31, 2026, 7:12 PM

Problem

Two build programs locate a third-party dependency by a cwd-relative filesystem path:

site specifier
buildScripts/webpack/buildThreads.mjs:21 let webpack = './node_modules/.bin/webpack';
buildScripts/build/parse5.mjs:11 entryPoints: ['node_modules/parse5/dist/index.js']

Neither is resolved relative to the script. buildScripts/build/all.mjs spawns both with cpOpts, whose cwd is process.cwd() — line 136 spawns ${neoPath}/buildScripts/build/parse5.mjs and line 150 spawns ${webpackPath}/buildThreads.mjs. So each literal is read from wherever the build was started, and only holds for a checkout whose node_modules sits exactly one level below that directory.

npm hoists. A consumer installing the engine as a dependency gets its own layout, the literals point at nothing, and each fails as a bare ENOENT / "Could not resolve" that names a path but never the cause — which is why the class was diagnosed one site at a time rather than once.

Reproduced. Running buildScripts/build/parse5.mjs's esbuild call from a foreign cwd reds with Could not resolve "node_modules/parse5/dist/index.js". From the repo root the identical call succeeds. The only variable is cwd.

The Architectural Reality

buildScripts/build/highlightJs.mjs — the file directly beside parse5.mjs — already does this correctly: neoPath = path.resolve(__dirname, '../../'), derived from the module, never from cwd. buildScripts/util/prepare.mjs:36 goes further, resolving husky's entry from the package's own bin declaration rather than assuming an entry point, with the rationale documented in its JSDoc. The correct idiom is already in this directory twice; these two sites predate it.

Two measurements that constrain the fix:

  • require.resolve('parse5') resolves to node_modules/parse5/dist/index.js — byte-identical to the hardcoded target — while require.resolve('parse5/dist/index.js') throws ERR_PACKAGE_PATH_NOT_EXPORTED, because parse5's exports map does not expose that subpath. The bare specifier is the only working module-resolution form, and it happens to look less like the literal it replaces. That is a trap for a future reader.
  • webpack declares bin: {"webpack": "bin/webpack.js"} and carries no exports field. Targeting the declared JS entry and launching it with node — the dispatch all.mjs already uses for every script it spawns — removes the .bin shim from the picture entirely. That matters on Windows, where a .bin entry is a .cmd script rather than an executable image; it is the constraint highlightJs.mjs documents, and the reason buildThreads.mjs carries a path.sep === '\\' special case that then becomes unnecessary.

Acceptance Criteria

  • buildScripts/webpack/buildThreads.mjs resolves the webpack entry through module resolution, from its own module URL rather than process.cwd().
  • buildScripts/build/parse5.mjs resolves its entry point the same way, via the bare specifier.
  • Coverage asserts resolution against real hoisted and nested node_modules layouts on disk, not a mocked resolver — a mock that returns a path passes for a resolver that ignores hoisting entirely.
  • A mutation control proves the coverage can fail: regressing the resolver to a cwd-relative .bin form must red.
  • The parse5 bare-vs-subpath trap is pinned by an assertion, so a future "cleanup" toward the visible path reds instead of shipping.

Out of Scope

  • The browser-loaded marked imports (src/component/Markdown.mjs, src/app/content/Component.mjs). That is #17868's third site and an explicitly deferred maintainer decision, not a line to patch — dev mode loads ES modules directly in the browser with no import map, so the relative path is load-bearing. It stays on #17868.
  • The --env/shell: true argv shape in buildThreads.mjs. Every argument there is interpolated into a shell string unquoted, so a path containing a space splits — the same class highlightJs.mjs was hardened against. Real, pre-existing, and independent of resolution; bundling it would make the resolution change unreviewable.

Related

  • #17868 — the parent class ticket, which names all three sites and stays open for the marked decision.
  • #17943 — a consumer cannot resolve the parse5 bundle (dist is npmignored). Same dependency, different layer: that one is about shipping the artifact, this one about locating the source.

Live latest-open sweep: checked latest 20 open issues at 2026-08-31T17:11Z; no equivalent found. A2A in-flight claim sweep over the last 30 messages: the only #17868-scope claim is my own [lane-claim] at 17:01:54Z.

Origin Session ID: da6b927c-0fb8-404a-92af-402c790d1b14