diff --git a/go/internal/analyzer/service_detector.go b/go/internal/analyzer/service_detector.go index 842b7316..d9123b87 100644 --- a/go/internal/analyzer/service_detector.go +++ b/go/internal/analyzer/service_detector.go @@ -165,7 +165,7 @@ func (sd *ServiceDetector) Detect(nodes []*model.CodeNode, edges []*model.CodeEd info := modules[dir] name := sd.extractServiceName(dir, info, projectDir, projectRoot) sn := &model.CodeNode{ - ID: "service:" + name, + ID: serviceID(dir, name), Kind: model.NodeService, Label: name, FilePath: ifBlank(dir, "."), @@ -213,7 +213,7 @@ func (sd *ServiceDetector) Detect(nodes []*model.CodeNode, edges []*model.CodeEd } n.Properties["service"] = sn.Label newEdges = append(newEdges, &model.CodeEdge{ - ID: fmt.Sprintf("edge:service:%s:contains:%s", sn.Label, n.ID), + ID: fmt.Sprintf("edge:%s:contains:%s", sn.ID, n.ID), Kind: model.EdgeContains, SourceID: sn.ID, TargetID: n.ID, @@ -441,3 +441,12 @@ func matchFirst(re *regexp.Regexp, s string) string { } return strings.TrimSpace(m[1]) } + +// serviceID builds a path-qualified service node ID. Two modules that share a +// service name in different directories must not collide on the same primary +// key — Kuzu's COPY FROM aborts the whole batch when a duplicate PK appears. +// Root module (empty dir) uses "." so the format stays consistent with the +// FilePath stamping at line 171. +func serviceID(dir, name string) string { + return "service:" + ifBlank(dir, ".") + ":" + name +} diff --git a/go/internal/analyzer/service_detector_test.go b/go/internal/analyzer/service_detector_test.go index 8453214a..883f1ce6 100644 --- a/go/internal/analyzer/service_detector_test.go +++ b/go/internal/analyzer/service_detector_test.go @@ -64,14 +64,60 @@ func TestServiceDetectorTwoModules(t *testing.T) { if mavenSvc.Layer != model.LayerBackend { t.Fatalf("maven svc layer = %v, want backend", mavenSvc.Layer) } - if mavenSvc.ID != "service:my-java-app" { - t.Fatalf("maven svc id = %q, want service:my-java-app", mavenSvc.ID) + if mavenSvc.ID != "service:.:my-java-app" { + t.Fatalf("maven svc id = %q, want service:.:my-java-app", mavenSvc.ID) } npmSvc := serviceByLabel(t, r.Nodes, "api-server") if got := npmSvc.Properties["build_tool"]; got != "npm" { t.Fatalf("npm svc build_tool = %v, want npm", got) } + if npmSvc.ID != "service:api:api-server" { + t.Fatalf("npm svc id = %q, want service:api:api-server", npmSvc.ID) + } +} + +// TestServiceDetectorPathQualifiedIDsBreakCollision: two modules in different +// directories that share the same service name MUST get distinct IDs so Kuzu's +// BulkLoadNodes COPY doesn't abort on duplicate primary key. Pre-fix this +// emitted "service:checkbox" twice and the whole batch was rejected. +func TestServiceDetectorPathQualifiedIDsBreakCollision(t *testing.T) { + root := t.TempDir() + // Two distinct Python modules in different folders, both named "checkbox". + writeFile(t, root, "frontend/widgets/checkbox/pyproject.toml", `[project] +name = "checkbox" +`) + writeFile(t, root, "backend/components/checkbox/pyproject.toml", `[project] +name = "checkbox" +`) + + d := &ServiceDetector{} + r := d.Detect(nil, nil, "p", root) + + if len(r.Nodes) != 2 { + t.Fatalf("want 2 service nodes, got %d", len(r.Nodes)) + } + + ids := map[string]bool{} + for _, n := range r.Nodes { + if n.Label != "checkbox" { + t.Errorf("want both labels = checkbox, got %q", n.Label) + } + if ids[n.ID] { + t.Fatalf("duplicate service ID %q — Kuzu BulkLoad would abort here", n.ID) + } + ids[n.ID] = true + } + + want := map[string]bool{ + "service:frontend/widgets/checkbox:checkbox": true, + "service:backend/components/checkbox:checkbox": true, + } + for id := range want { + if !ids[id] { + t.Errorf("missing expected ID %q. got: %v", id, ids) + } + } } // TestServiceDetectorDirectoryFallback: build file with no extractable name →