diff --git a/internal/graph/graph.go b/internal/graph/graph.go index 340dc62..c96c878 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -572,11 +572,30 @@ func (m callMarks) covers(e core.Edge) bool { if hasCol && set[line] { continue // the compiler gave no column } + if !hasCol && markedOnLine(set, line) { + // The guess gave no column (zod default.test.ts: result1.get + // guessed $ZodRegistry.get at 358; tsc resolved Map.get at 358:18). + continue + } return false } return true } +// markedOnLine reports whether set holds a position on line ("line" or +// "line:col"). +func markedOnLine(set map[string]bool, line string) bool { + if set[line] { + return true + } + for p := range set { + if l, _, ok := strings.Cut(p, ":"); ok && l == line { + return true + } + } + return false +} + // qualOwner is the type part of a qualified member name ("" for a function). func qualOwner(qual string) string { if i := strings.LastIndexByte(qual, '.'); i >= 0 { diff --git a/internal/graph/memberclassify.go b/internal/graph/memberclassify.go index 9f7360c..ea7c982 100644 --- a/internal/graph/memberclassify.go +++ b/internal/graph/memberclassify.go @@ -48,6 +48,16 @@ type memberClassifier struct { // a same-named member of another declarer. Compiler facts outrank the // receiver heuristics below. nativeOurs, nativeOthers map[string]bool + // nativeOursLines: per enclosing-symbol ID, the lines of its native + // edges to this member that carry positions (TypeScript's checker). A + // symbol listed here confirms only occurrences on those lines: hono + // Context.header reads options?.append (ours) and calls + // headers.append(...) (Headers.append) a line later. + nativeOursLines map[string]map[int]bool + // nativeOthersLines: the same for edges to other declarers' members (h3 + // tracing.test.ts builds H3Route and RouteDefinition literals, each with + // a handler: key, in one ). + nativeOthersLines map[string]map[int]bool confirmed, ambiguous []MemberAccess excluded int @@ -62,6 +72,7 @@ func newMemberClassifier(g *CodeGraph, a *memberAnchor) *memberClassifier { decl[d.ID] = true } c.nativeOurs, c.nativeOthers = map[string]bool{}, map[string]bool{} + c.nativeOursLines, c.nativeOthersLines = map[string]map[int]bool{}, map[string]map[int]bool{} for _, id := range g.idsNamed(a.name) { s := g.symbols[id] if s.ParentSymbol != "" && !a.owners[s.ParentSymbol] { @@ -75,21 +86,44 @@ func newMemberClassifier(g *CodeGraph, a *memberAnchor) *memberClassifier { } if decl[id] { c.nativeOurs[e.From] = true + noteEdgeLines(c.nativeOursLines, e) } else if s.ParentSymbol != "" { c.nativeOthers[e.From] = true + noteEdgeLines(c.nativeOthersLines, e) } } } return c } +// noteEdgeLines records a positioned edge's lines under its source. +func noteEdgeLines(into map[string]map[int]bool, e core.Edge) { + if e.Lines == "" { + return + } + if into[e.From] == nil { + into[e.From] = map[int]bool{} + } + for _, p := range strings.Split(e.Lines, ",") { + if l, _ := splitPosition(p); l > 0 { + into[e.From][l] = true + } + } +} + // compilerVerdict applies compiler-resolved member facts to a heuristic // verdict for an occurrence inside enclosing symbol e. -func (c *memberClassifier) compilerVerdict(e *core.SymbolRecord, v verdict, evidence string) (verdict, string) { +func (c *memberClassifier) compilerVerdict(e *core.SymbolRecord, line int, v verdict, evidence string) (verdict, string) { if e == nil { return v, evidence } ours, others := c.nativeOurs[e.ID], c.nativeOthers[e.ID] + if lines, ok := c.nativeOursLines[e.ID]; ok && !lines[line] { + ours = false + } + if lines, ok := c.nativeOthersLines[e.ID]; ok && !lines[line] { + others = false + } switch { case ours && !others: return vConfirm, "compiler-resolved" @@ -195,7 +229,7 @@ func (c *memberClassifier) classify(occs []core.MemberOccurrence) { if o.Form == "access" || o.Form == "key" { // member-shaped occurrences only: a same-named local in the // same function is not the property - v, evidence = c.compilerVerdict(e, v, evidence) + v, evidence = c.compilerVerdict(e, o.Line, v, evidence) } } if v == vSkip { diff --git a/internal/graph/positionedverdicts_test.go b/internal/graph/positionedverdicts_test.go new file mode 100644 index 0000000..ba9bde5 --- /dev/null +++ b/internal/graph/positionedverdicts_test.go @@ -0,0 +1,55 @@ +package graph + +import ( + "testing" + + "github.com/provasign/grove/internal/core" +) + +// zod default.test.ts (2026-10-16): the parser guessed result1.get(...) as +// $ZodRegistry.get at line 358 (no column); tsc resolved it to Map.get at +// 358:18. The guess is covered by the compiler's mark. +func TestCallMarksCoverColumnlessGuess(t *testing.T) { + m := callMarks{"f::top": {"get": {"358:18": true, "361:18": true}}} + guess := core.Edge{From: "f::top", To: "r.ts::Registry.get@b", Type: core.EdgeCalls, Lines: "358,361"} + if !m.covers(guess) { + t.Errorf("columnless guess on marked lines not covered") + } + guess.Lines = "358,362" + if m.covers(guess) { + t.Errorf("guess with an unmarked line covered") + } +} + +// hono Context.header reads options?.append (SetHeadersOptions.append) and +// calls headers.append(...) (Headers.append) a line later; h3's tracing +// tests build H3Route and other handler: literals in one . With +// positioned compiler edges the verdict is per line. +func TestCompilerVerdictPerLine(t *testing.T) { + c := &memberClassifier{ + nativeOurs: map[string]bool{"hdr": true, "top": true}, + nativeOthers: map[string]bool{"top": true}, + nativeOursLines: map[string]map[int]bool{}, + nativeOthersLines: map[string]map[int]bool{}, + } + noteEdgeLines(c.nativeOursLines, core.Edge{From: "hdr", Lines: "526:25"}) + noteEdgeLines(c.nativeOursLines, core.Edge{From: "top", Lines: "768:9,832:9"}) + noteEdgeLines(c.nativeOthersLines, core.Edge{From: "top", Lines: "54:7"}) + hdr := &core.SymbolRecord{ID: "hdr"} + top := &core.SymbolRecord{ID: "top"} + for _, tc := range []struct { + e *core.SymbolRecord + line int + want verdict + }{ + {hdr, 526, vConfirm}, + {hdr, 527, vAmbiguous}, + {top, 768, vConfirm}, + {top, 54, vExclude}, + {top, 100, vAmbiguous}, + } { + if v, _ := c.compilerVerdict(tc.e, tc.line, vAmbiguous, "heuristic"); v != tc.want { + t.Errorf("%s:%d: verdict %v, want %v", tc.e.ID, tc.line, v, tc.want) + } + } +} diff --git a/internal/graph/resolverversion.go b/internal/graph/resolverversion.go index 4cb47fa..4f1e4c8 100644 --- a/internal/graph/resolverversion.go +++ b/internal/graph/resolverversion.go @@ -4,7 +4,7 @@ import "github.com/provasign/grove/internal/core" // Bump when edge resolution changes for unchanged source. Stored call edges // cannot be reused across this boundary merely because blob hashes match. -const ResolverVersion = "2026-10-16-language-graph-v41" +const ResolverVersion = "2026-10-16-language-graph-v42" // CurrentNativeEdges keeps supplemental evidence that remains valid during // a resolver upgrade. Legacy Python native calls were name-only bindings. diff --git a/internal/native/js_ts.go b/internal/native/js_ts.go index 0893f8a..84dc066 100644 --- a/internal/native/js_ts.go +++ b/internal/native/js_ts.go @@ -269,6 +269,8 @@ func (jsTSAnalyzer) Analyze(ctx context.Context, req Request) Result { ToName string `json:"toName"` ToLine int `json:"toLine"` Write bool `json:"write"` + Line int `json:"line"` + Col int `json:"col"` } `json:"members"` Decls []compilerDecl `json:"decls"` } @@ -308,6 +310,11 @@ func (jsTSAnalyzer) Analyze(ctx context.Context, req Request) Result { to, okTo := symbols.at(edge.To, edge.ToName, edge.ToLine) if okFrom && okTo && from.ID == to.ID { marks.add(from.ID, to.Name, edge.Line, edge.Col) + if edge.Line > 0 { + // Direct recursion (`this.get(p)` inside get): a rename or + // signature change edits it, as the resolver's self edge says. + calls.add(symbolEdge(from, to, core.EdgeCalls, 0.98), edge.Line, edge.Col) + } continue } if okFrom && okTo { @@ -326,18 +333,23 @@ func (jsTSAnalyzer) Analyze(ctx context.Context, req Request) Result { // Member references: property reads/writes and object-literal properties // resolved by the checker (the literal's contextual type names the // member). Field-rename impact confirms occurrences through these. + // Each edge carries the member name's positions. A member's reference + // to itself (`this.match = router.match.bind(router)` inside + // SmartRouter.match) is kept as a self edge: a rename edits it too. + refs := newPositionedEdges() for _, m := range payload.Members { from, okFrom := symbols.atOrTopLevel(m.From, m.FromName, m.FromLine) to, okTo := symbols.at(m.To, m.ToName, m.ToLine) - if !okFrom || !okTo || from.ID == to.ID { + if !okFrom || !okTo || (from.ID == to.ID && m.Line <= 0) { continue } edgeType := core.EdgeReads if m.Write { edgeType = core.EdgeWrites } - edges = append(edges, symbolEdge(from, to, edgeType, 0.97)) + refs.add(symbolEdge(from, to, edgeType, 0.97), m.Line, m.Col) } + edges = append(edges, refs.finish()...) res := Result{ Edges: edges, Diagnostics: append([]string{ @@ -940,11 +952,12 @@ function visit(checker, options, host, sf, node, stack) { } } if (fromName) { - let target, write = false; + let target, write = false, nameNode; if (ts.isPropertyAccessExpression(node) && !(ts.isCallExpression(node.parent) && node.parent.expression === node)) { target = memberDeclInfo(checker, checker.getSymbolAtLocation(node.name)); write = isWriteTarget(node); + nameNode = node.name; } else if ((ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node) || ts.isMethodDeclaration(node)) && node.parent && ts.isObjectLiteralExpression(node.parent) && node.name && (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name))) { @@ -964,6 +977,7 @@ function visit(checker, options, host, sf, node, stack) { } } write = true; + nameNode = node.name; } else if (ts.isIndexedAccessTypeNode(node) && ts.isLiteralTypeNode(node.indexType) && ts.isStringLiteral(node.indexType.literal)) { @@ -971,9 +985,17 @@ function visit(checker, options, host, sf, node, stack) { const objType = checker.getTypeFromTypeNode(node.objectType); const p = objType && checker.getPropertyOfType(checker.getApparentType(objType), node.indexType.literal.text); if (p) target = memberDeclInfo(checker, p); + nameNode = node.indexType.literal; } if (target) { const m = {from: rel(sf.fileName), fromName, fromLine, to: target.file, toName: target.name, toLine: target.line, write}; + // The member name's position (1-based line, UTF-16 column; inside the + // quotes for Router['match']), so a rename lists the token. + if (nameNode) { + const at = sf.getLineAndCharacterOfPosition(nameNode.getStart(sf)); + m.line = at.line + 1; + m.col = at.character + 1 + (ts.isStringLiteral(nameNode) ? 1 : 0); + } const key = JSON.stringify(m); if (!memberKeys.has(key)) { memberKeys.add(key); members.push(m); } } diff --git a/internal/native/js_ts_memberrefs_test.go b/internal/native/js_ts_memberrefs_test.go new file mode 100644 index 0000000..39e03d2 --- /dev/null +++ b/internal/native/js_ts_memberrefs_test.go @@ -0,0 +1,80 @@ +package native + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/provasign/grove/internal/core" +) + +// hono Router.match (bench29, 2026-10-16): change_impact said "closed" and +// missed Router['match'], `this.match = match`, a member's own +// `this.match = ...` and an object literal's `match:`. The checker resolved +// every one, but its member edges carried no positions (so no edit site +// listed them) and a member's reference to itself was dropped. A direct +// recursive call is an edit site too (zod $ZodRegistry.get's this.get(p)). +func TestJsTSMemberRefsCarryPositions(t *testing.T) { + root := t.TempDir() + linkTypeScriptForTest(t, root) + write := func(rel, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, rel), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("tsconfig.json", `{"compilerOptions":{"strict":true},"include":["**/*.ts"]}`) + write("main.ts", `export interface Router { + match(p: string): number +} +export function match(this: R, p: string): number { + const m = ((q: string) => q.length) as Router['match'] + this.match = m + return m(p) +} +export class Smart implements Router { + match(p: string): number { + this.match = (q: string) => q.length + return p === "" ? 0 : this.match(p.slice(1)) + } +} +export const literal: Router = { + match: (p: string) => p.length, +} +`) + sym := func(name, qual, parent string, kind core.SymbolKind, start, end int) core.SymbolRecord { + return core.SymbolRecord{ID: "main.ts::" + qual + "@b", FilePath: "main.ts", BlobSHA: "b", Language: "typescript", + Kind: kind, Name: name, QualifiedName: qual, ParentSymbol: parent, Span: core.LineRange{Start: start, End: end}} + } + parsed := []core.SymbolRecord{ + sym("Router", "Router", "", core.KindInterface, 1, 3), + sym("match", "Router.match", "Router", core.KindMethod, 2, 2), + sym("match", "match", "", core.KindFunction, 4, 8), + sym("Smart", "Smart", "", core.KindClass, 9, 14), + sym("match", "Smart.match", "Smart", core.KindMethod, 10, 13), + sym("", "", "", core.KindFunction, 1, 17), + } + res := (jsTSAnalyzer{}).Analyze(context.Background(), Request{Root: root, Files: []string{"main.ts"}, Symbols: parsed}) + got := map[string]string{} + for _, e := range res.Edges { + if e.Type == core.EdgeReads || e.Type == core.EdgeWrites || e.Type == core.EdgeCalls { + got[string(e.Type)+" "+e.From+" -> "+e.To] = e.Lines + } + } + want := map[string]string{ + "reads main.ts::match@b -> main.ts::Router.match@b": "5:50", + "writes main.ts::match@b -> main.ts::Router.match@b": "6:8", + "writes main.ts::Smart.match@b -> main.ts::Smart.match@b": "11:10", + "calls main.ts::Smart.match@b -> main.ts::Smart.match@b": "12:32", + "writes main.ts::@b -> main.ts::Router.match@b": "16:3", + } + for k, lines := range want { + if got[k] != lines { + t.Errorf("%s: lines %q, want %q", k, got[k], lines) + } + } + if t.Failed() { + t.Logf("edges: %v\ndiagnostics: %v", got, res.Diagnostics) + } +}