[GH-ISSUE #3214] [Bug] Relative ESM imports with tabs or newlines remain unresolved despite present target files #1150

Closed
opened 2026-10-15 18:31:32 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @coygeek on GitHub (Oct 14, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/3214

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Evidence, artifacts, or reconstruction

Observed behavior

The static JavaScript application analyzer reports valid relative ESM imports containing escaped tabs or newlines as not-found, even when Node loads the same application. The CLI exits 0 with a normal Evidence envelope. The static-import edges retain the original specifiers and resolution_mode: "import", but their resolved_path values are null. An ordinary relative import to the same file resolves correctly.

Reproduction

  1. Start in a clean checkout of revision 58300885207133422af235e0f2b10658cf958b03, then install the locked dependencies and build the CLI:
npm ci
npm run build:cached

These prerequisite commands were not repeated during independent verification because the baseline checkout already had installed dependencies and a prepared build. The fixture, CLI analysis, Node execution, and controls below were independently executed.

  1. Create an extracted application and analyze it, then run the separate Node oracle. The Python strings write JavaScript escape sequences into the source. The JavaScript string values contain actual tab and newline characters:
python3 - <<'PYFIXTURE'
from pathlib import Path
app = Path('repro-esm/app')
app.mkdir(parents=True, exist_ok=True)
(app / 'package.json').write_text('{"type":"module"}\n')
(app / 'index.mjs').write_text(
    'import "./tab\\tname.js";\n'
    'import "./nl\\nname.js";\n'
    'import "./tabname.js";\n'
)
for name in ('tabname.js', 'nlname.js'):
    (app / name).write_text('export const loaded = true;\n')
(app / 'oracle.mjs').write_text(
    'console.log(JSON.stringify(["./tab\\tname.js", "./nl\\nname.js", "./tabname.js"]'
    '.map(spec => ({spec, resolved: import.meta.resolve(spec).split("/").at(-1)})), null, 2));\n'
)
PYFIXTURE
node scripts/rea.mjs analyze-javascript-application repro-esm --json > repro-esm-result.json
node repro-esm/app/index.mjs
node repro-esm/app/oracle.mjs
  1. Extract the static-import properties from the successful CLI envelope:
python3 - <<'PYRESULT'
import json
from pathlib import Path
result = json.loads(Path('repro-esm-result.json').read_text())
for edge in result['normalized_result']['graph']['edges']:
    properties = edge.get('properties', {})
    if properties.get('kind') == 'static-import':
        print(json.dumps({key: properties[key] for key in
            ('specifier', 'resolution_mode', 'resolved_path', 'resolution_status')}))
PYRESULT

The two specifiers containing control characters have null resolved_path values and resolution_status: "not-found". The plain import resolves to app/tabname.js. Node executes index.mjs successfully, and oracle.mjs selects tabname.js, nlname.js, and tabname.js, respectively.

  1. Run the percent-encoded ESM and literal CommonJS controls. The Python filename strings contain actual tabs:
python3 - <<'PYCONTROLS'
from pathlib import Path
app = Path('repro-esm-controls')
app.mkdir(exist_ok=True)
(app / 'package.json').write_text('{"type":"module"}\n')
(app / 'index.mjs').write_text(
    'import "./tabname.js";\n'
    'import "./nlname.js";\n'
    'import "./literal%09name.js";\n'
)
for name in ('tabname.js', 'nlname.js', 'literal\tname.js'):
    (app / name).write_text('export const loaded = true;\n')
(app / 'literal.cjs').write_text('module.exports = true;\n')
(app / 'literal\tname.cjs').write_text('module.exports = true;\n')
(app / 'require.cjs').write_text('require("./literal\\tname.cjs");\n')
PYCONTROLS
node scripts/rea.mjs analyze-javascript-application repro-esm-controls --json > repro-esm-controls-result.json
node repro-esm-controls/index.mjs
node repro-esm-controls/require.cjs

Both Node control executions exit 0. REA also exits 0 and resolves the ordinary imports, the percent-encoded ESM specifier to the literal-tab filename literal\tname.js, and the CommonJS specifier to the literal-tab filename literal\tname.cjs. These controls distinguish URL text preprocessing from percent decoding and CommonJS literal filenames.

Expected behavior

docs/javascript-artifact-reconstruction.md:597-598 documents URL suffix handling and one percent-decoding pass for ESM relative module paths. For this fixture, relative ESM resolution should select the same files as the independently executed Node resolver: ./tab\tname.js should resolve to app/tabname.js, and ./nl\nname.js should resolve to app/nlname.js. Preserve the original specifier strings and import resolution mode.

Node's observed ESM resolution removes tabs and newlines from the URL text before file lookup. The explicit tab/newline promise in docs/javascript-artifact-reconstruction.md:610-611 applies to selected package targets, so that narrower passage corroborates URL preprocessing rather than explicitly promising it for all relative imports. The direct Node execution and import.meta.resolve probe establish the file selection for the relative imports in this reproduction.

Impact and scope

Affected imports remain unresolved in the application's static relationship graph even though the target files are present and Node can load them. The reproduced scope is relative ESM strings containing tabs or newlines. The imports remain visible, analysis completes normally, and the ordinary-import and literal-filename controls resolve. Downstream tracing behavior was not exercised. The reproduction crossed no security boundary and required no native analysis engine or external service.

Independent verification also analyzed a copy with the affected strings replaced by ./tabname.js and ./nlname.js; all three imports then resolved. An analyst can inspect the retained raw specifiers or analyze such a disposable copy. Its Evidence describes changed artifact bytes and must not be presented as Evidence for the original artifact.

REA version

6.4.0 (main at 5830088520)

Runtime and platform

Node 26.10.0; macOS arm64. The verifier did not record the macOS version. This is the actual supported runtime used for the reproduction, rather than the repository's pinned Node 24.18.0 development toolchain.

Target and artifact context

Authored local extracted JavaScript application directories containing a package.json with "type":"module", .mjs import declarations, and inventoried .js target files. The controls also contain .cjs files whose names include a literal tab. REA analyzes the artifacts statically; separate Node commands execute only these authored fixtures as resolver oracles.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

REA CLI exit: 0
{"specifier":"./tabname.js","resolution_mode":"import","resolved_path":"app/tabname.js","resolution_status":"resolved"}
{"specifier":"./tab\tname.js","resolution_mode":"import","resolved_path":null,"resolution_status":"not-found"}
{"specifier":"./nl\nname.js","resolution_mode":"import","resolved_path":null,"resolution_status":"not-found"}
Node application exit: 0
Node resolver selected: tabname.js, nlname.js, tabname.js

Typed outcome or recovery expected

Both affected static-import edges should have resolution_status: "resolved" and the corresponding control-character-free artifact paths, while retaining the original specifiers and resolution_mode: "import". The ordinary import must keep resolving to app/tabname.js. The percent-encoded ESM and literal-tab CommonJS controls must continue to select files whose names contain tabs.

Analysis or suspected cause

Source-derived reasoning at the verified baseline: src/application/javascript/JavaScriptArtifactPathResolution.ts:502-525 admits these relative ESM declarations, strips query/fragment text, and percent-decodes the remaining string. src/application/javascript/JavaScriptArtifactPathResolution.ts:565-593 joins that decoded string as a filesystem path without the URL parser's tab/newline removal. No guard rejects the reproduced tabs or newlines. This inspection is consistent with the observed CLI results; no implementation fix was tested.

Additional context

Related reports #1236, #2021, #2960, and merged PR #1916 address HTML URL normalization, relative ESM extension fallback, encoded-byte admission, and package-target normalization, respectively; this report concerns actual tab/newline removal before relative ESM file lookup.

The verified P2 assessment reflects incorrect file relationships for bounded valid inputs, with analysis still available and an independently demonstrated disposable-copy workaround.

Originally created by @coygeek on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3214 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Evidence, artifacts, or reconstruction ### Observed behavior The static JavaScript application analyzer reports valid relative ESM imports containing escaped tabs or newlines as `not-found`, even when Node loads the same application. The CLI exits 0 with a normal Evidence envelope. The static-import edges retain the original specifiers and `resolution_mode: "import"`, but their `resolved_path` values are null. An ordinary relative import to the same file resolves correctly. ### Reproduction 1. Start in a clean checkout of revision `58300885207133422af235e0f2b10658cf958b03`, then install the locked dependencies and build the CLI: ```sh npm ci npm run build:cached ``` These prerequisite commands were not repeated during independent verification because the baseline checkout already had installed dependencies and a prepared build. The fixture, CLI analysis, Node execution, and controls below were independently executed. 2. Create an extracted application and analyze it, then run the separate Node oracle. The Python strings write JavaScript escape sequences into the source. The JavaScript string values contain actual tab and newline characters: ```sh python3 - <<'PYFIXTURE' from pathlib import Path app = Path('repro-esm/app') app.mkdir(parents=True, exist_ok=True) (app / 'package.json').write_text('{"type":"module"}\n') (app / 'index.mjs').write_text( 'import "./tab\\tname.js";\n' 'import "./nl\\nname.js";\n' 'import "./tabname.js";\n' ) for name in ('tabname.js', 'nlname.js'): (app / name).write_text('export const loaded = true;\n') (app / 'oracle.mjs').write_text( 'console.log(JSON.stringify(["./tab\\tname.js", "./nl\\nname.js", "./tabname.js"]' '.map(spec => ({spec, resolved: import.meta.resolve(spec).split("/").at(-1)})), null, 2));\n' ) PYFIXTURE node scripts/rea.mjs analyze-javascript-application repro-esm --json > repro-esm-result.json node repro-esm/app/index.mjs node repro-esm/app/oracle.mjs ``` 3. Extract the static-import properties from the successful CLI envelope: ```sh python3 - <<'PYRESULT' import json from pathlib import Path result = json.loads(Path('repro-esm-result.json').read_text()) for edge in result['normalized_result']['graph']['edges']: properties = edge.get('properties', {}) if properties.get('kind') == 'static-import': print(json.dumps({key: properties[key] for key in ('specifier', 'resolution_mode', 'resolved_path', 'resolution_status')})) PYRESULT ``` The two specifiers containing control characters have null `resolved_path` values and `resolution_status: "not-found"`. The plain import resolves to `app/tabname.js`. Node executes `index.mjs` successfully, and `oracle.mjs` selects `tabname.js`, `nlname.js`, and `tabname.js`, respectively. 4. Run the percent-encoded ESM and literal CommonJS controls. The Python filename strings contain actual tabs: ```sh python3 - <<'PYCONTROLS' from pathlib import Path app = Path('repro-esm-controls') app.mkdir(exist_ok=True) (app / 'package.json').write_text('{"type":"module"}\n') (app / 'index.mjs').write_text( 'import "./tabname.js";\n' 'import "./nlname.js";\n' 'import "./literal%09name.js";\n' ) for name in ('tabname.js', 'nlname.js', 'literal\tname.js'): (app / name).write_text('export const loaded = true;\n') (app / 'literal.cjs').write_text('module.exports = true;\n') (app / 'literal\tname.cjs').write_text('module.exports = true;\n') (app / 'require.cjs').write_text('require("./literal\\tname.cjs");\n') PYCONTROLS node scripts/rea.mjs analyze-javascript-application repro-esm-controls --json > repro-esm-controls-result.json node repro-esm-controls/index.mjs node repro-esm-controls/require.cjs ``` Both Node control executions exit 0. REA also exits 0 and resolves the ordinary imports, the percent-encoded ESM specifier to the literal-tab filename `literal\tname.js`, and the CommonJS specifier to the literal-tab filename `literal\tname.cjs`. These controls distinguish URL text preprocessing from percent decoding and CommonJS literal filenames. ### Expected behavior `docs/javascript-artifact-reconstruction.md:597-598` documents URL suffix handling and one percent-decoding pass for ESM relative module paths. For this fixture, relative ESM resolution should select the same files as the independently executed Node resolver: `./tab\tname.js` should resolve to `app/tabname.js`, and `./nl\nname.js` should resolve to `app/nlname.js`. Preserve the original specifier strings and import resolution mode. Node's observed ESM resolution removes tabs and newlines from the URL text before file lookup. The explicit tab/newline promise in `docs/javascript-artifact-reconstruction.md:610-611` applies to selected package targets, so that narrower passage corroborates URL preprocessing rather than explicitly promising it for all relative imports. The direct Node execution and `import.meta.resolve` probe establish the file selection for the relative imports in this reproduction. ### Impact and scope Affected imports remain unresolved in the application's static relationship graph even though the target files are present and Node can load them. The reproduced scope is relative ESM strings containing tabs or newlines. The imports remain visible, analysis completes normally, and the ordinary-import and literal-filename controls resolve. Downstream tracing behavior was not exercised. The reproduction crossed no security boundary and required no native analysis engine or external service. Independent verification also analyzed a copy with the affected strings replaced by `./tabname.js` and `./nlname.js`; all three imports then resolved. An analyst can inspect the retained raw specifiers or analyze such a disposable copy. Its Evidence describes changed artifact bytes and must not be presented as Evidence for the original artifact. ### REA version 6.4.0 (main at 58300885207133422af235e0f2b10658cf958b03) ### Runtime and platform Node 26.10.0; macOS arm64. The verifier did not record the macOS version. This is the actual supported runtime used for the reproduction, rather than the repository's pinned Node 24.18.0 development toolchain. ### Target and artifact context Authored local extracted JavaScript application directories containing a `package.json` with `"type":"module"`, `.mjs` import declarations, and inventoried `.js` target files. The controls also contain `.cjs` files whose names include a literal tab. REA analyzes the artifacts statically; separate Node commands execute only these authored fixtures as resolver oracles. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence ```text REA CLI exit: 0 {"specifier":"./tabname.js","resolution_mode":"import","resolved_path":"app/tabname.js","resolution_status":"resolved"} {"specifier":"./tab\tname.js","resolution_mode":"import","resolved_path":null,"resolution_status":"not-found"} {"specifier":"./nl\nname.js","resolution_mode":"import","resolved_path":null,"resolution_status":"not-found"} Node application exit: 0 Node resolver selected: tabname.js, nlname.js, tabname.js ``` ### Typed outcome or recovery expected Both affected static-import edges should have `resolution_status: "resolved"` and the corresponding control-character-free artifact paths, while retaining the original specifiers and `resolution_mode: "import"`. The ordinary import must keep resolving to `app/tabname.js`. The percent-encoded ESM and literal-tab CommonJS controls must continue to select files whose names contain tabs. ### Analysis or suspected cause Source-derived reasoning at the verified baseline: `src/application/javascript/JavaScriptArtifactPathResolution.ts:502-525` admits these relative ESM declarations, strips query/fragment text, and percent-decodes the remaining string. `src/application/javascript/JavaScriptArtifactPathResolution.ts:565-593` joins that decoded string as a filesystem path without the URL parser's tab/newline removal. No guard rejects the reproduced tabs or newlines. This inspection is consistent with the observed CLI results; no implementation fix was tested. ### Additional context Related reports [#1236](https://github.com/morluto/rea/issues/1236), [#2021](https://github.com/morluto/rea/issues/2021), [#2960](https://github.com/morluto/rea/issues/2960), and merged PR [#1916](https://github.com/morluto/rea/pull/1916) address HTML URL normalization, relative ESM extension fallback, encoded-byte admission, and package-target normalization, respectively; this report concerns actual tab/newline removal before relative ESM file lookup. The verified P2 assessment reflects incorrect file relationships for bounded valid inputs, with analysis still available and an independently demonstrated disposable-copy workaround.
bsakaguchi 2026-10-15 18:31:32 +00:00
  • closed this issue
  • added the
    bug
    label
Author
Owner

@jpdefo commented on GitHub (Oct 14, 2026):

I reproduced the relative ESM tab/newline resolution mismatch on current main with the production parser/resolver and a native Node control. I am working on a focused fix that preserves the original specifiers and percent-encoded/CommonJS literal filename behavior.

<!-- gh-comment-id:6161744382 --> @jpdefo commented on GitHub (Oct 14, 2026): I reproduced the relative ESM tab/newline resolution mismatch on current main with the production parser/resolver and a native Node control. I am working on a focused fix that preserves the original specifiers and percent-encoded/CommonJS literal filename behavior.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#1150
No description provided.