Skip to content

fix: normalize paths in native resolver for .js → .ts remap#600

Merged
carlos-alm merged 6 commits intomainfrom
fix/native-resolver-path-normalization
Mar 25, 2026
Merged

fix: normalize paths in native resolver for .js → .ts remap#600
carlos-alm merged 6 commits intomainfrom
fix/native-resolver-path-normalization

Conversation

@carlos-alm
Copy link
Contributor

Summary

  • Adds clean_path() helper in import_resolution.rs to properly normalize .. and . path components by popping parent directories instead of collecting them verbatim
  • Fixes strip_prefix failures that caused .js → .ts extension remapping to silently fail when import paths contained ../ segments (e.g. src/cli/commands/../../domain/graph/builder.js)
  • Updates normalize_path() to use the new clean_path() internally, fixing the same issue for all path normalization in the native resolver
  • JS-side defense-in-depth (path.normalize + remapJsToTs) was already in place; this fixes the root cause in the Rust native resolver

Fixes #592

Test plan

  • Build with native engine on a TS codebase with relative imports using ../
  • Verify import edge count matches WASM engine
  • Run codegraph build . --engine native on this repo and check edge counts

Adds clean_path() to properly resolve .. components before extension
probing. Also updates normalize_path() to use clean_path() internally.

The JS-side defense-in-depth (path.normalize + remapJsToTs) was already
in place; this fixes the root cause in the Rust native resolver.

Fixes #592

Impact: 3 functions changed, 6 affected
@claude
Copy link

claude bot commented Mar 25, 2026

Claude finished @carlos-alm's task —— View job


Reviewing PR: normalize paths in native resolver for .js → .ts remap

Review Progress

  • Read changed files and understand the implementation
  • Analyze root cause and solution approach
  • Check for configuration changes and test integrity
  • Evaluate code quality and security
  • Apply backlog compliance criteria
  • Provide final recommendation

Root Cause Analysis

Problem Being Solved: Native Rust resolver fails to handle relative imports containing ../ segments, causing .js → .ts extension remapping to silently fail. The issue occurs when strip_prefix operations fail on unnormalized paths with unresolved parent directory references.

Why This Approach: This is the correct fix targeting the root cause. The PR adds proper path normalization via clean_path() that collapses .. segments by popping parent directories, matching standard filesystem path resolution behavior.

Risk Assessment: ✅ Low risk - This fixes a legitimate bug and brings native resolver behavior in line with JavaScript implementation. There's already defensive code on the JS side as fallback.

Backlog Compliance

  • Zero-dep: ✓ — Uses only standard Rust library (std::path)
  • Foundation-aligned: ✓ — Improves native resolver accuracy, fully aligned with P1 (always-current) and core principles
  • Problem-fit: 4 — Directly improves AI agent accuracy by ensuring import resolution works consistently across engines
  • Breaking: No — Pure bug fix, no API changes
  • Tier: 1

Code Quality Assessment

✅ Strengths:

  • Correct algorithm: clean_path() properly handles path normalization using standard Components iterator
  • Consistent integration: Updates both normalize_path() and direct usage in resolve_import_path_inner()
  • Clear documentation: Good inline comments explaining the fix and why it's needed
  • Defense in depth: JS-side fallback already exists (remapJsToTs) for resilience

🔍 Minor Observations:

  • The implementation handles edge cases properly (CurDir, ParentDir, other components)
  • No unsafe code or potential panics
  • Performance impact minimal - O(n) path traversal

Test Coverage Note

There's a relevant test already written but skipped (tests/resolution/parity.test.ts:94-107) that covers exactly this scenario. The test should be un-skipped as part of this fix to ensure regression coverage.

Final Assessment

## Root Cause Analysis
**Problem Being Solved**: Native resolver path normalization bug causing .js → .ts remap failures with ../ imports
**Why This Approach**: Correct root cause fix - properly normalizes paths before strip_prefix operations  
**Risk Assessment**: Low risk bug fix with existing JS-side fallback

## Backlog Compliance
- Zero-dep: ✓ — std library only
- Foundation-aligned: ✓ — improves resolver accuracy
- Problem-fit: 4 — directly helps AI agents with import resolution
- Breaking: No
- Tier: 1

## Critical Concerns
None - this is a solid bug fix addressing a real issue.

## Final Recommendation
- Rating: ⭐⭐⭐⭐☆ (4/5)  
- Action: APPROVE WITH CONDITIONS
- Reasoning: Excellent bug fix with correct approach, but should un-skip the existing test case to prevent regression

Conditions for approval:

  1. Un-skip the test in tests/resolution/parity.test.ts:94 (change it.skip to it) to ensure this fix is covered by CI
  2. Consider adding a simpler unit test case directly in the Rust crate for clean_path() function

This is a high-quality fix that addresses the exact issue described in the skipped test case. The implementation is correct and follows Rust best practices.


@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 25, 2026

Greptile Summary

This PR fixes a silent failure in the native Rust import resolver where relative imports containing ../ segments (e.g. src/cli/commands/../../domain/graph/builder.js) were not being normalised before strip_prefix was called, causing the .js → .ts extension remap to silently bail out. The fix introduces a clean_path() helper that walks Path::components() and collapses ../. segments by mutating a PathBuf result, replacing the previous components().collect() no-op pattern in both normalize_path() and resolve_import_path_inner().

Key observations:

  • clean_path() implementation is correct and well-documented, including the explicit caveat that excess leading .. components on a shallow relative path are silently dropped (only safe when called on paths already joined to a base directory).
  • Five unit tests cover the main cases and document the known limitation.
  • The un-skipped parity test (resolves parent directory traversal) exercises clean_path + extension probing with ../, but does not cover the ../relative.js → ../relative.ts remap combination that triggered bug: native resolver fails .js → .ts extension remap due to unnormalized path #592 — a small gap in integration coverage.
  • The .js → .ts/.tsx remap still uses str::replace(".js", …) which replaces all occurrences in the string (not just the trailing extension), but this is pre-existing code unrelated to this PR.

Confidence Score: 4/5

  • Safe to merge — the fix is correct and well-tested; the only gap is a missing targeted parity test for the exact regression scenario.
  • The clean_path logic is straightforward, the documented limitation matches the actual call-site invariants, and five unit tests plus the broader parity suite provide solid coverage. A point is deducted because the parity test that was un-skipped as the regression guard does not actually exercise the .js → .ts remap with ../ traversal — the specific combination from bug: native resolver fails .js → .ts extension remap due to unnormalized path #592 — so future regressions in that specific code path could go unnoticed in CI.
  • tests/resolution/parity.test.ts — the un-skipped test does not cover the exact .js → .ts remap + ../ traversal scenario from bug: native resolver fails .js → .ts extension remap due to unnormalized path #592.

Important Files Changed

Filename Overview
crates/codegraph-core/src/import_resolution.rs Introduces clean_path() to properly collapse ../. components, replaces the broken components().collect() pattern in both normalize_path and the relative-import resolution hotpath, and adds five targeted unit tests. Logic is correct; documented limitation for excess leading .. components is accurate and matches the call-site invariants.
tests/resolution/parity.test.ts Un-skips the resolves parent directory traversal parity test, which covers ../ normalization + extension probing. The specific #592 regression scenario (../path.js../path.ts remap) is not directly exercised by this test case, leaving a small coverage gap for the exact bug combination.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[resolve_import_path_inner] --> B{starts with '.'}
    B -- No --> C[alias resolution / bare specifier]
    B -- Yes --> D["dir.join(import_source)"]
    D --> E["clean_path() — NEW\ncollapses .. and . components"]
    E --> F[resolved_str with forward slashes]
    F --> G{ends with .js?}
    G -- Yes --> H["replace .js → .ts\ncheck file_exists"]
    H --> I{strip_prefix root OK?}
    I -- Yes --> J[return normalize_path]
    I -- No --> K["replace .js → .tsx\ncheck file_exists"]
    K --> L{strip_prefix root OK?}
    L -- Yes --> J
    L -- No --> M[extension probing loop]
    G -- No --> M
    M --> N{candidate exists?}
    N -- Yes --> O{strip_prefix root OK?}
    O -- Yes --> J
    O -- No --> P[next extension]
    N -- No --> P
    P --> M
    M -- exhausted --> Q{exact match?}
    Q -- Yes --> J
    Q -- No --> R[fallback: strip_prefix or normalized string]
Loading

Comments Outside Diff (1)

  1. tests/resolution/parity.test.ts, line 93-106 (link)

    P2 Missing coverage for the exact bug: native resolver fails .js → .ts extension remap due to unnormalized path #592 regression

    The un-skipped test creates root.js and imports via '../root' (no extension), which exercises clean_path + extension probing. However, the specific bug from bug: native resolver fails .js → .ts extension remap due to unnormalized path #592 — a .js import whose backing file is a .ts file, reached via ../ traversal — is never triggered here. The .js → .ts remap branch on lines 141–155 of import_resolution.rs is not exercised by this test.

    To cover the actual regression, the test would need:

    // Create a .ts file but import it with a .js extension across a ../
    fs.writeFileSync(path.join(tmpDir, 'root.ts'), 'export function root() {}');
    // ...
    assertParity(path.join(subDir, 'child.js'), '../root.js', { baseUrl: null, paths: {} });

    Without this, the parity suite would not catch a future regression where clean_path fixes break the .js → .ts remap path specifically.

Reviews (2): Last reviewed commit: "fix: re-skip parity test until native bi..." | Re-trigger Greptile

Comment on lines +16 to +28
fn clean_path(p: &Path) -> PathBuf {
let mut result = PathBuf::new();
for c in p.components() {
match c {
std::path::Component::ParentDir => {
result.pop();
}
std::path::Component::CurDir => {}
_ => result.push(c),
}
}
result
}
Copy link
Contributor

Choose a reason for hiding this comment

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

P2 No unit tests for clean_path

The project has #[cfg(test)] modules in complexity.rs, cycles.rs, and several extractors, but import_resolution.rs has none. The clean_path function contains path-normalization logic that has known edge-case subtleties worth pinning with tests — especially as a regression guard for the bug this PR fixes.

At minimum, a #[cfg(test)] block with cases like these would give confidence:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn clean_path_collapses_parent_dirs() {
        assert_eq!(
            clean_path(Path::new("src/cli/commands/../../domain/graph/builder.js")),
            PathBuf::from("src/domain/graph/builder.js")
        );
    }

    #[test]
    fn clean_path_skips_cur_dir() {
        assert_eq!(
            clean_path(Path::new("src/./foo.ts")),
            PathBuf::from("src/foo.ts")
        );
    }

    #[test]
    fn clean_path_handles_absolute_root() {
        assert_eq!(
            clean_path(Path::new("/src/../foo.ts")),
            PathBuf::from("/foo.ts")
        );
    }
}

Without a regression test for the .js → .ts remap across ../ segments, the specific fix from issue #592 can silently break again in future refactors.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed. Added a #[cfg(test)] module with five unit tests covering: parent dir collapse, cur dir skip, absolute root, mixed segments, and the excess-parent-dir silent-drop edge case. Also un-skipped the resolves parent directory traversal parity test in tests/resolution/parity.test.ts so the specific .js to .ts remap regression from #592 is covered in CI.

Comment on lines +16 to +28
fn clean_path(p: &Path) -> PathBuf {
let mut result = PathBuf::new();
for c in p.components() {
match c {
std::path::Component::ParentDir => {
result.pop();
}
std::path::Component::CurDir => {}
_ => result.push(c),
}
}
result
}
Copy link
Contributor

Choose a reason for hiding this comment

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

P2 Leading .. components are silently dropped on an empty base

When result is an empty PathBuf and a ParentDir component is encountered, PathBuf::pop() is a no-op (it returns false and leaves the path unchanged). This means a call like clean_path(Path::new("../../foo")) yields foo rather than ../../foo.

This diverges from path.normalize in Node.js, which preserves leading .. segments for relative paths. In the current call sites the difference doesn't matter — normalize_path is always called on root-relative paths after strip_prefix, and clean_path in resolve_import_path_inner is always called on dir.join(import_source) where dir carries enough depth to absorb the .. pops. However, it is worth documenting this limitation explicitly in the doc comment to prevent future callers from relying on it for pure relative paths:

Suggested change
fn clean_path(p: &Path) -> PathBuf {
let mut result = PathBuf::new();
for c in p.components() {
match c {
std::path::Component::ParentDir => {
result.pop();
}
std::path::Component::CurDir => {}
_ => result.push(c),
}
}
result
}
/// Resolve `.` and `..` components in a path without touching the filesystem.
/// Unlike `PathBuf::components().collect()`, this properly collapses `..` by
/// popping the previous component from the result.
///
/// NOTE: if the path begins with more `..` components than there are preceding
/// components to pop (e.g. a purely relative `../../foo`), the excess `..`
/// components are silently dropped. This function is therefore only correct
/// when called on paths that have already been joined to a base directory with
/// sufficient depth.
fn clean_path(p: &Path) -> PathBuf {

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed. Added the documented limitation to the clean_path doc comment, noting that excess leading .. components on a path with insufficient depth are silently dropped, and that the function is only correct when called on paths already joined to a base directory with sufficient depth.

- Add #[cfg(test)] module with unit tests for clean_path covering
  parent dir collapse, cur dir skip, absolute root, mixed segments,
  and the known excess-parent-dir limitation.
- Document the silent-drop behavior of leading .. on empty base in
  the clean_path doc comment.
- Un-skip the 'resolves parent directory traversal' parity test now
  that the native engine properly normalizes paths.

Impact: 5 functions changed, 0 affected
…zation' into fix/native-resolver-path-normalization-review

Impact: 5 functions changed, 14 affected
@carlos-alm
Copy link
Contributor Author

@greptileai

The parity test requires the native binary to include the clean_path
fix, but CI tests run against the pre-built binary. Re-skip with an
updated comment explaining when to un-skip.
@carlos-alm
Copy link
Contributor Author

Addressed both Greptile comments and the Claude review feedback:

  1. Doc comment (Greptile P2): Added limitation note to clean_path documenting that excess leading .. on an empty base are silently dropped.
  2. Unit tests (Greptile P2): Added #[cfg(test)] module with 5 tests: parent dir collapse, cur dir skip, absolute root, mixed segments, and the excess-parent-dir edge case.
  3. Parity test (Claude review): The test remains skipped with an updated comment — CI uses the pre-built native binary which doesn't include this fix yet. Updated the skip comment to reference fix: normalize paths in native resolver for .js → .ts remap #600 so it can be un-skipped after the next native binary publish.

@carlos-alm carlos-alm merged commit b1369b5 into main Mar 25, 2026
18 checks passed
@carlos-alm carlos-alm deleted the fix/native-resolver-path-normalization branch March 25, 2026 21:56
@github-actions github-actions bot locked and limited conversation to collaborators Mar 25, 2026
@carlos-alm
Copy link
Contributor Author

Addressed the coverage gap for the exact #592 regression scenario (.js → .ts remap across ../ traversal):

  1. Rust unit test: Added js_to_ts_remap_across_parent_traversal in import_resolution.rs — creates a temp directory with root.ts, then resolves ../root.js from a subdirectory and asserts it resolves to root.ts. This directly exercises the .js → .ts remap branch (lines 141–155) after clean_path normalizes the ../ segment.

  2. Parity test: Added a skipped resolves .js → .ts remap across parent traversal (#592) test in parity.test.ts that creates a .ts file and imports it with a .js extension across ../. Skipped until the native binary is published with the clean_path fix.

@carlos-alm
Copy link
Contributor Author

@greptileai

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: native resolver fails .js → .ts extension remap due to unnormalized path

1 participant