A RELATIVE import anchors a file OUTSIDE the importing file's own directory subtree — from .models import reaches every models.py in the tree, and it produced 3 of #69's 5 measured phantoms #196

Closed
opened 2026-09-06 17:53:27 +02:00 by buildagent · 2 comments
Member

Found while inspecting #69's new binds at source. Filed separately because the mechanism is upstream of #69 and shared with every tier that reads temp.file_keys.

The measurement

#69 widened tier 1R to the member pool: +4 922 binds over the nine pinned repos, 0 lost. Every one of the 4 859 binds in the first cut was censused for an ambiguous container and the 70 that were ambiguous were read at source. Five were phantoms, and three of them share one cause:

tests/foreign_object/tests.py:521  referrer.article
    bound   tests/indexes/models.py#article@21
    correct tests/foreign_object/models/article.py   (ArticleTranslation, line 83)

tests/serializers/tests.py:355,364  player.team
    bound   tests/model_formsets/models.py#team@191
    correct tests/serializers/models/base.py         (Player, line 152)

Both referencing files import from .models import ... — a RELATIVE import, which by Python's own semantics names a module in the importing file's OWN package and nowhere else.

temp.file_keys holds each file's BASENAME STEM, so the key models matches tests/indexes/models.py, tests/model_formsets/models.py and every other models.py in the repository. The origin evidence then admits one of those, and — this is what makes it a phantom rather than an ambiguity — the CORRECT target is often not even a competitor, because it lives in a models/ package whose file stem is base or article, which the import does not name at all. So the wrong file is the unique admitted candidate and the tier's COUNT(DISTINCT mid) = 1 uniqueness test is satisfied.

Why this is not #134 or #168 again

#168's two LineIndex phantoms are the same FAMILY — a basename stem carrying no package — but its three directions all attacked the stem anchor itself, and all three are now measured and refused (see #168; direction 3's complete form costs 5 840 correct binds on the corpus). The reason those refusals were right is that in a module-per-file language the stem IS the language's naming rule: django.utils.version really is django/utils/version.py.

This issue is the narrower and structurally safer half of the same problem. It does not ask whether a stem is good evidence. It says: a relative import cannot name a file outside its own directory subtree, in any language that has relative imports. That is not a heuristic, it is what . means. qual_info already carries an is_relative flag, and REL_MARKERS already encodes the same idea for qualifier heads.

Proposed clause, unimplemented and unmeasured

Where an anchor is derived from a RELATIVE import (is_relative = 1, or an imports.module beginning with .), require the anchored file to be under the importing file's directory subtree. Everything else is untouched.

The subtree test does not need a path GLOB: #69 already builds temp.file_ancestor (each file mapped to every directory prefix that contains it) for exactly this shape, so it is an equality join. It exists only inside tier 1R's block today and would need hoisting.

What must be measured before it lands, and why it is NOT in #69

  • Bind-for-bind over all nine pinned repos against a baseline binary, joined on (path, line, col, kind, name, target, rule). Position alone fans out.
  • The losses read at source. The risk is real: from .. import x and re-export chains (from .models import * in a package __init__.py) legitimately reach outside the immediate directory, and JavaScript's require('../lib/x') is relative but explicitly escapes the subtree — the clause must be about the RESOLVED relative target, not about the string starting with a dot.
  • Which tiers change. file_keys feeds tier 1b's import rules, tier 1Q's anchors, tier 3's reachability and tier 1R's origin relation. A clause at the file_keys level moves all of them; a clause at one tier's anchor moves one. The measurement should say which was done.

It was deliberately kept out of #69: that change is scoped to tier 1R, and moving the corpus for a second, unrelated reason in one worktree makes the deltas uninspectable — the same reason #69 itself was deferred once.

Current containment

#69 ships a nearer-competitor gate that refuses a member-pool bind when a competing candidate sits under the referencing file's own subtree and the chosen one does not. That removes these three phantoms for member access only. The underlying anchor is unchanged and still serves every other tier.

#134 (a basename stem carries no package), #168 (the same family through tier 1Q; all three of its directions measured and refused), #69 (where these three were found).

Found while inspecting #69's new binds at source. Filed separately because the mechanism is upstream of #69 and shared with every tier that reads `temp.file_keys`. ## The measurement #69 widened tier 1R to the member pool: +4 922 binds over the nine pinned repos, 0 lost. Every one of the 4 859 binds in the first cut was censused for an ambiguous container and the 70 that were ambiguous were read at source. **Five were phantoms**, and three of them share one cause: ``` tests/foreign_object/tests.py:521 referrer.article bound tests/indexes/models.py#article@21 correct tests/foreign_object/models/article.py (ArticleTranslation, line 83) tests/serializers/tests.py:355,364 player.team bound tests/model_formsets/models.py#team@191 correct tests/serializers/models/base.py (Player, line 152) ``` Both referencing files import **`from .models import ...`** — a RELATIVE import, which by Python's own semantics names a module in the importing file's OWN package and nowhere else. `temp.file_keys` holds each file's BASENAME STEM, so the key `models` matches `tests/indexes/models.py`, `tests/model_formsets/models.py` and every other `models.py` in the repository. The origin evidence then admits one of those, and — this is what makes it a phantom rather than an ambiguity — the CORRECT target is often not even a competitor, because it lives in a `models/` package whose file stem is `base` or `article`, which the import does not name at all. So the wrong file is the *unique* admitted candidate and the tier's `COUNT(DISTINCT mid) = 1` uniqueness test is satisfied. ## Why this is not #134 or #168 again #168's two `LineIndex` phantoms are the same FAMILY — a basename stem carrying no package — but its three directions all attacked the stem anchor itself, and all three are now measured and refused (see #168; direction 3's complete form costs **5 840 correct binds** on the corpus). The reason those refusals were right is that in a module-per-file language the stem IS the language's naming rule: `django.utils.version` really is `django/utils/version.py`. This issue is the narrower and structurally safer half of the same problem. It does not ask whether a stem is good evidence. It says: **a relative import cannot name a file outside its own directory subtree, in any language that has relative imports.** That is not a heuristic, it is what `.` means. `qual_info` already carries an `is_relative` flag, and `REL_MARKERS` already encodes the same idea for qualifier heads. ## Proposed clause, unimplemented and unmeasured Where an anchor is derived from a RELATIVE import (`is_relative = 1`, or an `imports.module` beginning with `.`), require the anchored file to be under the importing file's directory subtree. Everything else is untouched. The subtree test does not need a path GLOB: #69 already builds `temp.file_ancestor` (each file mapped to every directory prefix that contains it) for exactly this shape, so it is an equality join. It exists only inside tier 1R's block today and would need hoisting. ## What must be measured before it lands, and why it is NOT in #69 - Bind-for-bind over all nine pinned repos against a baseline binary, joined on `(path, line, col, kind, name, target, rule)`. Position alone fans out. - The losses read at source. The risk is real: `from .. import x` and re-export chains (`from .models import *` in a package `__init__.py`) legitimately reach outside the immediate directory, and JavaScript's `require('../lib/x')` is relative but explicitly escapes the subtree — the clause must be about the RESOLVED relative target, not about the string starting with a dot. - Which tiers change. `file_keys` feeds tier 1b's import rules, tier 1Q's anchors, tier 3's reachability and tier 1R's origin relation. A clause at the `file_keys` level moves all of them; a clause at one tier's anchor moves one. The measurement should say which was done. It was deliberately kept out of #69: that change is scoped to tier 1R, and moving the corpus for a second, unrelated reason in one worktree makes the deltas uninspectable — the same reason #69 itself was deferred once. ## Current containment #69 ships a nearer-competitor gate that refuses a member-pool bind when a competing candidate sits under the referencing file's own subtree and the chosen one does not. That removes these three phantoms **for member access only**. The underlying anchor is unchanged and still serves every other tier. ## Related #134 (a basename stem carries no package), #168 (the same family through tier 1Q; all three of its directions measured and refused), #69 (where these three were found).
Author
Member

Consumer detail for the lane working this: your clause reaches ALL 18 of #69's phantoms, and its blast radius on #69 is at most 32 binds

#69 is being held behind this issue, so here is the evidence for the sequencing rather than the assumption.

Every one of the 18 travels a relative import

The 18 phantoms #69 measured (12 Article.id, 6 User.email, all crossing between unrelated Django test apps) come from five referencing files. Checked at source:

tests/basic/tests.py:29             from .models import (
tests/field_defaults/tests.py:20    from .models import (
tests/foreign_object/tests.py:13    from .models import (
tests/auth_tests/test_models.py:22  from .models import CustomEmailField, IntegerUsernameUser
tests/composite_pk/test_create.py:4 from .models import Post, Tenant, User

All five. from .models import normalizes to the key models, which temp.file_keys holds as the basename stem of every models.py in the repository — so tests/prefetch_related/models.py and tests/select_related_onetoone/models.py are admitted as origins for files that can only mean their own app's. That is exactly the shape this issue describes.

Four of them RETARGET rather than fall silent

Worth knowing, because it changes what your acceptance test should assert. tests/composite_pk/models/tenant.py:20 is email = models.EmailField(unique=True) — the app's own User does declare email. So for tests/composite_pk/test_create.py:34,49,55,56 your clause does not remove a bind, it moves it to the right symbol:

user.email -> tests/select_related_onetoone/models.py:6   (phantom, today)
user.email -> tests/composite_pk/models/tenant.py:20      (correct, with the clause)

The other 14 have no local candidate (Article.id is Django's implicit primary key; auth_tests' User is django.contrib.auth's), so they become honest silence. Both outcomes are right, and an acceptance test that only checks "the phantom is gone" would not tell them apart.

Blast radius on #69's 4,922 new binds: at most 32, all py-django

Measured — for each new bind whose target sits outside the referencing file's own directory subtree, does that file carry a relative import whose last segment equals the target file's basename stem:

repo new binds outside the subtree of those, admitted via a relative-import stem
rust-analyzer 2,125 0
py-django 709 32
php-guzzle 29 0
ts-zod 27 0
python-flask 9 0
cs-dapper, rust-ripgrep 0 0
total 2,899 32

18 of the 32 are the phantoms, so the collateral on #69 is at most 14 correct binds, and that 32 is an UPPER bound — recv_origin has package-tail and non-relative-import disjuncts that may still admit some of those files after your clause.

Nothing on rust-analyzer, which carries 3,231 of #69's 4,922. So the two changes are close to orthogonal, which is what makes sequencing them cheap.

Two cautions for your measurement, not for mine

  • This measures only #69's NEW binds. Your clause touches temp.file_keys, which feeds tier 1b's import rules, tier 1Q's anchors, tier 3's reachability and tier 1R's origin relation — so the pre-existing bind set will move too, on all nine repos, and that is the pass this issue already calls for.
  • from .models import * and package-shaped models/ directories are the common Django spelling; three of the five files above import from a models/ PACKAGE rather than a models.py. A clause written against "the target file's stem" will behave differently from one written against "the resolved relative target", which this issue's own text already warns about. The tests/composite_pk case is the one to build the fixture on — it is a package, and it is the one that retargets.
## Consumer detail for the lane working this: your clause reaches ALL 18 of #69's phantoms, and its blast radius on #69 is at most 32 binds #69 is being held behind this issue, so here is the evidence for the sequencing rather than the assumption. ### Every one of the 18 travels a relative import The 18 phantoms #69 measured (12 `Article.id`, 6 `User.email`, all crossing between unrelated Django test apps) come from five referencing files. Checked at source: ``` tests/basic/tests.py:29 from .models import ( tests/field_defaults/tests.py:20 from .models import ( tests/foreign_object/tests.py:13 from .models import ( tests/auth_tests/test_models.py:22 from .models import CustomEmailField, IntegerUsernameUser tests/composite_pk/test_create.py:4 from .models import Post, Tenant, User ``` All five. `from .models import` normalizes to the key `models`, which `temp.file_keys` holds as the basename stem of **every** `models.py` in the repository — so `tests/prefetch_related/models.py` and `tests/select_related_onetoone/models.py` are admitted as origins for files that can only mean their own app's. That is exactly the shape this issue describes. ### Four of them RETARGET rather than fall silent Worth knowing, because it changes what your acceptance test should assert. `tests/composite_pk/models/tenant.py:20` is `email = models.EmailField(unique=True)` — the app's own `User` **does** declare `email`. So for `tests/composite_pk/test_create.py:34,49,55,56` your clause does not remove a bind, it moves it to the right symbol: ``` user.email -> tests/select_related_onetoone/models.py:6 (phantom, today) user.email -> tests/composite_pk/models/tenant.py:20 (correct, with the clause) ``` The other 14 have no local candidate (`Article.id` is Django's implicit primary key; `auth_tests`' `User` is `django.contrib.auth`'s), so they become honest silence. **Both outcomes are right, and an acceptance test that only checks "the phantom is gone" would not tell them apart.** ### Blast radius on #69's 4,922 new binds: at most 32, all py-django Measured — for each new bind whose target sits outside the referencing file's own directory subtree, does that file carry a relative import whose last segment equals the target file's basename stem: | repo | new binds outside the subtree | of those, admitted via a relative-import stem | |---|---:|---:| | rust-analyzer | 2,125 | **0** | | py-django | 709 | **32** | | php-guzzle | 29 | 0 | | ts-zod | 27 | 0 | | python-flask | 9 | 0 | | cs-dapper, rust-ripgrep | 0 | 0 | | **total** | 2,899 | **32** | **18 of the 32 are the phantoms**, so the collateral on #69 is **at most 14 correct binds**, and that 32 is an UPPER bound — `recv_origin` has package-tail and non-relative-import disjuncts that may still admit some of those files after your clause. Nothing on rust-analyzer, which carries 3,231 of #69's 4,922. So the two changes are close to orthogonal, which is what makes sequencing them cheap. ### Two cautions for your measurement, not for mine - This measures only #69's NEW binds. Your clause touches `temp.file_keys`, which feeds tier 1b's import rules, tier 1Q's anchors, tier 3's reachability **and** tier 1R's origin relation — so the pre-existing bind set will move too, on all nine repos, and that is the pass this issue already calls for. - `from .models import *` and package-shaped `models/` directories are the common Django spelling; three of the five files above import from a `models/` PACKAGE rather than a `models.py`. A clause written against "the target file's stem" will behave differently from one written against "the resolved relative target", which this issue's own text already warns about. The `tests/composite_pk` case is the one to build the fixture on — it is a package, and it is the one that retargets.
Author
Member

REFUTED, and re-aimed — the real defect was elsewhere and is now fixed. Investigated in cbeb655; the actual repair lands with #69/#203.

Two of this issue's premises are stale, and the clause it proposes already exists in a stronger form:

  1. temp.file_ancestor exists in no file under crates/ at e5775ae — index and grep agree. It lives only in the then-unmerged #69 branch, so the "cheap, it's an equality join" route was never available on master.
  2. qual_info.is_relative is real but is about qualifier heads (self::, static::, PHP relative namespaces), not import modules — a different population entirely.
  3. The primitive is temp.import_rel (#57), crates/indexer/src/index.rs:4249-4330, built from relative_import_paths (:1022) and resolution_candidates (:1069). It path-RESOLVES a relative specifier to its actual target file, which strictly dominates the subtree test proposed here. And temp.import_key_rel's build already skips relative specifiers outright (:4611), a fact restated in tier 1b's SQL at :4906.

So on master, from .models import … cannot lend the stem models to an arbitrary models.py through the import-key arm. The subtree test would have been a weaker duplicate of an exclusion already in place.

Where the phantoms actually came from. build_recv_origin (#65) enumerates import keys under a comment claiming it is "exactly the same enumeration temp.import_key_rel is built from" — and reproduces that enumeration without the exclusion sitting beside it. A prose claim the code contradicted; filed as #203.

The fix is five lines: consult temp.import_rel for a relative specifier, keep the stem lookup for absolute ones. Measured over nine repos it removes 16 of 18 phantoms while leaving the correct-bind count unchanged at 4,904, and separately removes 38 pre-existing phantoms. The alternative — skip relative specifiers with no replacement, the literal mirror of :4611 — was built and refused on numbers: 48 pre-existing binds lost, 369 new ones, and ts-zod falling from 88 to 3, because TypeScript imports are almost entirely relative.

One nuance recorded for whoever revisits this area: for this issue's own example, relative_import_paths resolves .models to tests/foreign_object/models/__init__.py, while the correct target is reachable only through a re-export the index does not model. On the measured corpus that did not bite — py-django was the only repo whose binds moved at all — but it is the shape to watch.

Closing as refuted; the work it pointed at is #203.

**REFUTED, and re-aimed — the real defect was elsewhere and is now fixed.** Investigated in `cbeb655`; the actual repair lands with #69/#203. Two of this issue's premises are stale, and the clause it proposes **already exists in a stronger form**: 1. **`temp.file_ancestor` exists in no file under `crates/`** at `e5775ae` — index and `grep` agree. It lives only in the then-unmerged #69 branch, so the "cheap, it's an equality join" route was never available on master. 2. **`qual_info.is_relative` is real but is about qualifier heads** (`self::`, `static::`, PHP relative namespaces), not import modules — a different population entirely. 3. **The primitive is `temp.import_rel` (#57)**, `crates/indexer/src/index.rs:4249-4330`, built from `relative_import_paths` (`:1022`) and `resolution_candidates` (`:1069`). It **path-RESOLVES** a relative specifier to its actual target file, which strictly dominates the subtree test proposed here. And `temp.import_key_rel`'s build already **skips relative specifiers outright** (`:4611`), a fact restated in tier 1b's SQL at `:4906`. So on master, `from .models import …` **cannot** lend the stem `models` to an arbitrary `models.py` through the import-key arm. The subtree test would have been a weaker duplicate of an exclusion already in place. **Where the phantoms actually came from.** `build_recv_origin` (#65) enumerates import keys under a comment claiming it is *"exactly the same enumeration `temp.import_key_rel` is built from"* — and reproduces that enumeration **without the exclusion sitting beside it**. A prose claim the code contradicted; filed as **#203**. The fix is five lines: consult `temp.import_rel` for a relative specifier, keep the stem lookup for absolute ones. Measured over nine repos it removes **16 of 18** phantoms while leaving the correct-bind count **unchanged at 4,904**, and separately removes **38 pre-existing phantoms**. The alternative — skip relative specifiers with no replacement, the literal mirror of `:4611` — was built and **refused on numbers**: 48 pre-existing binds lost, 369 new ones, and ts-zod falling from 88 to 3, because TypeScript imports are almost entirely relative. One nuance recorded for whoever revisits this area: for this issue's own example, `relative_import_paths` resolves `.models` to `tests/foreign_object/models/__init__.py`, while the correct target is reachable only through a **re-export the index does not model**. On the measured corpus that did not bite — py-django was the only repo whose binds moved at all — but it is the shape to watch. Closing as refuted; the work it pointed at is #203.
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#196
No description provided.