Skip to content

fix(extract): resolve .js/.mjs/.cjs specifiers through TypeScript's full source order (#3486) - #3488

Open
dajiaohuang wants to merge 1 commit into
Graphify-Labs:v8from
dajiaohuang:fix/3486-js-specifier-tsx
Open

fix(extract): resolve .js/.mjs/.cjs specifiers through TypeScript's full source order (#3486)#3488
dajiaohuang wants to merge 1 commit into
Graphify-Labs:v8from
dajiaohuang:fix/3486-js-specifier-tsx

Conversation

@dajiaohuang

@dajiaohuang dajiaohuang commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #3486.

_resolve_js_import_path mapped a .js specifier to .ts and stopped. TypeScript compiles Button.tsx to Button.js, so under NodeNext/Node16 the specifier the compiler requires is ./Button.js while the source on disk is .tsx — and TypeScript resolves that specifier in the order .ts.tsx.d.ts.js. Only the first step was implemented, so every .tsx module imported that way dropped out of the graph.

Before (v8, 0.9.58)

Resolver alone, one target on disk at a time:

specifier on disk resolved to
Button.js Button.tsx Button.js — unresolved
types.js types.d.ts types.js — unresolved
mod.mjs mod.mts mod.mjs — unresolved
mod.cjs mod.cts mod.cjs — unresolved
helper.js helper.ts helper.ts — fine

End to end on the issue's own fixture, App.ts importing ./Button.js emitted a ref_button_js phantom instead of an edge to the Button.tsx node — the same asymmetry the issue measured as 171 lost edges on a 1,430-file monorepo.

Change

  • graphify/extractors/resolution.py — replace the .js-only branch with a suffix → ordered-candidates table (_TS_SOURCE_SUFFIXES), so each emitted specifier is tried against every source suffix TypeScript accepts, implementation always ahead of a declaration. A real .js/.jsx file on disk still wins, because the existence check short-circuits before any rewriting. .jsx → .tsx folds into the same table and behaves exactly as before.
  • tests/test_import_extension_resolution.py — 11 regression locks: the four resolution cases, ordering (.ts beats .tsx beats .d.ts), multi-dot stems (tag-action.shared.js → tag-action.shared.tsx), two non-regression guards, and the issue's reproducer end to end. Nine of the eleven fail on v8 and pass here; the other two pin behaviour that must not change.

One correction to the issue's suggested fix

The proposal builds .d.ts by concatenating onto with_suffix(""), on the assumption that Path.with_suffix(".d.ts") mangles a multi-dot stem. It does not — Path("Button.test.js").with_suffix(".d.ts") is Button.test.d.ts on 3.10 and 3.13 alike — so this uses with_suffix directly. No behaviour difference; just less code.

Validation

  • pytest tests/5136 passed, 286 skipped, with the same 42 pre-existing failures as v8 on Windows (FIFO/socket/symlink cases, tree-sitter-terraform, ollama deps). The failure set is byte-identical to the v8 baseline; a re-run gives 5137 passed with the identical failure set. (One timing-ratio test, test_ts_normalizer_scales_linearly_on_large_files, flaked once under full-suite load on this machine and passes 5/5 in isolation and on re-run — it is unrelated to this path.)
  • python -m tools.skillgen --check / --schema-singleton / --audit-coverage → all OK.

No new dependencies, no public API change.


I noticed @cbartens offered to upstream their fuller resolution logic in the issue. This PR deliberately implements only the file-level ordering from the issue's own table, and not the workspace exports-map / platform-split work — that is the separate #3487 — so it stays small and does not collide with a larger contribution there.

…ull source order (Graphify-Labs#3486)

`_resolve_js_import_path` mapped a `.js` specifier to `.ts` and stopped.
TypeScript compiles `Button.tsx` to `Button.js`, so under NodeNext/Node16 the
specifier the compiler requires is `./Button.js` while the source on disk is
`.tsx` — and TypeScript resolves that specifier in the order
`.ts` -> `.tsx` -> `.d.ts` -> `.js`. Only the first step was implemented, so
every `.tsx` module imported that way dropped out of the graph and surfaced as
a `ref_*` phantom edge instead of an `imports_from` edge to the real file node.

Replace the `.js`-only branch with a suffix -> ordered-candidates table, so each
emitted specifier is tried against every source suffix TypeScript accepts,
implementation always ahead of a declaration. `.jsx -> .tsx` folds into the same
table and behaves exactly as before; a real `.js`/`.jsx` file on disk still wins
because the existence check short-circuits first.

Nine of the eleven new tests fail on v8 and pass here; the other two lock
behaviour that must not change.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Extends _resolve_js_import_path so a .js/.jsx/.mjs/.cjs specifier resolves through TypeScript's full documented source-suffix order via the new _TS_SOURCE_SUFFIXES table (e.g. .js tries .ts, then .tsx, then .d.ts), rather than the old .js.ts / .jsx.tsx single-shot lookup that dropped .tsx-backed .js imports to phantom ref_ edges (#3486). A real file on disk still short-circuits and wins over any source sibling, and multi-dot stems keep their earlier dots since only the last suffix is replaced. Adds tests covering the precedence order, declaration-only fallbacks, the .mjs/.cjs variants, and the end-to-end .js.tsx import edge.

No blocking issues surfaced. 1 lower-confidence candidate did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1894 functions depend on the 228 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 585 callers, 44 callees
  • new: _rebuild_code() — 115 callers, 51 callees
  • new: _extract_generic() — 18 callers, 26 callees
  • new: _resolve_js_module_path() — 44 callers, 9 callees
  • new: extract_js() — 86 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: dispatch_command() — 2 callers, 124 callees
  • new: extract_objc() — 27 callers, 9 callees
  • …and 35 more — each is listed as a finding

Verification — 1894 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 919 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

29 of 268 test file(s) selected (11%) via static blast radius.

  • tests/test_astro_extraction.py — impact
  • tests/test_build.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_cpp_nested_and_cli.py — impact
  • tests/test_dotnet.py — impact
  • tests/test_extract.py — impact
  • tests/test_forwarding_review_findings.py — impact
  • tests/test_import_extension_resolution.py — impact, changed-test
  • tests/test_indirect_dispatch.py — impact
  • tests/test_indirect_dispatch_assign_return.py — impact
  • tests/test_indirect_dispatch_getattr.py — impact
  • tests/test_js_exported_scalar_bindings.py — impact
  • tests/test_languages.py — impact
  • tests/test_multilang.py — impact
  • tests/test_package_json_subpath_imports.py — impact
  • tests/test_pascal.py — impact
  • tests/test_pascal_resolution.py — impact
  • tests/test_phantom_external_import.py — impact
  • tests/test_python_import_resolution.py — impact
  • tests/test_python_underscore_resolution.py — impact
  • tests/test_rationale.py — impact
  • tests/test_ruby_resolution.py — impact
  • tests/test_scala_self_type.py — impact
  • tests/test_src_layout_import_resolution.py — impact
  • tests/test_swift_computed_properties.py — impact
  • tests/test_ts_new_expression_calls.py — impact
  • tests/test_typescript_module_extensions.py — impact
  • tests/test_unmapped_at_alias_resolution.py — impact
  • tests/test_vue_extraction.py — impact

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Formal verification

Could not verify: Could not verify \_resolve\_js\_import\_path.

The verifier did not have enough to check \_resolve\_js\_import\_path, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `candidate` is annotated `Path` — outside the synthesizable primitive/collection set

· 43 more finding(s) on lines outside this diff (see the check run).

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Extends TypeScript emitted-module resolution so a .js/.jsx/.mjs/.cjs specifier resolves through TypeScript's full documented source order via _TS_SOURCE_SUFFIXES — e.g. ./Button.js now finds Button.tsx, and .js tries .ts then .tsx then .d.ts, preferring implementations over declarations. Previously the .js branch only tried .ts (and .jsx only .tsx), so imports of transpiled .tsx modules dropped to phantom ref_ edges (#3486). Real on-disk files still short-circuit ahead of any suffix substitution.

Worth a look

  • .js specifiers never resolve to .jsx sourcesgraphify/extractors/resolution.py:38 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1894 functions depend on the 228 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 585 callers, 44 callees
  • new: _rebuild_code() — 115 callers, 51 callees
  • new: _extract_generic() — 18 callers, 26 callees
  • new: _resolve_js_module_path() — 44 callers, 9 callees
  • new: extract_js() — 86 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: dispatch_command() — 2 callers, 124 callees
  • new: extract_objc() — 27 callers, 9 callees
  • …and 35 more — each is listed as a finding

Verification — 1894 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 919 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

29 of 268 test file(s) selected (11%) via static blast radius.

  • tests/test_astro_extraction.py — impact
  • tests/test_build.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_cpp_nested_and_cli.py — impact
  • tests/test_dotnet.py — impact
  • tests/test_extract.py — impact
  • tests/test_forwarding_review_findings.py — impact
  • tests/test_import_extension_resolution.py — impact, changed-test
  • tests/test_indirect_dispatch.py — impact
  • tests/test_indirect_dispatch_assign_return.py — impact
  • tests/test_indirect_dispatch_getattr.py — impact
  • tests/test_js_exported_scalar_bindings.py — impact
  • tests/test_languages.py — impact
  • tests/test_multilang.py — impact
  • tests/test_package_json_subpath_imports.py — impact
  • tests/test_pascal.py — impact
  • tests/test_pascal_resolution.py — impact
  • tests/test_phantom_external_import.py — impact
  • tests/test_python_import_resolution.py — impact
  • tests/test_python_underscore_resolution.py — impact
  • tests/test_rationale.py — impact
  • tests/test_ruby_resolution.py — impact
  • tests/test_scala_self_type.py — impact
  • tests/test_src_layout_import_resolution.py — impact
  • tests/test_swift_computed_properties.py — impact
  • tests/test_ts_new_expression_calls.py — impact
  • tests/test_typescript_module_extensions.py — impact
  • tests/test_unmapped_at_alias_resolution.py — impact
  • tests/test_vue_extraction.py — impact

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Formal verification

Could not verify: Could not verify \_resolve\_js\_import\_path.

The verifier did not have enough to check \_resolve\_js\_import\_path, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `candidate` is annotated `Path` — outside the synthesizable primitive/collection set

· 43 more finding(s) on lines outside this diff (see the check run).

@dajiaohuang

Copy link
Copy Markdown
Contributor Author

Checked against the issue contract and TypeScript's emitted-module source order: a .js specifier resolves through .ts, .tsx, then .d.ts; .jsx is not a candidate for .js. The existing .jsx → .tsx behavior remains covered separately, so no code change is needed for this advisory.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JS/TS: a .js import specifier never resolves to a .tsx file, dropping every NodeNext-style import of a TSX module

1 participant