Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, rmSync, existsSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { saveDiffOverlay, loadDiffOverlay } from "../persistence/index.js";
import type { DiffOverlay } from "../types.js";

const testRoot = join(tmpdir(), "ua-diff-overlay-persist-test");

const overlay: DiffOverlay = {
version: "1.0.0",
baseBranch: "main",
generatedAt: "2026-04-01T00:00:00.000Z",
changedFiles: ["src/auth.ts"],
changedNodeIds: ["file:src/auth.ts"],
affectedNodeIds: ["file:src/session.ts"],
};

describe("diff overlay persistence", () => {
beforeEach(() => {
if (existsSync(testRoot)) rmSync(testRoot, { recursive: true });
mkdirSync(testRoot, { recursive: true });
});

afterEach(() => {
if (existsSync(testRoot)) rmSync(testRoot, { recursive: true });
});

it("saves and loads a diff overlay", () => {
saveDiffOverlay(testRoot, overlay);
const loaded = loadDiffOverlay(testRoot);
expect(loaded).not.toBeNull();
expect(loaded!.changedNodeIds).toEqual(["file:src/auth.ts"]);
expect(loaded!.affectedNodeIds).toEqual(["file:src/session.ts"]);
expect(loaded!.baseBranch).toBe("main");
});

it("returns null when no diff overlay exists", () => {
expect(loadDiffOverlay(testRoot)).toBeNull();
});

it("saves to diff-overlay.json, alongside but distinct from knowledge-graph.json", () => {
saveDiffOverlay(testRoot, overlay);
expect(existsSync(join(testRoot, ".ua", "diff-overlay.json"))).toBe(true);
expect(existsSync(join(testRoot, ".ua", "knowledge-graph.json"))).toBe(false);
});

it("sanitizes absolute changedFiles paths to stay relative to projectRoot", () => {
const absoluteOverlay: DiffOverlay = {
...overlay,
changedFiles: [join(testRoot, "src", "auth.ts")],
};
saveDiffOverlay(testRoot, absoluteOverlay);
const loaded = loadDiffOverlay(testRoot);
expect(loaded!.changedFiles).toEqual(["src/auth.ts"]);
});

it("returns null (not throw) on a corrupt diff-overlay.json", () => {
mkdirSync(join(testRoot, ".ua"), { recursive: true });
writeFileSync(join(testRoot, ".ua", "diff-overlay.json"), "not json", "utf-8");
expect(loadDiffOverlay(testRoot)).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { computeDiffOverlay } from "../diff-overlay.js";
import type { KnowledgeGraph } from "../types.js";

function makeGraph(): KnowledgeGraph {
return {
version: "1.0.0",
project: { name: "p", languages: ["typescript"], frameworks: [], description: "d", analyzedAt: "2026-01-01T00:00:00.000Z", gitCommitHash: "x" },
nodes: [
{ id: "file:auth.ts", type: "file", name: "auth.ts", filePath: "auth.ts", summary: "Auth.", tags: [], complexity: "moderate" },
{ id: "function:auth.ts:login", type: "function", name: "login", filePath: "auth.ts", summary: "Login fn.", tags: [], complexity: "moderate" },
{ id: "file:session.ts", type: "file", name: "session.ts", filePath: "session.ts", summary: "Sessions, imports auth.", tags: [], complexity: "simple" },
{ id: "file:unrelated.ts", type: "file", name: "unrelated.ts", filePath: "unrelated.ts", summary: "Nothing to do with auth.", tags: [], complexity: "simple" },
],
edges: [
// session.ts imports auth.ts -> session.ts should be "affected" when auth.ts changes
{ source: "file:session.ts", target: "file:auth.ts", type: "imports", direction: "forward", weight: 0.7 },
],
layers: [],
tour: [],
};
}

describe("computeDiffOverlay", () => {
it("maps changed files to both file and function/class nodes defined in them", () => {
const overlay = computeDiffOverlay(makeGraph(), ["auth.ts"]);
expect(overlay.changedNodeIds.sort()).toEqual(["file:auth.ts", "function:auth.ts:login"]);
});

it("finds affected nodes via 1-hop edges in either direction, excluding changed nodes themselves", () => {
const overlay = computeDiffOverlay(makeGraph(), ["auth.ts"]);
expect(overlay.affectedNodeIds).toEqual(["file:session.ts"]);
expect(overlay.affectedNodeIds).not.toContain("file:auth.ts");
});

it("does not flag unrelated files as affected", () => {
const overlay = computeDiffOverlay(makeGraph(), ["auth.ts"]);
expect(overlay.affectedNodeIds).not.toContain("file:unrelated.ts");
});

it("returns empty changed/affected sets for a file not present in the graph", () => {
const overlay = computeDiffOverlay(makeGraph(), ["does-not-exist.ts"]);
expect(overlay.changedNodeIds).toEqual([]);
expect(overlay.affectedNodeIds).toEqual([]);
});

it("includes baseBranch when provided, omits it when not", () => {
const withBranch = computeDiffOverlay(makeGraph(), ["auth.ts"], "main");
expect(withBranch.baseBranch).toBe("main");

const withoutBranch = computeDiffOverlay(makeGraph(), ["auth.ts"]);
expect(withoutBranch.baseBranch).toBeUndefined();
});

it("preserves the changedFiles list verbatim and stamps a generatedAt timestamp", () => {
const overlay = computeDiffOverlay(makeGraph(), ["auth.ts", "does-not-exist.ts"]);
expect(overlay.changedFiles).toEqual(["auth.ts", "does-not-exist.ts"]);
expect(typeof overlay.generatedAt).toBe("string");
expect(new Date(overlay.generatedAt).toString()).not.toBe("Invalid Date");
});
});
44 changes: 44 additions & 0 deletions understand-anything-plugin/packages/core/src/diff-overlay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { DiffOverlay, KnowledgeGraph } from "./types.js";

/**
* Maps a set of changed file paths onto an already-analyzed knowledge graph:
* finds the nodes defined in those files (file nodes plus any function/class
* nodes whose `filePath` matches, since graph-builder stamps the same
* filePath on both), then walks one hop of edges in both directions to find
* the blast radius — what imports/calls the changed nodes, and what they in
* turn import/call. Pure and deterministic; no LLM involved.
*
* `changedFiles` should be paths relative to the project root, matching how
* node.filePath is stored (see persistence's sanitiseFilePath).
*/
export function computeDiffOverlay(
graph: KnowledgeGraph,
changedFiles: string[],
baseBranch?: string,
): DiffOverlay {
const changedFileSet = new Set(changedFiles);

const changedNodeIds = graph.nodes
.filter((n) => typeof n.filePath === "string" && changedFileSet.has(n.filePath))
.map((n) => n.id);
const changedNodeIdSet = new Set(changedNodeIds);

const affectedNodeIdSet = new Set<string>();
for (const edge of graph.edges) {
if (changedNodeIdSet.has(edge.source) && !changedNodeIdSet.has(edge.target)) {
affectedNodeIdSet.add(edge.target);
}
if (changedNodeIdSet.has(edge.target) && !changedNodeIdSet.has(edge.source)) {
affectedNodeIdSet.add(edge.source);
}
}

return {
version: "1.0.0",
...(baseBranch ? { baseBranch } : {}),
generatedAt: new Date().toISOString(),
changedFiles,
changedNodeIds,
affectedNodeIds: [...affectedNodeIdSet],
};
}
1 change: 1 addition & 0 deletions understand-anything-plugin/packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,4 @@ export {
type IgnoreFilter,
} from "./ignore-filter.js";
export { generateStarterIgnoreFile } from "./ignore-generator.js";
export { computeDiffOverlay } from "./diff-overlay.js";
62 changes: 41 additions & 21 deletions understand-anything-plugin/packages/core/src/persistence/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join, isAbsolute, relative, basename } from "node:path";
import type { KnowledgeGraph, AnalysisMeta, ProjectConfig } from "../types.js";
import type { KnowledgeGraph, AnalysisMeta, ProjectConfig, DiffOverlay } from "../types.js";
import type { FingerprintStore } from "../fingerprint.js";
import { validateGraph } from "../schema.js";

Expand Down Expand Up @@ -50,32 +50,31 @@ function ensureDir(projectRoot: string): string {
* This means the developer's home directory, username, and company
* directory layout are never written to knowledge-graph.json.
*/
function sanitiseFilePath(fp: string, projectRoot: string): string {
const normalRoot = projectRoot.endsWith("/") ? projectRoot : projectRoot + "/";

if (!isAbsolute(fp)) {
// Already relative — nothing to do.
return fp;
}

if (fp.startsWith(normalRoot) || fp.startsWith(projectRoot)) {
// Inside the project root — make it relative.
return relative(projectRoot, fp);
}

// Absolute but outside the project root — use only the filename
// so we leak as little as possible.
return basename(fp);
}

function sanitiseFilePaths(
graph: KnowledgeGraph,
projectRoot: string,
): KnowledgeGraph {
const normalRoot = projectRoot.endsWith("/")
? projectRoot
: projectRoot + "/";

const sanitisedNodes = graph.nodes.map((node) => {
if (typeof node.filePath !== "string") return node;

const fp = node.filePath;

if (!isAbsolute(fp)) {
// Already relative — nothing to do.
return node;
}

if (fp.startsWith(normalRoot) || fp.startsWith(projectRoot)) {
// Inside the project root — make it relative.
return { ...node, filePath: relative(projectRoot, fp) };
}

// Absolute but outside the project root — use only the filename
// so we leak as little as possible.
return { ...node, filePath: basename(fp) };
return { ...node, filePath: sanitiseFilePath(node.filePath, projectRoot) };
});

return { ...graph, nodes: sanitisedNodes };
Expand Down Expand Up @@ -195,3 +194,24 @@ export function loadDomainGraph(

return data as KnowledgeGraph;
}

const DIFF_OVERLAY_FILE = "diff-overlay.json";

export function saveDiffOverlay(projectRoot: string, overlay: DiffOverlay): void {
const dir = ensureDir(projectRoot);
const sanitised: DiffOverlay = {
...overlay,
changedFiles: overlay.changedFiles.map((fp) => sanitiseFilePath(fp, projectRoot)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep outside diff paths from escaping the project

When saveDiffOverlay receives an absolute changedFiles entry that is outside projectRoot but merely shares its string prefix (for example projectRoot=/tmp/app and /tmp/app-old/src/a.ts), the sanitizer treats it as in-project and writes a ../app-old/src/a.ts path into .ua/diff-overlay.json instead of falling back to the basename. That violates the outside-path privacy contract and can expose sibling directory layout; the relativization should only run after a real path-boundary check.

Useful? React with 👍 / 👎.

};
writeFileSync(join(dir, DIFF_OVERLAY_FILE), JSON.stringify(sanitised, null, 2), "utf-8");
}

export function loadDiffOverlay(projectRoot: string): DiffOverlay | null {
const filePath = join(resolveUaDir(projectRoot), DIFF_OVERLAY_FILE);
if (!existsSync(filePath)) return null;
try {
return JSON.parse(readFileSync(filePath, "utf-8")) as DiffOverlay;
} catch {
return null;
}
}
14 changes: 14 additions & 0 deletions understand-anything-plugin/packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,20 @@ export interface KnowledgeGraph {
tour: TourStep[];
}

// DiffOverlay — maps a set of changed files onto the knowledge graph so the
// dashboard can highlight changed vs. affected (blast-radius) nodes. Matches
// the shape the understand-diff skill already writes/reads at
// <ua-dir>/diff-overlay.json (see skills/understand-diff/SKILL.md step 8) —
// this type/persistence pair formalizes that existing on-disk contract.
export interface DiffOverlay {
version: string;
baseBranch?: string;
generatedAt: string;
changedFiles: string[];
changedNodeIds: string[];
affectedNodeIds: string[];
}

// Theme configuration (for dashboard customization)
export interface ThemeConfig {
presetId: string;
Expand Down