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
13 changes: 11 additions & 2 deletions go/internal/analyzer/service_detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "."),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
50 changes: 48 additions & 2 deletions go/internal/analyzer/service_detector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 →
Expand Down
Loading