Skip to content

Commit 793c804

Browse files
aksOpsclaude
andcommitted
fix(analyzer): path-qualify SERVICE node IDs to break PK collisions
ServiceDetector emitted node IDs as `"service:" + name`. When a codebase contains two modules in different directories sharing a service name (e.g. `frontend/widgets/checkbox/` and `backend/components/checkbox/` both with `name = "checkbox"` in pyproject.toml) the IDs collided. Kuzu's BulkLoadNodes COPY aborts the whole batch on duplicate PK, so enrich exited non-zero on real polyglot trees (~/projects/, polyglot-bench). Switch IDs to `service:<dir>:<name>`, falling back to `.` for the root module so the format mirrors the existing FilePath stamping. Edge IDs flip from `edge:service:<label>:contains:...` to `edge:<sn.ID>:contains:...` for consistency — sn.ID is now the path-qualified identifier, sn.Label remains the friendly display name. Adds TestServiceDetectorPathQualifiedIDsBreakCollision which fails on main and passes after this patch. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 7313a0a commit 793c804

2 files changed

Lines changed: 59 additions & 4 deletions

File tree

go/internal/analyzer/service_detector.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ func (sd *ServiceDetector) Detect(nodes []*model.CodeNode, edges []*model.CodeEd
165165
info := modules[dir]
166166
name := sd.extractServiceName(dir, info, projectDir, projectRoot)
167167
sn := &model.CodeNode{
168-
ID: "service:" + name,
168+
ID: serviceID(dir, name),
169169
Kind: model.NodeService,
170170
Label: name,
171171
FilePath: ifBlank(dir, "."),
@@ -213,7 +213,7 @@ func (sd *ServiceDetector) Detect(nodes []*model.CodeNode, edges []*model.CodeEd
213213
}
214214
n.Properties["service"] = sn.Label
215215
newEdges = append(newEdges, &model.CodeEdge{
216-
ID: fmt.Sprintf("edge:service:%s:contains:%s", sn.Label, n.ID),
216+
ID: fmt.Sprintf("edge:%s:contains:%s", sn.ID, n.ID),
217217
Kind: model.EdgeContains,
218218
SourceID: sn.ID,
219219
TargetID: n.ID,
@@ -441,3 +441,12 @@ func matchFirst(re *regexp.Regexp, s string) string {
441441
}
442442
return strings.TrimSpace(m[1])
443443
}
444+
445+
// serviceID builds a path-qualified service node ID. Two modules that share a
446+
// service name in different directories must not collide on the same primary
447+
// key — Kuzu's COPY FROM aborts the whole batch when a duplicate PK appears.
448+
// Root module (empty dir) uses "." so the format stays consistent with the
449+
// FilePath stamping at line 171.
450+
func serviceID(dir, name string) string {
451+
return "service:" + ifBlank(dir, ".") + ":" + name
452+
}

go/internal/analyzer/service_detector_test.go

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,60 @@ func TestServiceDetectorTwoModules(t *testing.T) {
6464
if mavenSvc.Layer != model.LayerBackend {
6565
t.Fatalf("maven svc layer = %v, want backend", mavenSvc.Layer)
6666
}
67-
if mavenSvc.ID != "service:my-java-app" {
68-
t.Fatalf("maven svc id = %q, want service:my-java-app", mavenSvc.ID)
67+
if mavenSvc.ID != "service:.:my-java-app" {
68+
t.Fatalf("maven svc id = %q, want service:.:my-java-app", mavenSvc.ID)
6969
}
7070

7171
npmSvc := serviceByLabel(t, r.Nodes, "api-server")
7272
if got := npmSvc.Properties["build_tool"]; got != "npm" {
7373
t.Fatalf("npm svc build_tool = %v, want npm", got)
7474
}
75+
if npmSvc.ID != "service:api:api-server" {
76+
t.Fatalf("npm svc id = %q, want service:api:api-server", npmSvc.ID)
77+
}
78+
}
79+
80+
// TestServiceDetectorPathQualifiedIDsBreakCollision: two modules in different
81+
// directories that share the same service name MUST get distinct IDs so Kuzu's
82+
// BulkLoadNodes COPY doesn't abort on duplicate primary key. Pre-fix this
83+
// emitted "service:checkbox" twice and the whole batch was rejected.
84+
func TestServiceDetectorPathQualifiedIDsBreakCollision(t *testing.T) {
85+
root := t.TempDir()
86+
// Two distinct Python modules in different folders, both named "checkbox".
87+
writeFile(t, root, "frontend/widgets/checkbox/pyproject.toml", `[project]
88+
name = "checkbox"
89+
`)
90+
writeFile(t, root, "backend/components/checkbox/pyproject.toml", `[project]
91+
name = "checkbox"
92+
`)
93+
94+
d := &ServiceDetector{}
95+
r := d.Detect(nil, nil, "p", root)
96+
97+
if len(r.Nodes) != 2 {
98+
t.Fatalf("want 2 service nodes, got %d", len(r.Nodes))
99+
}
100+
101+
ids := map[string]bool{}
102+
for _, n := range r.Nodes {
103+
if n.Label != "checkbox" {
104+
t.Errorf("want both labels = checkbox, got %q", n.Label)
105+
}
106+
if ids[n.ID] {
107+
t.Fatalf("duplicate service ID %q — Kuzu BulkLoad would abort here", n.ID)
108+
}
109+
ids[n.ID] = true
110+
}
111+
112+
want := map[string]bool{
113+
"service:frontend/widgets/checkbox:checkbox": true,
114+
"service:backend/components/checkbox:checkbox": true,
115+
}
116+
for id := range want {
117+
if !ids[id] {
118+
t.Errorf("missing expected ID %q. got: %v", id, ids)
119+
}
120+
}
75121
}
76122

77123
// TestServiceDetectorDirectoryFallback: build file with no extractable name →

0 commit comments

Comments
 (0)