Skip to content

Commit 60c2ead

Browse files
fix(core): add deterministic normalization for parallel batch analyzer output
Parallel file-analyzer subagents can produce inconsistent node IDs (project-name prefixed, double-prefixed, bare paths) and invalid complexity values. Phase 3 ASSEMBLE now normalizes these deterministically before merging, preventing cascading edge drops and dashboard load failures. - Add normalize-graph.ts with normalizeNodeId, normalizeComplexity, and normalizeBatchOutput utilities - Rewrite SKILL.md Phase 3 with 6-step normalization sequence - Strengthen file-analyzer prompt with ID format warnings - Add 32 normalization tests and 2 schema boundary tests
1 parent 0694163 commit 60c2ead

6 files changed

Lines changed: 634 additions & 4 deletions

File tree

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
normalizeNodeId,
4+
normalizeComplexity,
5+
normalizeBatchOutput,
6+
} from "../analyzer/normalize-graph.js";
7+
8+
describe("normalizeNodeId", () => {
9+
it("passes through a correct file ID unchanged", () => {
10+
expect(
11+
normalizeNodeId("file:src/index.ts", { type: "file" }),
12+
).toBe("file:src/index.ts");
13+
});
14+
15+
it("passes through a correct func ID unchanged", () => {
16+
expect(
17+
normalizeNodeId("func:src/utils.ts:formatDate", { type: "function" }),
18+
).toBe("func:src/utils.ts:formatDate");
19+
});
20+
21+
it("passes through a correct class ID unchanged", () => {
22+
expect(
23+
normalizeNodeId("class:src/models/User.ts:User", { type: "class" }),
24+
).toBe("class:src/models/User.ts:User");
25+
});
26+
27+
it("fixes double-prefixed IDs", () => {
28+
expect(
29+
normalizeNodeId("file:file:src/foo.ts", { type: "file" }),
30+
).toBe("file:src/foo.ts");
31+
});
32+
33+
it("strips project-name prefix when valid prefix follows", () => {
34+
expect(
35+
normalizeNodeId("my-project:file:src/foo.ts", { type: "file" }),
36+
).toBe("file:src/foo.ts");
37+
});
38+
39+
it("strips project-name prefix and adds correct prefix for bare path", () => {
40+
expect(
41+
normalizeNodeId("my-project:src/foo.ts", { type: "file" }),
42+
).toBe("file:src/foo.ts");
43+
});
44+
45+
it("adds file: prefix to bare paths", () => {
46+
expect(
47+
normalizeNodeId("frontend/src/utils/constants.ts", { type: "file" }),
48+
).toBe("file:frontend/src/utils/constants.ts");
49+
});
50+
51+
it("reconstructs func ID from filePath and name for bare paths", () => {
52+
expect(
53+
normalizeNodeId("formatDate", {
54+
type: "function",
55+
filePath: "src/utils.ts",
56+
name: "formatDate",
57+
}),
58+
).toBe("func:src/utils.ts:formatDate");
59+
});
60+
61+
it("reconstructs class ID from filePath and name for bare paths", () => {
62+
expect(
63+
normalizeNodeId("User", {
64+
type: "class",
65+
filePath: "src/models/User.ts",
66+
name: "User",
67+
}),
68+
).toBe("class:src/models/User.ts:User");
69+
});
70+
71+
it("trims whitespace", () => {
72+
expect(
73+
normalizeNodeId(" file:src/foo.ts ", { type: "file" }),
74+
).toBe("file:src/foo.ts");
75+
});
76+
77+
it("handles module: and concept: prefixes", () => {
78+
expect(
79+
normalizeNodeId("module:auth", { type: "module" }),
80+
).toBe("module:auth");
81+
expect(
82+
normalizeNodeId("concept:caching", { type: "concept" }),
83+
).toBe("concept:caching");
84+
});
85+
86+
it("handles double project-name prefix", () => {
87+
expect(
88+
normalizeNodeId("my-project:service:docker-compose.yml", {
89+
type: "file",
90+
}),
91+
).toBe("file:docker-compose.yml");
92+
});
93+
94+
it("returns empty string for empty input", () => {
95+
expect(normalizeNodeId("", { type: "file" })).toBe("");
96+
});
97+
98+
it("falls back to untouched ID for unknown node type", () => {
99+
expect(normalizeNodeId("some-id", { type: "widget" as any })).toBe("some-id");
100+
});
101+
});
102+
103+
describe("normalizeComplexity", () => {
104+
it("passes through valid values unchanged", () => {
105+
expect(normalizeComplexity("simple")).toBe("simple");
106+
expect(normalizeComplexity("moderate")).toBe("moderate");
107+
expect(normalizeComplexity("complex")).toBe("complex");
108+
});
109+
110+
it("maps 'low' to 'simple'", () => {
111+
expect(normalizeComplexity("low")).toBe("simple");
112+
});
113+
114+
it("maps 'high' to 'complex'", () => {
115+
expect(normalizeComplexity("high")).toBe("complex");
116+
});
117+
118+
it("maps 'medium' to 'moderate'", () => {
119+
expect(normalizeComplexity("medium")).toBe("moderate");
120+
});
121+
122+
it("maps other aliases from upstream COMPLEXITY_ALIASES", () => {
123+
expect(normalizeComplexity("easy")).toBe("simple");
124+
expect(normalizeComplexity("hard")).toBe("complex");
125+
expect(normalizeComplexity("difficult")).toBe("complex");
126+
expect(normalizeComplexity("intermediate")).toBe("moderate");
127+
});
128+
129+
it("is case-insensitive", () => {
130+
expect(normalizeComplexity("LOW")).toBe("simple");
131+
expect(normalizeComplexity("High")).toBe("complex");
132+
expect(normalizeComplexity("MODERATE")).toBe("moderate");
133+
});
134+
135+
it("maps numeric 1-3 to simple", () => {
136+
expect(normalizeComplexity(1)).toBe("simple");
137+
expect(normalizeComplexity(3)).toBe("simple");
138+
});
139+
140+
it("maps numeric 4-6 to moderate", () => {
141+
expect(normalizeComplexity(4)).toBe("moderate");
142+
expect(normalizeComplexity(6)).toBe("moderate");
143+
});
144+
145+
it("maps numeric 7-10 to complex", () => {
146+
expect(normalizeComplexity(7)).toBe("complex");
147+
expect(normalizeComplexity(10)).toBe("complex");
148+
});
149+
150+
it("defaults free-text to moderate", () => {
151+
expect(normalizeComplexity("detailed")).toBe("moderate");
152+
expect(normalizeComplexity("very complex with many deps")).toBe("moderate");
153+
});
154+
155+
it("defaults undefined/null to moderate", () => {
156+
expect(normalizeComplexity(undefined)).toBe("moderate");
157+
expect(normalizeComplexity(null)).toBe("moderate");
158+
});
159+
160+
it("defaults zero and negative numbers to moderate", () => {
161+
expect(normalizeComplexity(0)).toBe("moderate");
162+
expect(normalizeComplexity(-5)).toBe("moderate");
163+
});
164+
});
165+
166+
describe("normalizeBatchOutput", () => {
167+
it("normalizes IDs and numeric complexity, rewrites edges", () => {
168+
const result = normalizeBatchOutput({
169+
nodes: [
170+
{
171+
id: "file:src/good.ts",
172+
type: "file",
173+
name: "good.ts",
174+
filePath: "src/good.ts",
175+
summary: "A good file",
176+
tags: ["util"],
177+
complexity: "simple",
178+
},
179+
{
180+
id: "my-project:file:src/bad.ts",
181+
type: "file",
182+
name: "bad.ts",
183+
filePath: "src/bad.ts",
184+
summary: "Project-prefixed",
185+
tags: ["api"],
186+
complexity: "simple",
187+
},
188+
{
189+
id: "src/bare.ts",
190+
type: "file",
191+
name: "bare.ts",
192+
filePath: "src/bare.ts",
193+
summary: "Bare path",
194+
tags: [],
195+
complexity: 4,
196+
},
197+
],
198+
edges: [
199+
{
200+
source: "file:src/good.ts",
201+
target: "my-project:file:src/bad.ts",
202+
type: "imports",
203+
direction: "forward",
204+
weight: 0.7,
205+
},
206+
{
207+
source: "src/bare.ts",
208+
target: "file:src/good.ts",
209+
type: "imports",
210+
direction: "forward",
211+
weight: 0.7,
212+
},
213+
],
214+
});
215+
216+
expect(result.nodes).toHaveLength(3);
217+
expect(result.nodes[0].id).toBe("file:src/good.ts");
218+
expect(result.nodes[1].id).toBe("file:src/bad.ts");
219+
expect(result.nodes[2].id).toBe("file:src/bare.ts");
220+
// Only numeric complexity is fixed here; string aliases are upstream's job
221+
expect(result.nodes[2].complexity).toBe("moderate");
222+
223+
// Edges should be rewritten through the ID map
224+
expect(result.edges).toHaveLength(2);
225+
expect(result.edges[0].source).toBe("file:src/good.ts");
226+
expect(result.edges[0].target).toBe("file:src/bad.ts");
227+
expect(result.edges[1].source).toBe("file:src/bare.ts");
228+
229+
expect(result.stats.idsFixed).toBe(2);
230+
expect(result.stats.complexityFixed).toBe(1); // only the numeric one
231+
expect(result.stats.edgesRewritten).toBe(2);
232+
expect(result.stats.danglingEdgesDropped).toBe(0);
233+
});
234+
235+
it("drops dangling edges after normalization", () => {
236+
const result = normalizeBatchOutput({
237+
nodes: [
238+
{
239+
id: "file:src/a.ts",
240+
type: "file",
241+
name: "a.ts",
242+
summary: "File A",
243+
tags: [],
244+
complexity: "simple",
245+
},
246+
],
247+
edges: [
248+
{
249+
source: "file:src/a.ts",
250+
target: "file:src/nonexistent.ts",
251+
type: "imports",
252+
direction: "forward",
253+
weight: 0.7,
254+
},
255+
],
256+
});
257+
258+
expect(result.edges).toHaveLength(0);
259+
expect(result.stats.danglingEdgesDropped).toBe(1);
260+
});
261+
262+
it("deduplicates nodes keeping last occurrence", () => {
263+
const result = normalizeBatchOutput({
264+
nodes: [
265+
{
266+
id: "file:src/a.ts",
267+
type: "file",
268+
name: "a.ts",
269+
summary: "First version",
270+
tags: [],
271+
complexity: "simple",
272+
},
273+
{
274+
id: "file:src/a.ts",
275+
type: "file",
276+
name: "a.ts",
277+
summary: "Second version",
278+
tags: ["updated"],
279+
complexity: "complex",
280+
},
281+
],
282+
edges: [],
283+
});
284+
285+
expect(result.nodes).toHaveLength(1);
286+
expect(result.nodes[0].summary).toBe("Second version");
287+
});
288+
289+
it("deduplicates edges after ID rewriting", () => {
290+
const result = normalizeBatchOutput({
291+
nodes: [
292+
{
293+
id: "file:src/a.ts",
294+
type: "file",
295+
name: "a.ts",
296+
summary: "A",
297+
tags: [],
298+
complexity: "simple",
299+
},
300+
{
301+
id: "file:src/b.ts",
302+
type: "file",
303+
name: "b.ts",
304+
summary: "B",
305+
tags: [],
306+
complexity: "simple",
307+
},
308+
],
309+
edges: [
310+
{
311+
source: "file:src/a.ts",
312+
target: "file:src/b.ts",
313+
type: "imports",
314+
direction: "forward",
315+
weight: 0.7,
316+
},
317+
{
318+
source: "proj:file:src/a.ts",
319+
target: "file:src/b.ts",
320+
type: "imports",
321+
direction: "forward",
322+
weight: 0.7,
323+
},
324+
],
325+
});
326+
327+
// Both edges resolve to the same source after normalization — deduplicated
328+
expect(result.edges).toHaveLength(1);
329+
});
330+
331+
it("returns accurate stats", () => {
332+
const result = normalizeBatchOutput({
333+
nodes: [
334+
{
335+
id: "file:src/ok.ts",
336+
type: "file",
337+
name: "ok.ts",
338+
summary: "OK",
339+
tags: [],
340+
complexity: "simple",
341+
},
342+
{
343+
id: "proj:file:src/fix.ts",
344+
type: "file",
345+
name: "fix.ts",
346+
summary: "Needs fix",
347+
tags: [],
348+
complexity: 2,
349+
},
350+
],
351+
edges: [
352+
{
353+
source: "proj:file:src/fix.ts",
354+
target: "file:src/ok.ts",
355+
type: "imports",
356+
direction: "forward",
357+
weight: 0.7,
358+
},
359+
{
360+
source: "file:src/ok.ts",
361+
target: "file:src/gone.ts",
362+
type: "imports",
363+
direction: "forward",
364+
weight: 0.7,
365+
},
366+
],
367+
});
368+
369+
expect(result.stats.idsFixed).toBe(1);
370+
expect(result.stats.complexityFixed).toBe(1);
371+
expect(result.stats.edgesRewritten).toBe(1);
372+
expect(result.stats.danglingEdgesDropped).toBe(1);
373+
expect(result.edges).toHaveLength(1);
374+
});
375+
});

understand-anything-plugin/packages/core/src/__tests__/schema.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,12 @@ describe("schema validation", () => {
109109
expect(result.success).toBe(false);
110110
expect(result.errors).toBeDefined();
111111
});
112+
113+
it("accepts node with bare string ID (schema is lenient on format)", () => {
114+
const graph = structuredClone(validGraph);
115+
graph.nodes[0].id = "src/foo.ts";
116+
117+
const result = validateGraph(graph);
118+
expect(result.success).toBe(true);
119+
});
112120
});

0 commit comments

Comments
 (0)