diff --git a/internal/graph/incremental.go b/internal/graph/incremental.go index d4ead53..7c94cec 100644 --- a/internal/graph/incremental.go +++ b/internal/graph/incremental.go @@ -720,11 +720,35 @@ func affectedCallers(idx *edgeIndex, symbols []core.SymbolRecord, changedFiles m // even when the changed file is out of scope. if callSitesTouchNames(s.CallSites, nameDelta) || callSitesTouchNames(s.AttrSites, nameDelta) { affected[s.ID] = true + continue + } + // TS member reads/writes (tsMemberRefEdges) come from the body text, + // not from sites: `inst._zod.parse = wrapped` re-binds when a `_zod` + // or `parse` declaration changes anywhere (fuzz 2026-10-16: a new + // file declaring another `_zod` left zod memoizer's write edges stale). + if tsFamilyLang(s.Language) && dottedTextTouchesNames(s.RawText, nameDelta) { + affected[s.ID] = true } } return affected, true } +// dottedTextTouchesNames reports whether any segment of a dotted chain in +// text (`a.b.c`) is in the lowercased name-delta set. +func dottedTextTouchesNames(text string, nameDelta map[string]bool) bool { + if len(nameDelta) == 0 || !strings.Contains(text, ".") { + return false + } + for _, chain := range tsDottedChainRe.FindAllString(text, -1) { + for _, seg := range strings.Split(chain, ".") { + if nameDelta[strings.ToLower(strings.TrimSpace(seg))] { + return true + } + } + } + return false +} + // callSitesTouchNames reports whether any site's callee (bare name, method // name, or receiver qualifier) appears in the lowercased name-delta set. func callSitesTouchNames(sites []core.CallSite, nameDelta map[string]bool) bool { diff --git a/internal/graph/tspropertytypes_test.go b/internal/graph/tspropertytypes_test.go index b3ea784..3b0b4c2 100644 --- a/internal/graph/tspropertytypes_test.go +++ b/internal/graph/tspropertytypes_test.go @@ -165,3 +165,55 @@ func TestTSPropertyRule_GenericConstraintAlone(t *testing.T) { t.Errorf("`_zod: I` with I extends $SubInternals must reach _$Internals.parse") } } + +// The member reads/writes come from the caller's text, not its sites, so the +// incremental name rule must look at the text: a new file declaring another +// `_zod` (whose type has its own parse) makes the binding ambiguous, and a +// caller in an untouched file must drop its write edge as a full build does. +func TestTSPropertyRule_DeltaRebindsMemberRefsOnNewDeclaration(t *testing.T) { + prev := tsPropFixture() + for i := range prev { + if prev[i].ID == "s::top" { + prev[i].ID, prev[i].FilePath = "w::top", "w.ts" + prev[i].CallSites = prev[i].CallSites[:1] // reads/writes only: no `.parse(` call site + } + } + add := func(id string, kind core.SymbolKind, name, parent, sig, raw string, line int) core.SymbolRecord { + qn := name + if parent != "" { + qn = parent + "." + name + } + return core.SymbolRecord{ID: id, FilePath: "fz.ts", BlobSHA: "2", Language: "typescript", Kind: kind, + Name: name, QualifiedName: qn, ParentSymbol: parent, Signature: sig, RawText: raw, + Span: core.LineRange{Start: line, End: line}} + } + next := append(append([]core.SymbolRecord{}, prev...), + add("fz::Fz", core.KindInterface, "Fz", "", "interface Fz", "interface Fz { _zod: Fz2 }", 1), + add("fz::Fz._zod", core.KindField, "_zod", "Fz", "_zod: Fz2", "_zod: Fz2", 1), + add("fz::Fz2", core.KindInterface, "Fz2", "", "interface Fz2", "interface Fz2 { parse(x: number): void }", 2), + add("fz::Fz2.parse", core.KindMethod, "parse", "Fz2", "parse(x: number): void", "parse(x: number): void", 2), + ) + memberRefs := func(edges []core.Edge) map[string]bool { + out := map[string]bool{} + for _, e := range edges { + if e.From == "w::top" && e.Reason == core.ReasonMemberRef { + out[string(e.Type)+" "+e.To+" "+e.Lines] = true + } + } + return out + } + prevEdges := BuildEdges(prev) + if len(memberRefs(prevEdges)) == 0 { + t.Fatal("fixture: the caller should have member-ref edges before the edit") + } + full := memberRefs(BuildEdges(next)) + delta := memberRefs(BuildEdgesDelta(prevEdges, prev, next, map[string]bool{"fz.ts": true}, nil)) + if len(full) != len(delta) { + t.Fatalf("delta member-ref edges %v, full build %v", delta, full) + } + for k := range full { + if !delta[k] { + t.Fatalf("delta member-ref edges %v, full build %v", delta, full) + } + } +}