Skip to content

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

@carlos-alm

Description

@carlos-alm

Found during dogfooding v3.3.2-dev.39

Severity: Critical
Command: All commands — import resolution is broken for TypeScript codebases

Reproduction

# From the codegraph repo root
node -e "
const { loadNative } = require('./dist/infrastructure/native.js');
const native = loadNative();
console.log(native.resolveImport('src/cli/commands/build.ts', '../../domain/graph/builder.js', '.'));
"
# Output: src/cli/commands/../../domain/graph/builder.js
# Expected: src/domain/graph/builder.ts

After building a TypeScript codebase where imports use .js extensions (ESM convention: import { foo } from './bar.js' where bar.ts is the actual file), only 21 import edges are resolved out of hundreds of imports.

Expected behavior

resolveImport should return src/domain/graph/builder.ts — the .js.ts remap logic (line 117-133 in import_resolution.rs) should trigger.

Actual behavior

Returns src/cli/commands/../../domain/graph/builder.js — the path is not normalized, causing strip_prefix(root_dir) to fail silently.

Root cause

In crates/codegraph-core/src/import_resolution.rs, line 114:

let resolved: PathBuf = dir.join(import_source).components().collect();

PathBuf::components().collect() does NOT resolve .. components in Rust — it only normalizes . and separators. So when import_source is ../../domain/graph/builder.js, the resolved path retains the ../.. components.

The .js.ts remap at lines 117-133 then fails because:

  1. file_exists(&ts_candidate, None) may succeed (OS resolves .. in exists()), BUT
  2. Path::new(&ts_candidate).strip_prefix(root) fails because the unnormalized path src/cli/commands/../../domain/graph/builder.ts cannot be prefix-stripped by . or an absolute root path.
  3. The function falls through and returns the original .js path.

The same bug affects ALL extension probing logic that follows (lines 134+).

Suggested fix

Rust fix (primary)

Replace line 114 with proper path normalization:

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
}

let resolved = clean_path(&dir.join(import_source));

JS workaround (immediate)

In resolveImportPath (resolve.ts), after getting the native result, check if it ends with .js and apply the JS .js.ts fallback:

if (native) {
    const result = native.resolveImport(fromFile, importSource, rootDir, ...);
    const normalized = normalizePath(path.normalize(result));
    if (normalized.endsWith('.js')) {
        const tsPath = path.resolve(rootDir, normalized.replace(/\.js$/, '.ts'));
        if (fs.existsSync(tsPath)) return normalizePath(path.relative(rootDir, tsPath));
        const tsxPath = path.resolve(rootDir, normalized.replace(/\.js$/, '.tsx'));
        if (fs.existsSync(tsxPath)) return normalizePath(path.relative(rootDir, tsxPath));
    }
    return normalized;
}

This also affects resolve_imports_batch when called without known_files.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdogfoodFound during dogfooding

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions