Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions internal/graph/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
38 changes: 36 additions & 2 deletions internal/graph/memberclassify.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <top-level>).
nativeOthersLines map[string]map[int]bool

confirmed, ambiguous []MemberAccess
excluded int
Expand All @@ -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] {
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
55 changes: 55 additions & 0 deletions internal/graph/positionedverdicts_test.go
Original file line number Diff line number Diff line change
@@ -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 <top-level>. 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)
}
}
}
2 changes: 1 addition & 1 deletion internal/graph/resolverversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 25 additions & 3 deletions internal/native/js_ts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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{
Expand Down Expand Up @@ -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))) {
Expand All @@ -964,16 +977,25 @@ 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)) {
// Router<T>['match'] names the member as a type.
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<T>['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); }
}
Expand Down
80 changes: 80 additions & 0 deletions internal/native/js_ts_memberrefs_test.go
Original file line number Diff line number Diff line change
@@ -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<T>['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<R extends Router>(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("<top-level>", "<top-level>", "", 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::<top-level>@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)
}
}
Loading