Skip to content

resolver: suffix matching resolves cross-package at full confidence (from #183 items 2 and 4) #319

Description

@zaebee

Refiled from #183, whose items 2 and 4 describe the same heuristic in two places. Item 2's original wording did not match the code — corrected below — but there is a real defect underneath both.

The heuristic

Two sites resolve a name by suffix match and return the result at full confidence.

indices.pymap_to_node_fqn strip-loop. Strips leading segments off the imported FQN and takes the first suffix that exists as a node:

parts = imported_fqn.split(".")
for i in range(1, len(parts)):
    candidate = ".".join(parts[i:])
    if candidate in self.nodes:
        return candidate

symbols.py:49resolve_class_ref validator. Looks the bare class name up in the global index, then accepts the result if its tail matches:

resolved.endswith(f".{name}")

What is actually wrong (and what is not)

#183 said the strip-loop "returns the FIRST candidate … on a prefix collision it resolves to an arbitrary symbol". That part is incorrect. The loop strips fewest segments first, so it returns the most specific suffix, and candidate in self.nodes is an exact key lookup — there is no candidate set to disambiguate, so the len(candidates) == 1 guard from the suffix_map branch above does not carry over. Measured:

nodes {engine.X, X}   map_to_node_fqn('cgis.resolver.engine.X')  ->  engine.X    # most specific wins

The real defect is different: a suffix match cannot tell "the import had an extra package prefix" from "this is a different package entirely", and it reports the guess at confidence 1.0.

nodes {utils.helper}  map_to_node_fqn('pkg_a.utils.helper')  ->  utils.helper   # unrelated package
nodes {thing}         map_to_node_fqn('a.b.c.thing')         ->  thing          # collapses to a bare leaf

The second shape is the sharp one — a four-segment import matching a bare top-level name is close to unconstrained. resolve_class_ref has the same hole: for a dotted ref util.Base it requires only that the resolved FQN end in .util.Base, which any util module in the graph satisfies.

Absorbing a prefix mismatch is the function's stated purpose, so the heuristic is not simply a bug to delete — it needs a confidence signal or a tighter acceptance rule.

Why this cannot be validated here

Instrumented map_to_node_fqn over a full ingest of src/:

1912 calls
  unresolved   1178   61.6%
  exact         734   38.4%
  strip-loop      0    0.0%

The strip-loop never fires on cgis's own graph. It is a compatibility path for layouts that do not match source_roots, so a self-ingest can neither exercise a change nor measure its blast radius.

The constraint that makes this land carefully

Tightening resolution raises unresolved_ratio, which feeds drift_tolerance — and tolerances are a committed ratchet since #151, with docs/ontology/tolerances.lock failing the suite on any increase. A change that lowers the resolution rate on a repo where this path is live can trip that gate. That is the intended behaviour of the ratchet, but it means this work needs measurement before it needs a patch.

Suggested approach

  1. Pick a corpus repo where the path actually fires — the 🗺️ Roadmap: priorities from multi-repo dogfooding (#170–#178) #179 dogfooding set (httpx, click, sqlalchemy) or the Ownima backend, ingested without matching source_roots so the prefix mismatch is real.
  2. Record baseline: strip-loop hit count, resolution rate, unresolved_ratio per domain.
  3. Then choose between:
    • confidence, not rejection — keep the match but emit it below 1.0, so downstream consumers can filter (--min-confidence already exists);
    • require a minimum retained prefix — refuse the fully-stripped bare-leaf case, keeping at least one module segment;
    • uniqueness across strip levels — refuse when more than one strip level matches a node.
  4. Re-measure. A tolerance re-baseline, if needed, is legitimate but must be explicit in the PR per ci: enforce the drift-tolerance ratchet (values may only go down) #151.

Acceptance

  • A fixture where pkg_a.utils.helper must not resolve to an unrelated utils.helper.
  • Before/after resolution counts on at least one corpus repo where the path fires.
  • Either the ratchet still passes, or the re-baseline is justified in the PR.

Supersedes items 2 and 4 of #183.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions