Skip to content

Reindexing a file destroys incoming capability and structural edges from unchanged files #690

Description

@pbednarcik

Split out of the #678 review, where it surfaced as the thing an eviction receipt gate was detecting but could not repair.

What happens

A file-scoped eviction deletes every edge touching a doomed node, including edges whose SOURCE is an unchanged file. Before the eviction, restubIncomingRefs parks the incoming edges it can, but it is gated on IsResolvableRefEdge:

// internal/graph/stub.go
case EdgeCalls, EdgeReferences, EdgeReads, EdgeWrites,
    EdgeTypedAs, EdgeReturns, EdgeInstantiates,
    EdgeImplements, EdgeExtends, EdgeComposes:

Every other kind is destroyed outright. On a real Go package that means accesses_field, arg_of and tests edges, in quantity: the #678 review instrumented the eviction on a flat copy of internal/graph and counted accesses_field alone in the dozens per evicted file. Independently of the exact count, 24 of 25 single-file reindexes of that tree had at least one surviving-source edge of a non-restubbed kind, so this is the common shape rather than the exceptional one.

Nothing recreates them. Resolution retargets edges that still exist and are parked under a stub, so a deleted edge is out of its reach, and the edge only comes back when the SOURCE file is next reparsed for its own reasons. Editing b.go therefore silently degrades queries anchored on the unchanged a.go.

Reproduction

TestResolveAllDoesNotRestoreAnEvictionDestroyedCapabilityEdge in internal/resolver/eviction_edge_loss_test.go is the minimal case, and lands with #678:

  • a.go::Caller --accesses_field--> b.go::T.F, resolved.
  • Reindex b.go: evict the file, re-add the identical definition.
  • The edge is gone, and a subsequent ResolveAll does not bring it back.

The test asserts the loss, so it will start failing when this is fixed - that is deliberate, and the fix should invert it.

Why it is filed rather than fixed in place

The obvious fix, widening restubIncomingRefs to park these kinds, does not work as-is. Those kinds are not name-resolvable, so parking them under unresolved::<Name> mints stubs that nothing will ever rebind, trading silent edge loss for a growing population of permanently pending edges. Making them resolvable is a resolver-semantics change with its own ambiguity and cost questions - accesses_field targets field names, which collide heavily.

Directions worth weighing:

  1. Re-derive the affected edges for surviving sources as part of the eviction, from the surviving side's existing parse, rather than parking or resolving them.
  2. Give the non-resolvable kinds their own parked form with a targeted rebind pass keyed on the doomed node identity rather than on a name.
  3. Record the destroyed edges in the mutation so a consumer can decide to reparse the affected sources.

Scope note

This is not a receipt-exactness problem. The eviction's resolution delta stays exactly describable, because the edge is equally absent whichever pass runs afterwards. #678 originally failed the receipt closed on this shape, which forced a whole-graph resolve that reaches an identical graph; that gate has been removed and the damage tracked here instead.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions