bug: tier 1b stem-matches relative import specifiers — require("./utils") from lib/ reaches test/utils.js #57

Closed
opened 2026-07-29 18:57:38 +02:00 by buildagent · 1 comment
Member

Found while implementing #56. Blocks #56 part 2 (CommonJS require() capture) and is very likely producing phantoms in the other languages today.

The defect

Tier 1b's file-key reachability arm (crates/indexer/src/index.rs ~1026-1032) treats an import as evidence for a candidate file when the import's module string matches that file's stem:

OR EXISTS (
    SELECT 1 FROM imports i
    JOIN temp.file_keys k ON k.file_id = cand.file_id
    WHERE i.file_id = st.file_id
      AND (i.module = k.key
           OR {IMPORT_SEGMENTS} GLOB ('*.' || k.key || '.*'))
)

temp.file_keys holds bare Path::file_stem values (index.rs ~879-890). So a relative specifier is matched by stem alone, with the directory it is relative to discarded.

Reproduction (measured)

In the pinned js-express corpus repo there are two files with stem utils: lib/utils.js and test/utils.js. lib/application.js requires ./utils — unambiguously lib/utils.js.

With #56 part 2's import capture landed, six references resolve into the test file:

lib/application.js:221 fn (type) -> test/utils.js#fn
lib/application.js:221 fn (type) -> test/utils.js#fn
lib/application.js:226 fn (type) -> test/utils.js#fn
lib/application.js:227 fn (type) -> test/utils.js#fn
lib/application.js:232 fn (type) -> test/utils.js#fn
lib/application.js:240 fn (type) -> test/utils.js#fn

A production file binding into a test file. fn is a local in application.js; the import edge is what made test/utils.js reachable.

Why this matters beyond JS

The same arm serves every language. Any two files sharing a stem in a repo that uses relative imports can be confused:

  • Python from .utils import x where pkg_a/utils.py and pkg_b/utils.py both exist
  • TypeScript import "./helpers" with src/helpers.ts and test/helpers.ts
  • PHP require __DIR__ . "/../Util.php"

The fix therefore has upside beyond unblocking #56, and should be measured across the whole corpus rather than just express.

Suggested direction

Path-resolve a relative specifier against the importing file's directory before matching, instead of comparing stems:

  • specifier starts with ./ or ../ → resolve to a normalised repo-relative path and require the candidate file's path to equal it (modulo extension and /index.<ext>);
  • non-relative specifiers (express, lodash, crate::x) keep the current stem/package-tail behaviour — they have no directory to resolve against.

That is a narrowing, so it can only remove candidates, never add them: this cannot mint a phantom. It can reduce recall where the current stem match happened to be right, so it needs the usual measurement.

Two implementation notes:

  • imports.module stores the raw specifier, so resolution can be done in Rust when building temp.file_keys/a new temp table, rather than in SQL (SQLite cannot normalise ../ paths).
  • Language-specific: JS/TS resolve ./x to x.js|.ts|.tsx|/index.js, Python's .x is package-relative, PHP uses filesystem paths. Handle the ones the corpus covers and leave the rest on the existing behaviour.

Acceptance

  • a relative specifier resolves only to the file it actually names; a hermetic fixture with two same-stem files in different directories proves the wrong one is NOT reachable
  • phantom_count == 0 maintained; corpus mutation guard still 0 rebinds
  • measured resolution delta per corpus repo, with any losses inspected individually (a narrowing should mostly remove phantoms)
  • corpus_ratchet re-blessed with the reason
  • #56 part 2 re-landed on top, and the six lib/application.js -> test/utils.js bindings are gone

Severity: medium

Latent today (nothing has measured how many current resolutions depend on a wrong same-stem match — worth quantifying first), but it is a hard blocker for #56 part 2, and it is a phantom class rather than a recall gap.

Found while implementing #56. **Blocks #56 part 2** (CommonJS `require()` capture) and is very likely producing phantoms in the other languages today. ## The defect Tier 1b's file-key reachability arm (`crates/indexer/src/index.rs` ~1026-1032) treats an import as evidence for a candidate file when the import's module string matches that file's **stem**: ```sql OR EXISTS ( SELECT 1 FROM imports i JOIN temp.file_keys k ON k.file_id = cand.file_id WHERE i.file_id = st.file_id AND (i.module = k.key OR {IMPORT_SEGMENTS} GLOB ('*.' || k.key || '.*')) ) ``` `temp.file_keys` holds bare `Path::file_stem` values (`index.rs` ~879-890). So a **relative** specifier is matched by stem alone, with the directory it is relative *to* discarded. ## Reproduction (measured) In the pinned `js-express` corpus repo there are two files with stem `utils`: `lib/utils.js` and `test/utils.js`. `lib/application.js` requires `./utils` — unambiguously `lib/utils.js`. With #56 part 2's import capture landed, six references resolve **into the test file**: ``` lib/application.js:221 fn (type) -> test/utils.js#fn lib/application.js:221 fn (type) -> test/utils.js#fn lib/application.js:226 fn (type) -> test/utils.js#fn lib/application.js:227 fn (type) -> test/utils.js#fn lib/application.js:232 fn (type) -> test/utils.js#fn lib/application.js:240 fn (type) -> test/utils.js#fn ``` A production file binding into a test file. `fn` is a local in `application.js`; the import edge is what made `test/utils.js` reachable. ## Why this matters beyond JS The same arm serves **every** language. Any two files sharing a stem in a repo that uses relative imports can be confused: - Python `from .utils import x` where `pkg_a/utils.py` and `pkg_b/utils.py` both exist - TypeScript `import "./helpers"` with `src/helpers.ts` and `test/helpers.ts` - PHP `require __DIR__ . "/../Util.php"` The fix therefore has upside beyond unblocking #56, and should be measured across the whole corpus rather than just express. ## Suggested direction Path-resolve a relative specifier against the **importing file's directory** before matching, instead of comparing stems: - specifier starts with `./` or `../` → resolve to a normalised repo-relative path and require the candidate file's path to equal it (modulo extension and `/index.<ext>`); - non-relative specifiers (`express`, `lodash`, `crate::x`) keep the current stem/package-tail behaviour — they have no directory to resolve against. That is a narrowing, so it can only **remove** candidates, never add them: this cannot mint a phantom. It can reduce recall where the current stem match happened to be right, so it needs the usual measurement. Two implementation notes: - `imports.module` stores the raw specifier, so resolution can be done in Rust when building `temp.file_keys`/a new temp table, rather than in SQL (SQLite cannot normalise `../` paths). - Language-specific: JS/TS resolve `./x` to `x.js|.ts|.tsx|/index.js`, Python's `.x` is package-relative, PHP uses filesystem paths. Handle the ones the corpus covers and leave the rest on the existing behaviour. ## Acceptance - [ ] a relative specifier resolves only to the file it actually names; a hermetic fixture with two same-stem files in different directories proves the wrong one is NOT reachable - [ ] `phantom_count == 0` maintained; corpus mutation guard still 0 rebinds - [ ] measured resolution delta per corpus repo, with any losses inspected individually (a narrowing should mostly remove phantoms) - [ ] `corpus_ratchet` re-blessed with the reason - [ ] #56 part 2 re-landed on top, and the six `lib/application.js -> test/utils.js` bindings are gone ## Severity: medium Latent today (nothing has measured how many current resolutions depend on a wrong same-stem match — worth quantifying first), but it is a hard blocker for #56 part 2, and it is a phantom class rather than a recall gap.
Author
Member

Fixed and released in v0.9.0.

Relative import specifiers are now path-resolved against the importing file's directory instead of stem-matched, so require("./utils") from lib/ can no longer reach test/utils.js. Implemented via a temp.import_rel table built in Rust (normalise_rel, relative_import_paths, resolution_candidates).

Three things the corpus forced during the fix, each caught by measurement rather than review:

  • The first cut cost ts-zod 2,659 resolutions, because NodeNext specifiers like ./api.js must resolve to api.ts → fixed with extension substitution.
  • python-flask resolved only 14/176, because the Python plugin stores the imported symbol in the module string → the resolver now offers both readings.
  • require("../..") produced an empty base and matched /index.js → prefix guard added.

This was a prerequisite for #56 part 2, which had been reverted for +6 phantoms and re-landed clean once this was in.

Regression cover: three new tests in crates/indexer/tests/resolver.rs.

Fixed and released in **v0.9.0**. Relative import specifiers are now **path-resolved** against the importing file's directory instead of stem-matched, so `require("./utils")` from `lib/` can no longer reach `test/utils.js`. Implemented via a `temp.import_rel` table built in Rust (`normalise_rel`, `relative_import_paths`, `resolution_candidates`). Three things the corpus forced during the fix, each caught by measurement rather than review: - The first cut cost ts-zod **2,659 resolutions**, because NodeNext specifiers like `./api.js` must resolve to `api.ts` → fixed with extension substitution. - python-flask resolved only 14/176, because the Python plugin stores the imported *symbol* in the module string → the resolver now offers both readings. - `require("../..")` produced an empty base and matched `/index.js` → prefix guard added. This was a prerequisite for #56 part 2, which had been reverted for +6 phantoms and re-landed clean once this was in. Regression cover: three new tests in `crates/indexer/tests/resolver.rs`.
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
h-dv/code-index#57
No description provided.