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
This commit is contained in:
Sreeram
2026-03-31 00:36:53 +05:30
Unverified
parent 0694163f1a
commit 60c2ead398
6 changed files with 634 additions and 4 deletions
@@ -0,0 +1,375 @@
import { describe, it, expect } from "vitest";
import {
normalizeNodeId,
normalizeComplexity,
normalizeBatchOutput,
} from "../analyzer/normalize-graph.js";
describe("normalizeNodeId", () => {
it("passes through a correct file ID unchanged", () => {
expect(
normalizeNodeId("file:src/index.ts", { type: "file" }),
).toBe("file:src/index.ts");
});
it("passes through a correct func ID unchanged", () => {
expect(
normalizeNodeId("func:src/utils.ts:formatDate", { type: "function" }),
).toBe("func:src/utils.ts:formatDate");
});
it("passes through a correct class ID unchanged", () => {
expect(
normalizeNodeId("class:src/models/User.ts:User", { type: "class" }),
).toBe("class:src/models/User.ts:User");
});
it("fixes double-prefixed IDs", () => {
expect(
normalizeNodeId("file:file:src/foo.ts", { type: "file" }),
).toBe("file:src/foo.ts");
});
it("strips project-name prefix when valid prefix follows", () => {
expect(
normalizeNodeId("my-project:file:src/foo.ts", { type: "file" }),
).toBe("file:src/foo.ts");
});
it("strips project-name prefix and adds correct prefix for bare path", () => {
expect(
normalizeNodeId("my-project:src/foo.ts", { type: "file" }),
).toBe("file:src/foo.ts");
});
it("adds file: prefix to bare paths", () => {
expect(
normalizeNodeId("frontend/src/utils/constants.ts", { type: "file" }),
).toBe("file:frontend/src/utils/constants.ts");
});
it("reconstructs func ID from filePath and name for bare paths", () => {
expect(
normalizeNodeId("formatDate", {
type: "function",
filePath: "src/utils.ts",
name: "formatDate",
}),
).toBe("func:src/utils.ts:formatDate");
});
it("reconstructs class ID from filePath and name for bare paths", () => {
expect(
normalizeNodeId("User", {
type: "class",
filePath: "src/models/User.ts",
name: "User",
}),
).toBe("class:src/models/User.ts:User");
});
it("trims whitespace", () => {
expect(
normalizeNodeId(" file:src/foo.ts ", { type: "file" }),
).toBe("file:src/foo.ts");
});
it("handles module: and concept: prefixes", () => {
expect(
normalizeNodeId("module:auth", { type: "module" }),
).toBe("module:auth");
expect(
normalizeNodeId("concept:caching", { type: "concept" }),
).toBe("concept:caching");
});
it("handles double project-name prefix", () => {
expect(
normalizeNodeId("my-project:service:docker-compose.yml", {
type: "file",
}),
).toBe("file:docker-compose.yml");
});
it("returns empty string for empty input", () => {
expect(normalizeNodeId("", { type: "file" })).toBe("");
});
it("falls back to untouched ID for unknown node type", () => {
expect(normalizeNodeId("some-id", { type: "widget" as any })).toBe("some-id");
});
});
describe("normalizeComplexity", () => {
it("passes through valid values unchanged", () => {
expect(normalizeComplexity("simple")).toBe("simple");
expect(normalizeComplexity("moderate")).toBe("moderate");
expect(normalizeComplexity("complex")).toBe("complex");
});
it("maps 'low' to 'simple'", () => {
expect(normalizeComplexity("low")).toBe("simple");
});
it("maps 'high' to 'complex'", () => {
expect(normalizeComplexity("high")).toBe("complex");
});
it("maps 'medium' to 'moderate'", () => {
expect(normalizeComplexity("medium")).toBe("moderate");
});
it("maps other aliases from upstream COMPLEXITY_ALIASES", () => {
expect(normalizeComplexity("easy")).toBe("simple");
expect(normalizeComplexity("hard")).toBe("complex");
expect(normalizeComplexity("difficult")).toBe("complex");
expect(normalizeComplexity("intermediate")).toBe("moderate");
});
it("is case-insensitive", () => {
expect(normalizeComplexity("LOW")).toBe("simple");
expect(normalizeComplexity("High")).toBe("complex");
expect(normalizeComplexity("MODERATE")).toBe("moderate");
});
it("maps numeric 1-3 to simple", () => {
expect(normalizeComplexity(1)).toBe("simple");
expect(normalizeComplexity(3)).toBe("simple");
});
it("maps numeric 4-6 to moderate", () => {
expect(normalizeComplexity(4)).toBe("moderate");
expect(normalizeComplexity(6)).toBe("moderate");
});
it("maps numeric 7-10 to complex", () => {
expect(normalizeComplexity(7)).toBe("complex");
expect(normalizeComplexity(10)).toBe("complex");
});
it("defaults free-text to moderate", () => {
expect(normalizeComplexity("detailed")).toBe("moderate");
expect(normalizeComplexity("very complex with many deps")).toBe("moderate");
});
it("defaults undefined/null to moderate", () => {
expect(normalizeComplexity(undefined)).toBe("moderate");
expect(normalizeComplexity(null)).toBe("moderate");
});
it("defaults zero and negative numbers to moderate", () => {
expect(normalizeComplexity(0)).toBe("moderate");
expect(normalizeComplexity(-5)).toBe("moderate");
});
});
describe("normalizeBatchOutput", () => {
it("normalizes IDs and numeric complexity, rewrites edges", () => {
const result = normalizeBatchOutput({
nodes: [
{
id: "file:src/good.ts",
type: "file",
name: "good.ts",
filePath: "src/good.ts",
summary: "A good file",
tags: ["util"],
complexity: "simple",
},
{
id: "my-project:file:src/bad.ts",
type: "file",
name: "bad.ts",
filePath: "src/bad.ts",
summary: "Project-prefixed",
tags: ["api"],
complexity: "simple",
},
{
id: "src/bare.ts",
type: "file",
name: "bare.ts",
filePath: "src/bare.ts",
summary: "Bare path",
tags: [],
complexity: 4,
},
],
edges: [
{
source: "file:src/good.ts",
target: "my-project:file:src/bad.ts",
type: "imports",
direction: "forward",
weight: 0.7,
},
{
source: "src/bare.ts",
target: "file:src/good.ts",
type: "imports",
direction: "forward",
weight: 0.7,
},
],
});
expect(result.nodes).toHaveLength(3);
expect(result.nodes[0].id).toBe("file:src/good.ts");
expect(result.nodes[1].id).toBe("file:src/bad.ts");
expect(result.nodes[2].id).toBe("file:src/bare.ts");
// Only numeric complexity is fixed here; string aliases are upstream's job
expect(result.nodes[2].complexity).toBe("moderate");
// Edges should be rewritten through the ID map
expect(result.edges).toHaveLength(2);
expect(result.edges[0].source).toBe("file:src/good.ts");
expect(result.edges[0].target).toBe("file:src/bad.ts");
expect(result.edges[1].source).toBe("file:src/bare.ts");
expect(result.stats.idsFixed).toBe(2);
expect(result.stats.complexityFixed).toBe(1); // only the numeric one
expect(result.stats.edgesRewritten).toBe(2);
expect(result.stats.danglingEdgesDropped).toBe(0);
});
it("drops dangling edges after normalization", () => {
const result = normalizeBatchOutput({
nodes: [
{
id: "file:src/a.ts",
type: "file",
name: "a.ts",
summary: "File A",
tags: [],
complexity: "simple",
},
],
edges: [
{
source: "file:src/a.ts",
target: "file:src/nonexistent.ts",
type: "imports",
direction: "forward",
weight: 0.7,
},
],
});
expect(result.edges).toHaveLength(0);
expect(result.stats.danglingEdgesDropped).toBe(1);
});
it("deduplicates nodes keeping last occurrence", () => {
const result = normalizeBatchOutput({
nodes: [
{
id: "file:src/a.ts",
type: "file",
name: "a.ts",
summary: "First version",
tags: [],
complexity: "simple",
},
{
id: "file:src/a.ts",
type: "file",
name: "a.ts",
summary: "Second version",
tags: ["updated"],
complexity: "complex",
},
],
edges: [],
});
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0].summary).toBe("Second version");
});
it("deduplicates edges after ID rewriting", () => {
const result = normalizeBatchOutput({
nodes: [
{
id: "file:src/a.ts",
type: "file",
name: "a.ts",
summary: "A",
tags: [],
complexity: "simple",
},
{
id: "file:src/b.ts",
type: "file",
name: "b.ts",
summary: "B",
tags: [],
complexity: "simple",
},
],
edges: [
{
source: "file:src/a.ts",
target: "file:src/b.ts",
type: "imports",
direction: "forward",
weight: 0.7,
},
{
source: "proj:file:src/a.ts",
target: "file:src/b.ts",
type: "imports",
direction: "forward",
weight: 0.7,
},
],
});
// Both edges resolve to the same source after normalization — deduplicated
expect(result.edges).toHaveLength(1);
});
it("returns accurate stats", () => {
const result = normalizeBatchOutput({
nodes: [
{
id: "file:src/ok.ts",
type: "file",
name: "ok.ts",
summary: "OK",
tags: [],
complexity: "simple",
},
{
id: "proj:file:src/fix.ts",
type: "file",
name: "fix.ts",
summary: "Needs fix",
tags: [],
complexity: 2,
},
],
edges: [
{
source: "proj:file:src/fix.ts",
target: "file:src/ok.ts",
type: "imports",
direction: "forward",
weight: 0.7,
},
{
source: "file:src/ok.ts",
target: "file:src/gone.ts",
type: "imports",
direction: "forward",
weight: 0.7,
},
],
});
expect(result.stats.idsFixed).toBe(1);
expect(result.stats.complexityFixed).toBe(1);
expect(result.stats.edgesRewritten).toBe(1);
expect(result.stats.danglingEdgesDropped).toBe(1);
expect(result.edges).toHaveLength(1);
});
});
@@ -109,4 +109,12 @@ describe("schema validation", () => {
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
});
it("accepts node with bare string ID (schema is lenient on format)", () => {
const graph = structuredClone(validGraph);
graph.nodes[0].id = "src/foo.ts";
const result = validateGraph(graph);
expect(result.success).toBe(true);
});
});
@@ -0,0 +1,221 @@
import type { GraphNode, GraphEdge } from "../types.js";
const VALID_PREFIXES = new Set(["file", "func", "class", "module", "concept"]);
const TYPE_TO_PREFIX: Record<string, string> = {
file: "file",
function: "func",
class: "class",
module: "module",
concept: "concept",
};
/**
* Strips all non-valid prefixes from an ID, returning the bare path
* and the first valid prefix found (if any).
*/
function stripToValidPrefix(id: string): { prefix: string | null; path: string } {
let remaining = id;
// Peel off colon-separated segments until we find a valid prefix or run out
while (true) {
const colonIdx = remaining.indexOf(":");
if (colonIdx <= 0) break;
const segment = remaining.slice(0, colonIdx);
if (VALID_PREFIXES.has(segment)) {
// Check for double valid prefix (e.g., "file:file:src/foo.ts")
const rest = remaining.slice(colonIdx + 1);
const innerColonIdx = rest.indexOf(":");
if (innerColonIdx > 0 && VALID_PREFIXES.has(rest.slice(0, innerColonIdx))) {
// Double-prefixed — skip the outer, recurse on inner
remaining = rest;
continue;
}
return { prefix: segment, path: rest };
}
// Not a valid prefix — strip it and continue
remaining = remaining.slice(colonIdx + 1);
}
return { prefix: null, path: remaining };
}
/**
* Normalizes a node ID to the canonical `type:path` format.
* Handles: double-prefixed IDs, project-name-prefixed IDs, bare paths.
* Idempotent — correct IDs pass through unchanged.
*/
export function normalizeNodeId(
id: string,
node: { type: string; filePath?: string; name?: string },
): string {
const trimmed = id.trim();
if (!trimmed) return trimmed;
const expectedPrefix = TYPE_TO_PREFIX[node.type];
const { prefix, path } = stripToValidPrefix(trimmed);
if (prefix) {
return `${prefix}:${path}`;
}
// No valid prefix found — bare path
if (expectedPrefix) {
// For func/class, reconstruct from filePath + name if available
if (
(node.type === "function" || node.type === "class") &&
node.filePath &&
node.name
) {
return `${expectedPrefix}:${node.filePath}:${node.name}`;
}
return `${expectedPrefix}:${path}`;
}
return trimmed;
}
const VALID_COMPLEXITIES = new Set(["simple", "moderate", "complex"]);
// String aliases for complexity — mirrors upstream's COMPLEXITY_ALIASES.
// After rebasing onto upstream main, this can be replaced with an import.
const COMPLEXITY_STRING_MAP: Record<string, string> = {
low: "simple",
easy: "simple",
medium: "moderate",
intermediate: "moderate",
high: "complex",
hard: "complex",
difficult: "complex",
};
/**
* Normalizes a complexity value to one of "simple" | "moderate" | "complex".
* Handles both string aliases and numeric scales — defaults to "moderate".
*/
export function normalizeComplexity(
value: unknown,
): "simple" | "moderate" | "complex" {
if (typeof value === "string") {
const lower = value.toLowerCase().trim();
if (VALID_COMPLEXITIES.has(lower)) return lower as "simple" | "moderate" | "complex";
const aliased = COMPLEXITY_STRING_MAP[lower];
if (aliased) return aliased as "simple" | "moderate" | "complex";
return "moderate";
}
if (typeof value === "number" && Number.isFinite(value) && value >= 1) {
if (value <= 3) return "simple";
if (value <= 6) return "moderate";
return "complex";
}
return "moderate";
}
export interface NormalizationStats {
idsFixed: number;
complexityFixed: number;
edgesRewritten: number;
danglingEdgesDropped: number;
}
export interface NormalizeBatchResult {
nodes: GraphNode[];
edges: GraphEdge[];
idMap: Map<string, string>;
stats: NormalizationStats;
}
/**
* Normalizes a merged batch output: fixes node IDs and numeric complexity,
* rewrites edge references, deduplicates nodes and edges, and drops dangling edges.
*
* This runs BEFORE upstream's sanitizeGraph/autoFixGraph/normalizeGraph pipeline,
* handling concerns that pipeline does not cover: malformed IDs, numeric complexity,
* edge reference rewriting after ID correction, and edge deduplication.
*/
export function normalizeBatchOutput(data: {
nodes: Record<string, unknown>[];
edges: Record<string, unknown>[];
}): NormalizeBatchResult {
const stats: NormalizationStats = {
idsFixed: 0,
complexityFixed: 0,
edgesRewritten: 0,
danglingEdgesDropped: 0,
};
const idMap = new Map<string, string>();
// Pass 1: Normalize node IDs and numeric complexity
const nodes = data.nodes.map((raw) => {
const oldId = String(raw.id ?? "");
const nodeType = String(raw.type ?? "file");
const newId = normalizeNodeId(oldId, {
type: nodeType,
filePath: typeof raw.filePath === "string" ? raw.filePath : undefined,
name: typeof raw.name === "string" ? raw.name : undefined,
});
if (newId !== oldId) {
stats.idsFixed++;
}
idMap.set(oldId, newId);
const result: Record<string, unknown> = { ...raw, id: newId };
// Only fix numeric complexity here — string aliases are handled by upstream's
// COMPLEXITY_ALIASES in autoFixGraph
if (typeof raw.complexity === "number") {
result.complexity = normalizeComplexity(raw.complexity);
stats.complexityFixed++;
}
return result;
});
// Deduplicate nodes (keep last occurrence)
const seenIds = new Map<string, number>();
for (let i = 0; i < nodes.length; i++) {
seenIds.set(String(nodes[i].id), i);
}
const deduped = nodes.filter((_, i) => seenIds.get(String(nodes[i].id)) === i);
const validNodeIds = new Set(deduped.map((n) => String(n.id)));
// Pass 2: Rewrite edge references and deduplicate
const edges: Record<string, unknown>[] = [];
const seenEdges = new Set<string>();
for (const raw of data.edges) {
const oldSource = String(raw.source ?? "");
const oldTarget = String(raw.target ?? "");
const newSource = idMap.get(oldSource) ?? oldSource;
const newTarget = idMap.get(oldTarget) ?? oldTarget;
if (newSource !== oldSource || newTarget !== oldTarget) {
stats.edgesRewritten++;
}
if (!validNodeIds.has(newSource) || !validNodeIds.has(newTarget)) {
stats.danglingEdgesDropped++;
continue;
}
// Deduplicate by composite key (source + target + type)
const edgeType = String(raw.type ?? "");
const edgeKey = `${newSource}|${newTarget}|${edgeType}`;
if (seenEdges.has(edgeKey)) continue;
seenEdges.add(edgeKey);
edges.push({ ...raw, source: newSource, target: newTarget });
}
return {
nodes: deduped as unknown as GraphNode[],
edges: edges as unknown as GraphEdge[],
idMap,
stats,
};
}
@@ -10,6 +10,13 @@ export {
parseProjectSummaryResponse,
} from "./analyzer/llm-analyzer.js";
export type { LLMFileAnalysis, LLMProjectSummary } from "./analyzer/llm-analyzer.js";
export {
normalizeNodeId,
normalizeComplexity,
normalizeBatchOutput,
type NormalizationStats,
type NormalizeBatchResult,
} from "./analyzer/normalize-graph.js";
export { SearchEngine, type SearchResult, type SearchOptions } from "./search.js";
export {
getChangedFiles,
@@ -150,11 +150,28 @@ After batches complete, merge with the existing graph:
## Phase 3 — ASSEMBLE
Merge all file-analyzer results into a single set of nodes and edges. Then perform basic integrity cleanup:
Merge all file-analyzer results into a single set of nodes and edges. Then perform normalization and integrity cleanup **in this order**:
- Remove any edge whose `source` or `target` references a node ID that does not exist in the merged node set
- Remove duplicate node IDs (keep the last occurrence)
- Log any removed edges or nodes for the final summary
1. **Normalize node IDs:** For every node, verify the `id` field follows the convention `<type-prefix>:<path>` where type-prefix is one of `file`, `func`, `class`, `module`, `concept`. Apply these fixes:
- If the ID has a double prefix (e.g., `file:file:src/foo.ts`), strip the duplicate prefix.
- If the ID has a project-name prefix (e.g., `my-project:file:src/foo.ts`), strip the project-name portion.
- If the ID is a bare file path with no prefix, add the appropriate prefix based on the node's `type` field: `file` → `file:<path>`, `function` → `func:<filePath>:<name>`, `class` → `class:<filePath>:<name>`.
- Build a mapping of original IDs → corrected IDs.
2. **Normalize complexity values:** For every node, verify `complexity` is one of `"simple"`, `"moderate"`, `"complex"`. Apply these mappings for invalid values:
- `"low"`, `"easy"`, `"trivial"`, `"basic"` → `"simple"`
- `"medium"`, `"mid"`, `"average"` → `"moderate"`
- `"high"`, `"hard"`, `"difficult"`, `"advanced"` → `"complex"`
- Numeric 1-3 → `"simple"`, 4-6 → `"moderate"`, 7-10 → `"complex"`
- Any other value → `"moderate"`
3. **Rewrite edge references:** Using the ID mapping from step 1, update every edge's `source` and `target` fields. This prevents cascading edge drops when only the ID format was wrong.
4. **Remove duplicate node IDs:** If duplicate node IDs exist after normalization, keep the last occurrence.
5. **Remove dangling edges:** Remove any edge whose `source` or `target` references a node ID that does not exist in the merged node set.
6. **Log changes:** Record counts of IDs corrected, complexity values fixed, edges rewritten, duplicates removed, and dangling edges dropped. Include these counts in the Phase warnings list passed to the reviewer.
---
@@ -226,6 +226,8 @@ You MUST use these exact prefixes for node IDs:
**Scope restriction:** Only produce `file:`, `func:`, and `class:` nodes. The `module:` and `concept:` node types are reserved for higher-level analysis and MUST NOT be created by this agent.
> **WARNING:** Node IDs MUST use the exact prefix formats shown above. Do NOT prefix IDs with the project name (e.g., `my-project:file:src/foo.ts` is WRONG). Do NOT use bare file paths without a type prefix (e.g., `src/foo.ts` is WRONG). Invalid IDs will be auto-corrected during assembly, which may cause unexpected edge rewiring.
## Output Format
Produce a single, valid JSON block. Validate it mentally before writing -- malformed JSON breaks the entire pipeline.