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
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
Problem
Two build programs locate a third-party dependency by a cwd-relative filesystem path:
buildScripts/webpack/buildThreads.mjs:21let webpack = './node_modules/.bin/webpack';buildScripts/build/parse5.mjs:11entryPoints: ['node_modules/parse5/dist/index.js']Neither is resolved relative to the script.
buildScripts/build/all.mjsspawns both withcpOpts, whosecwdisprocess.cwd()— line 136 spawns${neoPath}/buildScripts/build/parse5.mjsand line 150 spawns${webpackPath}/buildThreads.mjs. So each literal is read from wherever the build was started, and only holds for a checkout whosenode_modulessits 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 withCould 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 besideparse5.mjs— already does this correctly:neoPath = path.resolve(__dirname, '../../'), derived from the module, never from cwd.buildScripts/util/prepare.mjs:36goes further, resolving husky's entry from the package's ownbindeclaration 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 tonode_modules/parse5/dist/index.js— byte-identical to the hardcoded target — whilerequire.resolve('parse5/dist/index.js')throwsERR_PACKAGE_PATH_NOT_EXPORTED, because parse5'sexportsmap 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.bin: {"webpack": "bin/webpack.js"}and carries noexportsfield. Targeting the declared JS entry and launching it withnode— the dispatchall.mjsalready uses for every script it spawns — removes the.binshim from the picture entirely. That matters on Windows, where a.binentry is a.cmdscript rather than an executable image; it is the constrainthighlightJs.mjsdocuments, and the reasonbuildThreads.mjscarries apath.sep === '\\'special case that then becomes unnecessary.Acceptance Criteria
buildScripts/webpack/buildThreads.mjsresolves the webpack entry through module resolution, from its own module URL rather thanprocess.cwd().buildScripts/build/parse5.mjsresolves its entry point the same way, via the bare specifier.node_moduleslayouts on disk, not a mocked resolver — a mock that returns a path passes for a resolver that ignores hoisting entirely..binform must red.Out of Scope
markedimports (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.--env/shell: trueargv shape inbuildThreads.mjs. Every argument there is interpolated into a shell string unquoted, so a path containing a space splits — the same classhighlightJs.mjswas hardened against. Real, pre-existing, and independent of resolution; bundling it would make the resolution change unreviewable.Related
markeddecision.distis 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