mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-10-07 12:58:28 +08:00
fix: harden graph freshness reporting
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "understand-anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "2.8.2",
|
||||
"version": "2.8.3",
|
||||
"author": {
|
||||
"name": "Egonex"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "understand-anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "2.8.2",
|
||||
"version": "2.8.3",
|
||||
"author": {
|
||||
"name": "Egonex"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "understand-anything",
|
||||
"displayName": "Understand Anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "2.8.2",
|
||||
"version": "2.8.3",
|
||||
"author": {
|
||||
"name": "Egonex"
|
||||
},
|
||||
|
||||
@@ -11,11 +11,29 @@ const graphConsumerSkills = [
|
||||
"understand-explain",
|
||||
"understand-diff",
|
||||
"understand-onboard",
|
||||
"understand-domain",
|
||||
];
|
||||
|
||||
const requiredFreshnessInstructions = [
|
||||
"gitCommitHash",
|
||||
"git rev-parse HEAD",
|
||||
'git rev-parse --verify --end-of-options "${GRAPH_COMMIT_RAW}^{commit}"',
|
||||
"git diff --name-only \"$GRAPH_COMMIT\" HEAD -- .",
|
||||
"git diff --cached --name-only -- .",
|
||||
"git diff --name-only -- .",
|
||||
"git ls-files --others --exclude-standard -- .",
|
||||
"working-tree",
|
||||
"hash mismatch",
|
||||
"project diff is empty",
|
||||
"Ignore `.understand-anything/`",
|
||||
"warn",
|
||||
"continue",
|
||||
"Run `/understand`",
|
||||
];
|
||||
|
||||
describe("graph-consuming skills", () => {
|
||||
it.each(graphConsumerSkills)(
|
||||
"%s warns when the knowledge graph commit differs from HEAD",
|
||||
"%s checks committed and working-tree freshness before using a graph",
|
||||
(skillName) => {
|
||||
const skillPath = resolve(
|
||||
repoRoot,
|
||||
@@ -26,10 +44,28 @@ describe("graph-consuming skills", () => {
|
||||
);
|
||||
const content = readFileSync(skillPath, "utf-8");
|
||||
|
||||
expect(content).toContain("gitCommitHash");
|
||||
expect(content).toContain("git rev-parse HEAD");
|
||||
expect(content).toContain("stale");
|
||||
expect(content).toContain("Run `/understand`");
|
||||
for (const instruction of requiredFreshnessInstructions) {
|
||||
expect(content).toContain(instruction);
|
||||
}
|
||||
expect(content).not.toContain(
|
||||
'git diff --name-only "$GRAPH_COMMIT_RAW" HEAD -- .',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("understand-domain applies the preflight only to its existing-graph path", () => {
|
||||
const content = readFileSync(
|
||||
resolve(
|
||||
repoRoot,
|
||||
"understand-anything-plugin",
|
||||
"skills",
|
||||
"understand-domain",
|
||||
"SKILL.md",
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
expect(content).toContain("When `--full` is used, skip this preflight");
|
||||
expect(content).toContain("Phase 3: Derive from Existing Graph");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "understand-anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "2.8.2",
|
||||
"version": "2.8.3",
|
||||
"author": {
|
||||
"name": "Egonex"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@understand-anything/skill",
|
||||
"version": "2.8.2",
|
||||
"version": "2.8.3",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { execFileMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("child_process")>(
|
||||
"child_process",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
execFile: execFileMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { getGraphFreshness } from "../staleness.js";
|
||||
|
||||
describe("getGraphFreshness timeout handling", () => {
|
||||
it("returns an explicit unknown result when Git times out", async () => {
|
||||
execFileMock.mockImplementation(
|
||||
(
|
||||
_file: string,
|
||||
_args: string[],
|
||||
_options: Record<string, unknown>,
|
||||
callback: (
|
||||
error: Error & {
|
||||
code: number | null;
|
||||
killed: boolean;
|
||||
signal: string;
|
||||
},
|
||||
stdout: Buffer,
|
||||
stderr: Buffer,
|
||||
) => void,
|
||||
) => {
|
||||
callback(
|
||||
Object.assign(new Error("timed out"), {
|
||||
code: null,
|
||||
killed: true,
|
||||
signal: "SIGTERM",
|
||||
}),
|
||||
Buffer.alloc(0),
|
||||
Buffer.alloc(0),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
getGraphFreshness("/project", { graphCommitHash: "abc123" }),
|
||||
).resolves.toEqual({
|
||||
status: "unknown",
|
||||
reason: "git-command-timeout",
|
||||
graphCommitHash: "abc123",
|
||||
});
|
||||
});
|
||||
});
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getGraphFreshness,
|
||||
getGraphFreshnessBatch,
|
||||
} from "../staleness.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function createTemporaryDirectory(prefix = "ua-freshness-"): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), prefix));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function writeProjectFile(
|
||||
projectDir: string,
|
||||
relativePath: string,
|
||||
contents: string,
|
||||
): void {
|
||||
const filePath = join(projectDir, ...relativePath.split("/"));
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, contents, "utf8");
|
||||
}
|
||||
|
||||
function commitAll(repoDir: string, message: string): string {
|
||||
git(repoDir, "add", "--all");
|
||||
git(repoDir, "commit", "-m", message);
|
||||
return git(repoDir, "rev-parse", "HEAD");
|
||||
}
|
||||
|
||||
function createRepository(
|
||||
initialFiles: Record<string, string> = {
|
||||
"src/index.ts": "export const value = 1;\n",
|
||||
},
|
||||
): { repoDir: string; baseline: string } {
|
||||
const repoDir = createTemporaryDirectory();
|
||||
git(repoDir, "init");
|
||||
git(repoDir, "config", "user.email", "freshness-tests@example.com");
|
||||
git(repoDir, "config", "user.name", "Freshness Tests");
|
||||
|
||||
for (const [relativePath, contents] of Object.entries(initialFiles)) {
|
||||
writeProjectFile(repoDir, relativePath, contents);
|
||||
}
|
||||
|
||||
return {
|
||||
repoDir,
|
||||
baseline: commitAll(repoDir, "baseline"),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe(
|
||||
"getGraphFreshness with a real Git repository",
|
||||
{ timeout: 15_000 },
|
||||
() => {
|
||||
it("returns fresh for a clean project at the analyzed commit", async () => {
|
||||
const { repoDir, baseline } = createRepository();
|
||||
|
||||
await expect(
|
||||
getGraphFreshness(repoDir, {
|
||||
graphCommitHash: baseline,
|
||||
lastAnalyzedAt: "2026-07-10T00:00:00.000Z",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: "fresh",
|
||||
graphCommitHash: baseline,
|
||||
headCommitHash: baseline,
|
||||
changedFileCount: 0,
|
||||
changedFiles: [],
|
||||
commitsBehind: 0,
|
||||
commitsAhead: 0,
|
||||
lastAnalyzedAt: "2026-07-10T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "unstaged",
|
||||
path: "src/index.ts",
|
||||
prepare(repoDir: string) {
|
||||
writeProjectFile(repoDir, "src/index.ts", "export const value = 2;\n");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "staged",
|
||||
path: "src/staged.ts",
|
||||
prepare(repoDir: string) {
|
||||
writeProjectFile(repoDir, "src/staged.ts", "export const staged = true;\n");
|
||||
git(repoDir, "add", "src/staged.ts");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "untracked",
|
||||
path: "src/untracked.ts",
|
||||
prepare(repoDir: string) {
|
||||
writeProjectFile(
|
||||
repoDir,
|
||||
"src/untracked.ts",
|
||||
"export const untracked = true;\n",
|
||||
);
|
||||
},
|
||||
},
|
||||
])("returns dirty for $name project changes", async ({ path, prepare }) => {
|
||||
const { repoDir, baseline } = createRepository();
|
||||
prepare(repoDir);
|
||||
|
||||
await expect(
|
||||
getGraphFreshness(repoDir, { graphCommitHash: baseline }),
|
||||
).resolves.toMatchObject({
|
||||
status: "dirty",
|
||||
graphCommitHash: baseline,
|
||||
headCommitHash: baseline,
|
||||
changedFileCount: 1,
|
||||
changedFiles: [path],
|
||||
commitsBehind: 0,
|
||||
commitsAhead: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates paths changed in both the index and working tree", async () => {
|
||||
const { repoDir, baseline } = createRepository();
|
||||
writeProjectFile(repoDir, "src/index.ts", "export const value = 2;\n");
|
||||
git(repoDir, "add", "src/index.ts");
|
||||
writeProjectFile(repoDir, "src/index.ts", "export const value = 3;\n");
|
||||
|
||||
await expect(
|
||||
getGraphFreshness(repoDir, { graphCommitHash: baseline }),
|
||||
).resolves.toMatchObject({
|
||||
status: "dirty",
|
||||
changedFileCount: 1,
|
||||
changedFiles: ["src/index.ts"],
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores untracked Understand Anything output files", async () => {
|
||||
const { repoDir, baseline } = createRepository();
|
||||
writeProjectFile(
|
||||
repoDir,
|
||||
".understand-anything/knowledge-graph.json",
|
||||
"{}\n",
|
||||
);
|
||||
writeProjectFile(
|
||||
repoDir,
|
||||
".understand-anything/intermediate/batch-0.json",
|
||||
"{}\n",
|
||||
);
|
||||
|
||||
await expect(
|
||||
getGraphFreshness(repoDir, { graphCommitHash: baseline }),
|
||||
).resolves.toMatchObject({
|
||||
status: "fresh",
|
||||
changedFileCount: 0,
|
||||
changedFiles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores commits that only change Understand Anything output files", async () => {
|
||||
const { repoDir, baseline } = createRepository();
|
||||
writeProjectFile(
|
||||
repoDir,
|
||||
".understand-anything/knowledge-graph.json",
|
||||
"{}\n",
|
||||
);
|
||||
const headCommit = commitAll(repoDir, "persist generated graph");
|
||||
|
||||
await expect(
|
||||
getGraphFreshness(repoDir, { graphCommitHash: baseline }),
|
||||
).resolves.toEqual({
|
||||
status: "fresh",
|
||||
graphCommitHash: baseline,
|
||||
headCommitHash: headCommit,
|
||||
changedFileCount: 0,
|
||||
changedFiles: [],
|
||||
commitsBehind: 0,
|
||||
commitsAhead: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("still reports source changes beside ignored output files", async () => {
|
||||
const { repoDir, baseline } = createRepository();
|
||||
writeProjectFile(
|
||||
repoDir,
|
||||
".understand-anything/knowledge-graph.json",
|
||||
"{}\n",
|
||||
);
|
||||
writeProjectFile(repoDir, "src/real-change.ts", "export const changed = true;\n");
|
||||
|
||||
await expect(
|
||||
getGraphFreshness(repoDir, { graphCommitHash: baseline }),
|
||||
).resolves.toMatchObject({
|
||||
status: "dirty",
|
||||
changedFileCount: 1,
|
||||
changedFiles: ["src/real-change.ts"],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves an abbreviated graph hash to the full matching commit", async () => {
|
||||
const { repoDir, baseline } = createRepository();
|
||||
|
||||
await expect(
|
||||
getGraphFreshness(repoDir, {
|
||||
graphCommitHash: baseline.slice(0, 8),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: "fresh",
|
||||
graphCommitHash: baseline,
|
||||
headCommitHash: baseline,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a graph behind the current project history", async () => {
|
||||
const { repoDir, baseline } = createRepository();
|
||||
writeProjectFile(repoDir, "src/index.ts", "export const value = 2;\n");
|
||||
const head = commitAll(repoDir, "project change");
|
||||
|
||||
await expect(
|
||||
getGraphFreshness(repoDir, { graphCommitHash: baseline }),
|
||||
).resolves.toMatchObject({
|
||||
status: "stale",
|
||||
relation: "behind",
|
||||
graphCommitHash: baseline,
|
||||
headCommitHash: head,
|
||||
changedFileCount: 1,
|
||||
changedFiles: ["src/index.ts"],
|
||||
commitsBehind: 1,
|
||||
commitsAhead: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a graph ahead of the checked-out project history", async () => {
|
||||
const { repoDir, baseline } = createRepository();
|
||||
writeProjectFile(repoDir, "src/future.ts", "export const future = true;\n");
|
||||
const graphCommit = commitAll(repoDir, "future graph commit");
|
||||
git(repoDir, "checkout", "--detach", baseline);
|
||||
|
||||
await expect(
|
||||
getGraphFreshness(repoDir, { graphCommitHash: graphCommit }),
|
||||
).resolves.toMatchObject({
|
||||
status: "stale",
|
||||
relation: "ahead",
|
||||
graphCommitHash: graphCommit,
|
||||
headCommitHash: baseline,
|
||||
changedFileCount: 1,
|
||||
changedFiles: ["src/future.ts"],
|
||||
commitsBehind: 0,
|
||||
commitsAhead: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports divergent project histories without calling either side behind", async () => {
|
||||
const { repoDir, baseline } = createRepository();
|
||||
git(repoDir, "checkout", "-b", "graph-history");
|
||||
writeProjectFile(repoDir, "src/graph.ts", "export const graph = true;\n");
|
||||
const graphCommit = commitAll(repoDir, "graph-side change");
|
||||
|
||||
git(repoDir, "checkout", "-b", "head-history", baseline);
|
||||
writeProjectFile(repoDir, "src/head.ts", "export const head = true;\n");
|
||||
const headCommit = commitAll(repoDir, "head-side change");
|
||||
|
||||
await expect(
|
||||
getGraphFreshness(repoDir, { graphCommitHash: graphCommit }),
|
||||
).resolves.toMatchObject({
|
||||
status: "stale",
|
||||
relation: "diverged",
|
||||
graphCommitHash: graphCommit,
|
||||
headCommitHash: headCommit,
|
||||
changedFileCount: 2,
|
||||
changedFiles: ["src/graph.ts", "src/head.ts"],
|
||||
commitsBehind: 1,
|
||||
commitsAhead: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores commits that only touch a sibling monorepo project", async () => {
|
||||
const { repoDir, baseline } = createRepository({
|
||||
"apps/target/src/index.ts": "export const target = 1;\n",
|
||||
"apps/sibling/src/index.ts": "export const sibling = 1;\n",
|
||||
});
|
||||
writeProjectFile(
|
||||
repoDir,
|
||||
"apps/sibling/src/index.ts",
|
||||
"export const sibling = 2;\n",
|
||||
);
|
||||
const head = commitAll(repoDir, "sibling-only change");
|
||||
const targetProject = join(repoDir, "apps", "target");
|
||||
|
||||
await expect(
|
||||
getGraphFreshness(targetProject, { graphCommitHash: baseline }),
|
||||
).resolves.toEqual({
|
||||
status: "fresh",
|
||||
graphCommitHash: baseline,
|
||||
headCommitHash: head,
|
||||
changedFileCount: 0,
|
||||
changedFiles: [],
|
||||
commitsBehind: 0,
|
||||
commitsAhead: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores staged, unstaged, and untracked changes in a sibling project", async () => {
|
||||
const { repoDir, baseline } = createRepository({
|
||||
"apps/target/src/index.ts": "export const target = 1;\n",
|
||||
"apps/sibling/src/staged.ts": "export const staged = 1;\n",
|
||||
"apps/sibling/src/unstaged.ts": "export const unstaged = 1;\n",
|
||||
});
|
||||
writeProjectFile(
|
||||
repoDir,
|
||||
"apps/sibling/src/staged.ts",
|
||||
"export const staged = 2;\n",
|
||||
);
|
||||
git(repoDir, "add", "apps/sibling/src/staged.ts");
|
||||
writeProjectFile(
|
||||
repoDir,
|
||||
"apps/sibling/src/unstaged.ts",
|
||||
"export const unstaged = 2;\n",
|
||||
);
|
||||
writeProjectFile(
|
||||
repoDir,
|
||||
"apps/sibling/src/untracked.ts",
|
||||
"export const untracked = true;\n",
|
||||
);
|
||||
const targetProject = join(repoDir, "apps", "target");
|
||||
|
||||
await expect(
|
||||
getGraphFreshness(targetProject, { graphCommitHash: baseline }),
|
||||
).resolves.toEqual({
|
||||
status: "fresh",
|
||||
graphCommitHash: baseline,
|
||||
headCommitHash: baseline,
|
||||
changedFileCount: 0,
|
||||
changedFiles: [],
|
||||
commitsBehind: 0,
|
||||
commitsAhead: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts only selected-project commits and returns project-relative paths", async () => {
|
||||
const { repoDir, baseline } = createRepository({
|
||||
"apps/target/src/index.ts": "export const target = 1;\n",
|
||||
"apps/sibling/src/index.ts": "export const sibling = 1;\n",
|
||||
});
|
||||
writeProjectFile(
|
||||
repoDir,
|
||||
"apps/sibling/src/index.ts",
|
||||
"export const sibling = 2;\n",
|
||||
);
|
||||
commitAll(repoDir, "sibling-only change");
|
||||
writeProjectFile(
|
||||
repoDir,
|
||||
"apps/target/src/index.ts",
|
||||
"export const target = 2;\n",
|
||||
);
|
||||
commitAll(repoDir, "target-project change");
|
||||
const targetProject = join(repoDir, "apps", "target");
|
||||
|
||||
await expect(
|
||||
getGraphFreshness(targetProject, { graphCommitHash: baseline }),
|
||||
).resolves.toMatchObject({
|
||||
status: "stale",
|
||||
relation: "behind",
|
||||
changedFileCount: 1,
|
||||
changedFiles: ["src/index.ts"],
|
||||
commitsBehind: 1,
|
||||
commitsAhead: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves spaces and non-ASCII characters in changed paths", async () => {
|
||||
const specialPath = "src/space name-文件.ts";
|
||||
const { repoDir, baseline } = createRepository({
|
||||
[specialPath]: "export const value = 1;\n",
|
||||
});
|
||||
writeProjectFile(repoDir, specialPath, "export const value = 2;\n");
|
||||
commitAll(repoDir, "change special path");
|
||||
|
||||
await expect(
|
||||
getGraphFreshness(repoDir, { graphCommitHash: baseline }),
|
||||
).resolves.toMatchObject({
|
||||
status: "stale",
|
||||
changedFileCount: 1,
|
||||
changedFiles: [specialPath],
|
||||
});
|
||||
});
|
||||
|
||||
it("evaluates multiple graph commits in one batch", async () => {
|
||||
const { repoDir, baseline } = createRepository();
|
||||
writeProjectFile(repoDir, "src/domain.ts", "export const domain = 1;\n");
|
||||
const domainCommit = commitAll(repoDir, "domain graph commit");
|
||||
writeProjectFile(repoDir, "src/index.ts", "export const value = 2;\n");
|
||||
const headCommit = commitAll(repoDir, "knowledge graph drift");
|
||||
|
||||
await expect(
|
||||
getGraphFreshnessBatch(repoDir, {
|
||||
knowledge: { graphCommitHash: baseline },
|
||||
domain: { graphCommitHash: domainCommit },
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
knowledge: {
|
||||
status: "stale",
|
||||
relation: "behind",
|
||||
headCommitHash: headCommit,
|
||||
commitsBehind: 2,
|
||||
},
|
||||
domain: {
|
||||
status: "stale",
|
||||
relation: "behind",
|
||||
headCommitHash: headCommit,
|
||||
commitsBehind: 1,
|
||||
changedFiles: ["src/index.ts"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns missing-graph-commit without consulting Git", async () => {
|
||||
const projectDir = createTemporaryDirectory();
|
||||
|
||||
await expect(
|
||||
getGraphFreshness(projectDir, {
|
||||
graphCommitHash: " ",
|
||||
lastAnalyzedAt: "2026-07-10T00:00:00.000Z",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: "unknown",
|
||||
reason: "missing-graph-commit",
|
||||
lastAnalyzedAt: "2026-07-10T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns git-head-unavailable outside a Git repository", async () => {
|
||||
const projectDir = createTemporaryDirectory();
|
||||
|
||||
await expect(
|
||||
getGraphFreshness(projectDir, { graphCommitHash: "deadbeef" }),
|
||||
).resolves.toEqual({
|
||||
status: "unknown",
|
||||
reason: "git-head-unavailable",
|
||||
graphCommitHash: "deadbeef",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns graph-commit-unavailable for an unknown graph commit", async () => {
|
||||
const { repoDir, baseline } = createRepository();
|
||||
|
||||
await expect(
|
||||
getGraphFreshness(repoDir, { graphCommitHash: "deadbeef" }),
|
||||
).resolves.toEqual({
|
||||
status: "unknown",
|
||||
reason: "graph-commit-unavailable",
|
||||
graphCommitHash: "deadbeef",
|
||||
headCommitHash: baseline,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,360 +1,257 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { KnowledgeGraph, GraphNode, GraphEdge } from "../types.js";
|
||||
|
||||
vi.mock("child_process", () => ({
|
||||
execFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
import { execFileSync } from "child_process";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { KnowledgeGraph, GraphNode, GraphEdge } from "../types.js";
|
||||
|
||||
vi.mock("child_process", () => ({
|
||||
execFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
import { execFileSync } from "child_process";
|
||||
import {
|
||||
getChangedFiles,
|
||||
getGraphFreshness,
|
||||
isStale,
|
||||
mergeGraphUpdate,
|
||||
} from "../staleness.js";
|
||||
|
||||
const mockedExecFileSync = vi.mocked(execFileSync);
|
||||
|
||||
const makeNode = (
|
||||
overrides: Partial<GraphNode> & { id: string; name: string },
|
||||
): GraphNode => ({
|
||||
type: "file",
|
||||
summary: "",
|
||||
tags: [],
|
||||
complexity: "simple",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeEdge = (
|
||||
overrides: Partial<GraphEdge> & { source: string; target: string },
|
||||
): GraphEdge => ({
|
||||
type: "imports",
|
||||
direction: "forward",
|
||||
weight: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function makeGraph(overrides?: Partial<KnowledgeGraph>): KnowledgeGraph {
|
||||
return {
|
||||
version: "1.0.0",
|
||||
project: {
|
||||
name: "test-project",
|
||||
languages: ["typescript"],
|
||||
frameworks: [],
|
||||
description: "A test project",
|
||||
analyzedAt: "2026-01-01T00:00:00.000Z",
|
||||
gitCommitHash: "abc123",
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
layers: [],
|
||||
tour: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getChangedFiles", () => {
|
||||
it("returns changed file list from git diff", () => {
|
||||
mockedExecFileSync.mockReturnValue("src/index.ts\nsrc/utils.ts\n");
|
||||
|
||||
const result = getChangedFiles("/project", "abc123");
|
||||
|
||||
expect(result).toEqual(["src/index.ts", "src/utils.ts"]);
|
||||
expect(mockedExecFileSync).toHaveBeenCalledWith(
|
||||
"git",
|
||||
["diff", "abc123..HEAD", "--name-only"],
|
||||
{ cwd: "/project", encoding: "utf-8" },
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty array when no changes", () => {
|
||||
mockedExecFileSync.mockReturnValue("");
|
||||
|
||||
const result = getChangedFiles("/project", "abc123");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array on git error", () => {
|
||||
mockedExecFileSync.mockImplementation(() => {
|
||||
throw new Error("fatal: bad revision");
|
||||
});
|
||||
|
||||
const result = getChangedFiles("/project", "abc123");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isStale", () => {
|
||||
it("returns stale when files have changed", () => {
|
||||
mockedExecFileSync.mockReturnValue("src/index.ts\n");
|
||||
|
||||
const result = isStale("/project", "abc123");
|
||||
|
||||
expect(result).toEqual({
|
||||
stale: true,
|
||||
changedFiles: ["src/index.ts"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns not stale when no files changed", () => {
|
||||
mockedExecFileSync.mockReturnValue("");
|
||||
|
||||
const result = isStale("/project", "abc123");
|
||||
|
||||
expect(result).toEqual({
|
||||
stale: false,
|
||||
changedFiles: [],
|
||||
});
|
||||
});
|
||||
|
||||
const mockedExecFileSync = vi.mocked(execFileSync);
|
||||
|
||||
const makeNode = (
|
||||
overrides: Partial<GraphNode> & { id: string; name: string },
|
||||
): GraphNode => ({
|
||||
type: "file",
|
||||
summary: "",
|
||||
tags: [],
|
||||
complexity: "simple",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("getGraphFreshness", () => {
|
||||
it("returns fresh when the graph commit matches HEAD", () => {
|
||||
mockedExecFileSync.mockReturnValue("abc123\n");
|
||||
const makeEdge = (
|
||||
overrides: Partial<GraphEdge> & { source: string; target: string },
|
||||
): GraphEdge => ({
|
||||
type: "imports",
|
||||
direction: "forward",
|
||||
weight: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const result = getGraphFreshness("/project", {
|
||||
graphCommitHash: "abc123",
|
||||
lastAnalyzedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
function makeGraph(overrides?: Partial<KnowledgeGraph>): KnowledgeGraph {
|
||||
return {
|
||||
version: "1.0.0",
|
||||
project: {
|
||||
name: "test-project",
|
||||
languages: ["typescript"],
|
||||
frameworks: [],
|
||||
description: "A test project",
|
||||
analyzedAt: "2026-01-01T00:00:00.000Z",
|
||||
gitCommitHash: "abc123",
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
layers: [],
|
||||
tour: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "fresh",
|
||||
graphCommitHash: "abc123",
|
||||
headCommitHash: "abc123",
|
||||
changedFileCount: 0,
|
||||
changedFiles: [],
|
||||
commitsBehind: 0,
|
||||
lastAnalyzedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getChangedFiles", () => {
|
||||
it("returns changed file list from git diff", () => {
|
||||
mockedExecFileSync.mockReturnValue("src/index.ts\nsrc/utils.ts\n");
|
||||
|
||||
const result = getChangedFiles("/project", "abc123");
|
||||
|
||||
expect(result).toEqual(["src/index.ts", "src/utils.ts"]);
|
||||
expect(mockedExecFileSync).toHaveBeenCalledWith(
|
||||
"git",
|
||||
["rev-parse", "HEAD"],
|
||||
["diff", "abc123..HEAD", "--name-only"],
|
||||
{ cwd: "/project", encoding: "utf-8" },
|
||||
);
|
||||
});
|
||||
|
||||
it("returns stale with changed file and commit counts when HEAD moved", () => {
|
||||
mockedExecFileSync.mockImplementation((command, args) => {
|
||||
expect(command).toBe("git");
|
||||
if (Array.isArray(args) && args[0] === "rev-parse") return "def456\n";
|
||||
if (Array.isArray(args) && args[0] === "rev-list") return "3\n";
|
||||
if (Array.isArray(args) && args[0] === "diff") {
|
||||
return "src/index.ts\nsrc/auth.ts\n";
|
||||
}
|
||||
throw new Error(`unexpected git args: ${String(args)}`);
|
||||
});
|
||||
it("returns empty array when no changes", () => {
|
||||
mockedExecFileSync.mockReturnValue("");
|
||||
|
||||
const result = getGraphFreshness("/project", {
|
||||
graphCommitHash: "abc123",
|
||||
lastAnalyzedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const result = getChangedFiles("/project", "abc123");
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "stale",
|
||||
graphCommitHash: "abc123",
|
||||
headCommitHash: "def456",
|
||||
changedFileCount: 2,
|
||||
changedFiles: ["src/index.ts", "src/auth.ts"],
|
||||
commitsBehind: 3,
|
||||
lastAnalyzedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns unknown when graph metadata has no commit hash", () => {
|
||||
const result = getGraphFreshness("/project", {
|
||||
graphCommitHash: "",
|
||||
lastAnalyzedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "unknown",
|
||||
reason: "missing-graph-commit",
|
||||
lastAnalyzedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(mockedExecFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns unknown when git HEAD cannot be read", () => {
|
||||
it("returns empty array on git error", () => {
|
||||
mockedExecFileSync.mockImplementation(() => {
|
||||
throw new Error("fatal: not a git repository");
|
||||
});
|
||||
|
||||
const result = getGraphFreshness("/project", {
|
||||
graphCommitHash: "abc123",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "unknown",
|
||||
reason: "git-head-unavailable",
|
||||
graphCommitHash: "abc123",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns unknown when the graph commit cannot be diffed", () => {
|
||||
mockedExecFileSync.mockImplementation((command, args) => {
|
||||
expect(command).toBe("git");
|
||||
if (Array.isArray(args) && args[0] === "rev-parse") return "def456\n";
|
||||
throw new Error("fatal: bad revision");
|
||||
});
|
||||
|
||||
const result = getGraphFreshness("/project", {
|
||||
graphCommitHash: "missing123",
|
||||
});
|
||||
const result = getChangedFiles("/project", "abc123");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isStale", () => {
|
||||
it("returns stale when files have changed", () => {
|
||||
mockedExecFileSync.mockReturnValue("src/index.ts\n");
|
||||
|
||||
const result = isStale("/project", "abc123");
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "unknown",
|
||||
reason: "graph-commit-unavailable",
|
||||
graphCommitHash: "missing123",
|
||||
headCommitHash: "def456",
|
||||
stale: true,
|
||||
changedFiles: ["src/index.ts"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns not stale when no files changed", () => {
|
||||
mockedExecFileSync.mockReturnValue("");
|
||||
|
||||
const result = isStale("/project", "abc123");
|
||||
|
||||
expect(result).toEqual({
|
||||
stale: false,
|
||||
changedFiles: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeGraphUpdate", () => {
|
||||
it("replaces nodes for changed files", () => {
|
||||
const existingGraph = makeGraph({
|
||||
nodes: [
|
||||
makeNode({
|
||||
id: "file-a",
|
||||
name: "a.ts",
|
||||
filePath: "src/a.ts",
|
||||
summary: "Old summary",
|
||||
}),
|
||||
makeNode({
|
||||
id: "file-b",
|
||||
name: "b.ts",
|
||||
filePath: "src/b.ts",
|
||||
summary: "Unchanged",
|
||||
}),
|
||||
makeNode({
|
||||
id: "func-a1",
|
||||
name: "funcA1",
|
||||
type: "function",
|
||||
filePath: "src/a.ts",
|
||||
summary: "Old function",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const newNodes = [
|
||||
makeNode({
|
||||
id: "file-a-v2",
|
||||
name: "a.ts",
|
||||
filePath: "src/a.ts",
|
||||
summary: "New summary",
|
||||
}),
|
||||
makeNode({
|
||||
id: "func-a2",
|
||||
name: "funcA2",
|
||||
type: "function",
|
||||
filePath: "src/a.ts",
|
||||
summary: "New function",
|
||||
}),
|
||||
];
|
||||
|
||||
const result = mergeGraphUpdate(
|
||||
existingGraph,
|
||||
["src/a.ts"],
|
||||
newNodes,
|
||||
[],
|
||||
"def456",
|
||||
);
|
||||
|
||||
// Old nodes from src/a.ts should be gone
|
||||
expect(result.nodes.find((n) => n.id === "file-a")).toBeUndefined();
|
||||
expect(result.nodes.find((n) => n.id === "func-a1")).toBeUndefined();
|
||||
|
||||
// New nodes should be present
|
||||
expect(result.nodes.find((n) => n.id === "file-a-v2")).toBeDefined();
|
||||
expect(result.nodes.find((n) => n.id === "func-a2")).toBeDefined();
|
||||
|
||||
// Unchanged file should remain
|
||||
expect(result.nodes.find((n) => n.id === "file-b")).toBeDefined();
|
||||
});
|
||||
|
||||
it("removes edges originating from changed files", () => {
|
||||
const existingGraph = makeGraph({
|
||||
nodes: [
|
||||
makeNode({ id: "file-a", name: "a.ts", filePath: "src/a.ts" }),
|
||||
makeNode({ id: "file-b", name: "b.ts", filePath: "src/b.ts" }),
|
||||
makeNode({ id: "file-c", name: "c.ts", filePath: "src/c.ts" }),
|
||||
],
|
||||
edges: [
|
||||
// Edge from changed file -> should be removed
|
||||
makeEdge({ source: "file-a", target: "file-b" }),
|
||||
// Edge between unchanged files -> should remain
|
||||
makeEdge({ source: "file-b", target: "file-c" }),
|
||||
// Edge to changed file from unchanged -> should remain
|
||||
makeEdge({ source: "file-c", target: "file-a" }),
|
||||
],
|
||||
});
|
||||
|
||||
const newNodes = [
|
||||
makeNode({
|
||||
id: "file-a-v2",
|
||||
name: "a.ts",
|
||||
filePath: "src/a.ts",
|
||||
summary: "Updated",
|
||||
}),
|
||||
];
|
||||
|
||||
const newEdges = [
|
||||
makeEdge({ source: "file-a-v2", target: "file-c" }),
|
||||
];
|
||||
|
||||
const result = mergeGraphUpdate(
|
||||
existingGraph,
|
||||
["src/a.ts"],
|
||||
newNodes,
|
||||
newEdges,
|
||||
"def456",
|
||||
);
|
||||
|
||||
// Old edge from file-a should be removed
|
||||
expect(
|
||||
result.edges.find(
|
||||
(e) => e.source === "file-a" && e.target === "file-b",
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
// Edge between unchanged files should remain
|
||||
expect(
|
||||
result.edges.find(
|
||||
(e) => e.source === "file-b" && e.target === "file-c",
|
||||
),
|
||||
).toBeDefined();
|
||||
|
||||
// Edge to changed file from unchanged should be removed (dangling target)
|
||||
expect(
|
||||
result.edges.find(
|
||||
(e) => e.source === "file-c" && e.target === "file-a",
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
// New edge should be added
|
||||
expect(
|
||||
result.edges.find(
|
||||
(e) => e.source === "file-a-v2" && e.target === "file-c",
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("updates analyzedAt timestamp and gitCommitHash", () => {
|
||||
const existingGraph = makeGraph();
|
||||
|
||||
const before = new Date().toISOString();
|
||||
const result = mergeGraphUpdate(existingGraph, [], [], [], "def456");
|
||||
const after = new Date().toISOString();
|
||||
|
||||
expect(result.project.gitCommitHash).toBe("def456");
|
||||
expect(result.project.analyzedAt >= before).toBe(true);
|
||||
expect(result.project.analyzedAt <= after).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeGraphUpdate", () => {
|
||||
it("replaces nodes for changed files", () => {
|
||||
const existingGraph = makeGraph({
|
||||
nodes: [
|
||||
makeNode({
|
||||
id: "file-a",
|
||||
name: "a.ts",
|
||||
filePath: "src/a.ts",
|
||||
summary: "Old summary",
|
||||
}),
|
||||
makeNode({
|
||||
id: "file-b",
|
||||
name: "b.ts",
|
||||
filePath: "src/b.ts",
|
||||
summary: "Unchanged",
|
||||
}),
|
||||
makeNode({
|
||||
id: "func-a1",
|
||||
name: "funcA1",
|
||||
type: "function",
|
||||
filePath: "src/a.ts",
|
||||
summary: "Old function",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const newNodes = [
|
||||
makeNode({
|
||||
id: "file-a-v2",
|
||||
name: "a.ts",
|
||||
filePath: "src/a.ts",
|
||||
summary: "New summary",
|
||||
}),
|
||||
makeNode({
|
||||
id: "func-a2",
|
||||
name: "funcA2",
|
||||
type: "function",
|
||||
filePath: "src/a.ts",
|
||||
summary: "New function",
|
||||
}),
|
||||
];
|
||||
|
||||
const result = mergeGraphUpdate(
|
||||
existingGraph,
|
||||
["src/a.ts"],
|
||||
newNodes,
|
||||
[],
|
||||
"def456",
|
||||
);
|
||||
|
||||
// Old nodes from src/a.ts should be gone
|
||||
expect(result.nodes.find((n) => n.id === "file-a")).toBeUndefined();
|
||||
expect(result.nodes.find((n) => n.id === "func-a1")).toBeUndefined();
|
||||
|
||||
// New nodes should be present
|
||||
expect(result.nodes.find((n) => n.id === "file-a-v2")).toBeDefined();
|
||||
expect(result.nodes.find((n) => n.id === "func-a2")).toBeDefined();
|
||||
|
||||
// Unchanged file should remain
|
||||
expect(result.nodes.find((n) => n.id === "file-b")).toBeDefined();
|
||||
});
|
||||
|
||||
it("removes edges originating from changed files", () => {
|
||||
const existingGraph = makeGraph({
|
||||
nodes: [
|
||||
makeNode({ id: "file-a", name: "a.ts", filePath: "src/a.ts" }),
|
||||
makeNode({ id: "file-b", name: "b.ts", filePath: "src/b.ts" }),
|
||||
makeNode({ id: "file-c", name: "c.ts", filePath: "src/c.ts" }),
|
||||
],
|
||||
edges: [
|
||||
// Edge from changed file -> should be removed
|
||||
makeEdge({ source: "file-a", target: "file-b" }),
|
||||
// Edge between unchanged files -> should remain
|
||||
makeEdge({ source: "file-b", target: "file-c" }),
|
||||
// Edge to changed file from unchanged -> should remain
|
||||
makeEdge({ source: "file-c", target: "file-a" }),
|
||||
],
|
||||
});
|
||||
|
||||
const newNodes = [
|
||||
makeNode({
|
||||
id: "file-a-v2",
|
||||
name: "a.ts",
|
||||
filePath: "src/a.ts",
|
||||
summary: "Updated",
|
||||
}),
|
||||
];
|
||||
|
||||
const newEdges = [
|
||||
makeEdge({ source: "file-a-v2", target: "file-c" }),
|
||||
];
|
||||
|
||||
const result = mergeGraphUpdate(
|
||||
existingGraph,
|
||||
["src/a.ts"],
|
||||
newNodes,
|
||||
newEdges,
|
||||
"def456",
|
||||
);
|
||||
|
||||
// Old edge from file-a should be removed
|
||||
expect(
|
||||
result.edges.find(
|
||||
(e) => e.source === "file-a" && e.target === "file-b",
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
// Edge between unchanged files should remain
|
||||
expect(
|
||||
result.edges.find(
|
||||
(e) => e.source === "file-b" && e.target === "file-c",
|
||||
),
|
||||
).toBeDefined();
|
||||
|
||||
// Edge to changed file from unchanged should be removed (dangling target)
|
||||
expect(
|
||||
result.edges.find(
|
||||
(e) => e.source === "file-c" && e.target === "file-a",
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
// New edge should be added
|
||||
expect(
|
||||
result.edges.find(
|
||||
(e) => e.source === "file-a-v2" && e.target === "file-c",
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("updates analyzedAt timestamp and gitCommitHash", () => {
|
||||
const existingGraph = makeGraph();
|
||||
|
||||
const before = new Date().toISOString();
|
||||
const result = mergeGraphUpdate(existingGraph, [], [], [], "def456");
|
||||
const after = new Date().toISOString();
|
||||
|
||||
expect(result.project.gitCommitHash).toBe("def456");
|
||||
expect(result.project.analyzedAt >= before).toBe(true);
|
||||
expect(result.project.analyzedAt <= after).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,127 +1,130 @@
|
||||
export * from "./types.js";
|
||||
export * from "./persistence/index.js";
|
||||
export {
|
||||
KnowledgeGraphSchema,
|
||||
validateGraph,
|
||||
sanitizeGraph,
|
||||
autoFixGraph,
|
||||
COMPLEXITY_ALIASES,
|
||||
DIRECTION_ALIASES,
|
||||
type ValidationResult,
|
||||
type GraphIssue,
|
||||
} from "./schema.js";
|
||||
export { TreeSitterPlugin } from "./plugins/tree-sitter-plugin.js";
|
||||
export type { LanguageExtractor } from "./plugins/extractors/types.js";
|
||||
export { builtinExtractors } from "./plugins/extractors/index.js";
|
||||
export { GraphBuilder } from "./analyzer/graph-builder.js";
|
||||
export {
|
||||
buildFileAnalysisPrompt,
|
||||
buildProjectSummaryPrompt,
|
||||
parseFileAnalysisResponse,
|
||||
parseProjectSummaryResponse,
|
||||
} from "./analyzer/llm-analyzer.js";
|
||||
export type { LLMFileAnalysis, LLMProjectSummary } from "./analyzer/llm-analyzer.js";
|
||||
export {
|
||||
normalizeNodeId,
|
||||
normalizeComplexity,
|
||||
normalizeBatchOutput,
|
||||
type DroppedEdge,
|
||||
type NormalizationStats,
|
||||
type NormalizeBatchResult,
|
||||
} from "./analyzer/normalize-graph.js";
|
||||
export { SearchEngine, type SearchResult, type SearchOptions } from "./search.js";
|
||||
export {
|
||||
export * from "./types.js";
|
||||
export * from "./persistence/index.js";
|
||||
export {
|
||||
KnowledgeGraphSchema,
|
||||
validateGraph,
|
||||
sanitizeGraph,
|
||||
autoFixGraph,
|
||||
COMPLEXITY_ALIASES,
|
||||
DIRECTION_ALIASES,
|
||||
type ValidationResult,
|
||||
type GraphIssue,
|
||||
} from "./schema.js";
|
||||
export { TreeSitterPlugin } from "./plugins/tree-sitter-plugin.js";
|
||||
export type { LanguageExtractor } from "./plugins/extractors/types.js";
|
||||
export { builtinExtractors } from "./plugins/extractors/index.js";
|
||||
export { GraphBuilder } from "./analyzer/graph-builder.js";
|
||||
export {
|
||||
buildFileAnalysisPrompt,
|
||||
buildProjectSummaryPrompt,
|
||||
parseFileAnalysisResponse,
|
||||
parseProjectSummaryResponse,
|
||||
} from "./analyzer/llm-analyzer.js";
|
||||
export type { LLMFileAnalysis, LLMProjectSummary } from "./analyzer/llm-analyzer.js";
|
||||
export {
|
||||
normalizeNodeId,
|
||||
normalizeComplexity,
|
||||
normalizeBatchOutput,
|
||||
type DroppedEdge,
|
||||
type NormalizationStats,
|
||||
type NormalizeBatchResult,
|
||||
} from "./analyzer/normalize-graph.js";
|
||||
export { SearchEngine, type SearchResult, type SearchOptions } from "./search.js";
|
||||
export {
|
||||
getChangedFiles,
|
||||
getGraphFreshness,
|
||||
getGraphFreshnessBatch,
|
||||
isStale,
|
||||
mergeGraphUpdate,
|
||||
type GraphFreshnessInput,
|
||||
type GraphFreshnessRelation,
|
||||
type GraphFreshnessResult,
|
||||
type GraphFreshnessUnknownReason,
|
||||
type StalenessResult,
|
||||
} from "./staleness.js";
|
||||
export {
|
||||
detectLayers,
|
||||
buildLayerDetectionPrompt,
|
||||
parseLayerDetectionResponse,
|
||||
applyLLMLayers,
|
||||
} from "./analyzer/layer-detector.js";
|
||||
export type { LLMLayerResponse } from "./analyzer/layer-detector.js";
|
||||
export {
|
||||
buildTourGenerationPrompt,
|
||||
parseTourGenerationResponse,
|
||||
generateHeuristicTour,
|
||||
} from "./analyzer/tour-generator.js";
|
||||
export {
|
||||
buildLanguageLessonPrompt,
|
||||
parseLanguageLessonResponse,
|
||||
detectLanguageConcepts,
|
||||
type LanguageLessonResult,
|
||||
} from "./analyzer/language-lesson.js";
|
||||
export { PluginRegistry } from "./plugins/registry.js";
|
||||
export {
|
||||
LanguageRegistry,
|
||||
FrameworkRegistry,
|
||||
builtinLanguageConfigs,
|
||||
builtinFrameworkConfigs,
|
||||
LanguageConfigSchema,
|
||||
FrameworkConfigSchema,
|
||||
} from "./languages/index.js";
|
||||
export type {
|
||||
LanguageConfig,
|
||||
FrameworkConfig,
|
||||
TreeSitterConfig,
|
||||
FilePatternConfig,
|
||||
} from "./languages/index.js";
|
||||
export {
|
||||
parsePluginConfig,
|
||||
serializePluginConfig,
|
||||
DEFAULT_PLUGIN_CONFIG,
|
||||
type PluginConfig,
|
||||
type PluginEntry,
|
||||
} from "./plugins/discovery.js";
|
||||
export {
|
||||
SemanticSearchEngine,
|
||||
cosineSimilarity,
|
||||
type SemanticSearchOptions,
|
||||
} from "./embedding-search.js";
|
||||
export {
|
||||
extractFileFingerprint,
|
||||
compareFingerprints,
|
||||
analyzeChanges,
|
||||
buildFingerprintStore,
|
||||
contentHash,
|
||||
type FunctionFingerprint,
|
||||
type ClassFingerprint,
|
||||
type ImportFingerprint,
|
||||
type FileFingerprint,
|
||||
type FingerprintStore,
|
||||
type ChangeLevel,
|
||||
type FileChangeResult,
|
||||
type ChangeAnalysis,
|
||||
} from "./fingerprint.js";
|
||||
export {
|
||||
classifyUpdate,
|
||||
type UpdateDecision,
|
||||
} from "./change-classifier.js";
|
||||
// Non-code parsers
|
||||
export {
|
||||
MarkdownParser,
|
||||
YAMLConfigParser,
|
||||
JSONConfigParser,
|
||||
TOMLParser,
|
||||
EnvParser,
|
||||
DockerfileParser,
|
||||
SQLParser,
|
||||
GraphQLParser,
|
||||
ProtobufParser,
|
||||
TerraformParser,
|
||||
MakefileParser,
|
||||
ShellParser,
|
||||
registerAllParsers,
|
||||
} from "./plugins/parsers/index.js";
|
||||
export {
|
||||
createIgnoreFilter,
|
||||
DEFAULT_IGNORE_PATTERNS,
|
||||
type IgnoreFilter,
|
||||
} from "./ignore-filter.js";
|
||||
export { generateStarterIgnoreFile } from "./ignore-generator.js";
|
||||
export {
|
||||
detectLayers,
|
||||
buildLayerDetectionPrompt,
|
||||
parseLayerDetectionResponse,
|
||||
applyLLMLayers,
|
||||
} from "./analyzer/layer-detector.js";
|
||||
export type { LLMLayerResponse } from "./analyzer/layer-detector.js";
|
||||
export {
|
||||
buildTourGenerationPrompt,
|
||||
parseTourGenerationResponse,
|
||||
generateHeuristicTour,
|
||||
} from "./analyzer/tour-generator.js";
|
||||
export {
|
||||
buildLanguageLessonPrompt,
|
||||
parseLanguageLessonResponse,
|
||||
detectLanguageConcepts,
|
||||
type LanguageLessonResult,
|
||||
} from "./analyzer/language-lesson.js";
|
||||
export { PluginRegistry } from "./plugins/registry.js";
|
||||
export {
|
||||
LanguageRegistry,
|
||||
FrameworkRegistry,
|
||||
builtinLanguageConfigs,
|
||||
builtinFrameworkConfigs,
|
||||
LanguageConfigSchema,
|
||||
FrameworkConfigSchema,
|
||||
} from "./languages/index.js";
|
||||
export type {
|
||||
LanguageConfig,
|
||||
FrameworkConfig,
|
||||
TreeSitterConfig,
|
||||
FilePatternConfig,
|
||||
} from "./languages/index.js";
|
||||
export {
|
||||
parsePluginConfig,
|
||||
serializePluginConfig,
|
||||
DEFAULT_PLUGIN_CONFIG,
|
||||
type PluginConfig,
|
||||
type PluginEntry,
|
||||
} from "./plugins/discovery.js";
|
||||
export {
|
||||
SemanticSearchEngine,
|
||||
cosineSimilarity,
|
||||
type SemanticSearchOptions,
|
||||
} from "./embedding-search.js";
|
||||
export {
|
||||
extractFileFingerprint,
|
||||
compareFingerprints,
|
||||
analyzeChanges,
|
||||
buildFingerprintStore,
|
||||
contentHash,
|
||||
type FunctionFingerprint,
|
||||
type ClassFingerprint,
|
||||
type ImportFingerprint,
|
||||
type FileFingerprint,
|
||||
type FingerprintStore,
|
||||
type ChangeLevel,
|
||||
type FileChangeResult,
|
||||
type ChangeAnalysis,
|
||||
} from "./fingerprint.js";
|
||||
export {
|
||||
classifyUpdate,
|
||||
type UpdateDecision,
|
||||
} from "./change-classifier.js";
|
||||
// Non-code parsers
|
||||
export {
|
||||
MarkdownParser,
|
||||
YAMLConfigParser,
|
||||
JSONConfigParser,
|
||||
TOMLParser,
|
||||
EnvParser,
|
||||
DockerfileParser,
|
||||
SQLParser,
|
||||
GraphQLParser,
|
||||
ProtobufParser,
|
||||
TerraformParser,
|
||||
MakefileParser,
|
||||
ShellParser,
|
||||
registerAllParsers,
|
||||
} from "./plugins/parsers/index.js";
|
||||
export {
|
||||
createIgnoreFilter,
|
||||
DEFAULT_IGNORE_PATTERNS,
|
||||
type IgnoreFilter,
|
||||
} from "./ignore-filter.js";
|
||||
export { generateStarterIgnoreFile } from "./ignore-generator.js";
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { execFileSync } from "child_process";
|
||||
import type { KnowledgeGraph, GraphNode, GraphEdge } from "./types.js";
|
||||
|
||||
import { execFile, execFileSync } from "child_process";
|
||||
import type { KnowledgeGraph, GraphNode, GraphEdge } from "./types.js";
|
||||
|
||||
export interface StalenessResult {
|
||||
stale: boolean;
|
||||
changedFiles: string[];
|
||||
}
|
||||
|
||||
export type GraphFreshnessRelation = "behind" | "ahead" | "diverged";
|
||||
|
||||
export type GraphFreshnessUnknownReason =
|
||||
| "missing-graph-commit"
|
||||
| "git-head-unavailable"
|
||||
| "graph-commit-unavailable"
|
||||
| "git-command-timeout"
|
||||
| "freshness-request-failed";
|
||||
|
||||
export type GraphFreshnessResult =
|
||||
| {
|
||||
status: "fresh";
|
||||
@@ -14,23 +23,33 @@ export type GraphFreshnessResult =
|
||||
changedFileCount: 0;
|
||||
changedFiles: [];
|
||||
commitsBehind: 0;
|
||||
commitsAhead: 0;
|
||||
lastAnalyzedAt?: string;
|
||||
}
|
||||
| {
|
||||
status: "dirty";
|
||||
graphCommitHash: string;
|
||||
headCommitHash: string;
|
||||
changedFileCount: number;
|
||||
changedFiles: string[];
|
||||
commitsBehind: 0;
|
||||
commitsAhead: 0;
|
||||
lastAnalyzedAt?: string;
|
||||
}
|
||||
| {
|
||||
status: "stale";
|
||||
relation: GraphFreshnessRelation;
|
||||
graphCommitHash: string;
|
||||
headCommitHash: string;
|
||||
changedFileCount: number;
|
||||
changedFiles: string[];
|
||||
commitsBehind: number;
|
||||
commitsAhead: number;
|
||||
lastAnalyzedAt?: string;
|
||||
}
|
||||
| {
|
||||
status: "unknown";
|
||||
reason:
|
||||
| "missing-graph-commit"
|
||||
| "git-head-unavailable"
|
||||
| "graph-commit-unavailable";
|
||||
reason: GraphFreshnessUnknownReason;
|
||||
graphCommitHash?: string;
|
||||
headCommitHash?: string;
|
||||
lastAnalyzedAt?: string;
|
||||
@@ -41,11 +60,293 @@ export interface GraphFreshnessInput {
|
||||
lastAnalyzedAt?: string;
|
||||
}
|
||||
|
||||
function runGit(projectDir: string, args: string[]): string {
|
||||
return execFileSync("git", args, {
|
||||
cwd: projectDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
interface ProjectGitSnapshot {
|
||||
projectDir: string;
|
||||
repoRoot: string;
|
||||
headCommitHash: string;
|
||||
dirtyFiles: string[];
|
||||
}
|
||||
|
||||
const GIT_TIMEOUT_MS = 5_000;
|
||||
const GIT_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
|
||||
const PROJECT_PATHSPEC = [
|
||||
"--",
|
||||
".",
|
||||
":(exclude).understand-anything",
|
||||
":(exclude).understand-anything/**",
|
||||
] as const;
|
||||
|
||||
class GitCommandError extends Error {
|
||||
constructor(
|
||||
readonly exitCode: number | null,
|
||||
readonly timedOut: boolean,
|
||||
) {
|
||||
super(timedOut ? "Git command timed out" : "Git command failed");
|
||||
}
|
||||
}
|
||||
|
||||
function runGit(projectDir: string, args: string[]): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
"git",
|
||||
args,
|
||||
{
|
||||
cwd: projectDir,
|
||||
encoding: null,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: GIT_MAX_BUFFER_BYTES,
|
||||
windowsHide: true,
|
||||
},
|
||||
(error, stdout) => {
|
||||
if (error) {
|
||||
reject(
|
||||
new GitCommandError(
|
||||
typeof error.code === "number" ? error.code : null,
|
||||
error.killed === true && error.signal !== null,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function parseScalar(output: Buffer): string {
|
||||
return output.toString("utf8").trim();
|
||||
}
|
||||
|
||||
function parseNulDelimitedPaths(output: Buffer): string[] {
|
||||
const value = output.toString("utf8");
|
||||
if (value.length === 0) return [];
|
||||
|
||||
const paths = value.split("\0");
|
||||
if (paths.at(-1) === "") paths.pop();
|
||||
return paths.filter((path) => path.length > 0);
|
||||
}
|
||||
|
||||
function uniqueSortedPaths(...pathGroups: string[][]): string[] {
|
||||
return [...new Set(pathGroups.flat())].sort();
|
||||
}
|
||||
|
||||
function optionalAnalysisTime(
|
||||
input: GraphFreshnessInput,
|
||||
): Pick<GraphFreshnessInput, "lastAnalyzedAt"> {
|
||||
return input.lastAnalyzedAt === undefined
|
||||
? {}
|
||||
: { lastAnalyzedAt: input.lastAnalyzedAt };
|
||||
}
|
||||
|
||||
function unknownReason(error: unknown, fallback: GraphFreshnessUnknownReason) {
|
||||
return error instanceof GitCommandError && error.timedOut
|
||||
? "git-command-timeout"
|
||||
: fallback;
|
||||
}
|
||||
|
||||
async function createProjectGitSnapshot(
|
||||
projectDir: string,
|
||||
): Promise<ProjectGitSnapshot> {
|
||||
const repoRoot = parseScalar(
|
||||
await runGit(projectDir, ["rev-parse", "--show-toplevel"]),
|
||||
);
|
||||
const headCommitHash = parseScalar(
|
||||
await runGit(projectDir, ["rev-parse", "HEAD"]),
|
||||
);
|
||||
const [staged, unstaged, untracked] = await Promise.all([
|
||||
runGit(projectDir, [
|
||||
"diff",
|
||||
"--cached",
|
||||
"--name-only",
|
||||
"-z",
|
||||
"--relative",
|
||||
...PROJECT_PATHSPEC,
|
||||
]),
|
||||
runGit(projectDir, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"-z",
|
||||
"--relative",
|
||||
...PROJECT_PATHSPEC,
|
||||
]),
|
||||
runGit(projectDir, [
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
"-z",
|
||||
...PROJECT_PATHSPEC,
|
||||
]),
|
||||
]);
|
||||
|
||||
return {
|
||||
projectDir,
|
||||
repoRoot,
|
||||
headCommitHash,
|
||||
dirtyFiles: uniqueSortedPaths(
|
||||
parseNulDelimitedPaths(staged),
|
||||
parseNulDelimitedPaths(unstaged),
|
||||
parseNulDelimitedPaths(untracked),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function isAncestor(
|
||||
projectDir: string,
|
||||
ancestor: string,
|
||||
descendant: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await runGit(projectDir, [
|
||||
"merge-base",
|
||||
"--is-ancestor",
|
||||
ancestor,
|
||||
descendant,
|
||||
]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof GitCommandError && error.exitCode === 1) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function evaluateGraphFreshness(
|
||||
snapshot: ProjectGitSnapshot,
|
||||
input: GraphFreshnessInput,
|
||||
requestedGraphCommitHash: string,
|
||||
): Promise<GraphFreshnessResult> {
|
||||
let graphCommitHash: string;
|
||||
try {
|
||||
graphCommitHash = parseScalar(
|
||||
await runGit(snapshot.projectDir, [
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
"--end-of-options",
|
||||
`${requestedGraphCommitHash}^{commit}`,
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unknown",
|
||||
reason: unknownReason(error, "graph-commit-unavailable"),
|
||||
graphCommitHash: requestedGraphCommitHash,
|
||||
headCommitHash: snapshot.headCommitHash,
|
||||
...optionalAnalysisTime(input),
|
||||
};
|
||||
}
|
||||
|
||||
let committedFiles: string[];
|
||||
try {
|
||||
committedFiles = parseNulDelimitedPaths(
|
||||
await runGit(snapshot.projectDir, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"-z",
|
||||
"--relative",
|
||||
graphCommitHash,
|
||||
snapshot.headCommitHash,
|
||||
...PROJECT_PATHSPEC,
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unknown",
|
||||
reason: unknownReason(error, "graph-commit-unavailable"),
|
||||
graphCommitHash,
|
||||
headCommitHash: snapshot.headCommitHash,
|
||||
...optionalAnalysisTime(input),
|
||||
};
|
||||
}
|
||||
|
||||
if (committedFiles.length === 0) {
|
||||
if (snapshot.dirtyFiles.length > 0) {
|
||||
return {
|
||||
status: "dirty",
|
||||
graphCommitHash,
|
||||
headCommitHash: snapshot.headCommitHash,
|
||||
changedFileCount: snapshot.dirtyFiles.length,
|
||||
changedFiles: snapshot.dirtyFiles,
|
||||
commitsBehind: 0,
|
||||
commitsAhead: 0,
|
||||
...optionalAnalysisTime(input),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "fresh",
|
||||
graphCommitHash,
|
||||
headCommitHash: snapshot.headCommitHash,
|
||||
changedFileCount: 0,
|
||||
changedFiles: [],
|
||||
commitsBehind: 0,
|
||||
commitsAhead: 0,
|
||||
...optionalAnalysisTime(input),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const [countsOutput, graphIsAncestor, headIsAncestor] = await Promise.all([
|
||||
runGit(snapshot.projectDir, [
|
||||
"rev-list",
|
||||
"--left-right",
|
||||
"--count",
|
||||
`${graphCommitHash}...${snapshot.headCommitHash}`,
|
||||
...PROJECT_PATHSPEC,
|
||||
]),
|
||||
isAncestor(
|
||||
snapshot.projectDir,
|
||||
graphCommitHash,
|
||||
snapshot.headCommitHash,
|
||||
),
|
||||
isAncestor(
|
||||
snapshot.projectDir,
|
||||
snapshot.headCommitHash,
|
||||
graphCommitHash,
|
||||
),
|
||||
]);
|
||||
const [commitsAhead, commitsBehind] = parseScalar(countsOutput)
|
||||
.split(/\s+/)
|
||||
.map((value) => Number.parseInt(value, 10));
|
||||
|
||||
if (
|
||||
!Number.isFinite(commitsAhead) ||
|
||||
!Number.isFinite(commitsBehind) ||
|
||||
commitsAhead < 0 ||
|
||||
commitsBehind < 0
|
||||
) {
|
||||
throw new GitCommandError(null, false);
|
||||
}
|
||||
|
||||
const relation: GraphFreshnessRelation = graphIsAncestor
|
||||
? "behind"
|
||||
: headIsAncestor
|
||||
? "ahead"
|
||||
: "diverged";
|
||||
const changedFiles = uniqueSortedPaths(
|
||||
committedFiles,
|
||||
snapshot.dirtyFiles,
|
||||
);
|
||||
|
||||
return {
|
||||
status: "stale",
|
||||
relation,
|
||||
graphCommitHash,
|
||||
headCommitHash: snapshot.headCommitHash,
|
||||
changedFileCount: changedFiles.length,
|
||||
changedFiles,
|
||||
commitsBehind,
|
||||
commitsAhead,
|
||||
...optionalAnalysisTime(input),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unknown",
|
||||
reason: unknownReason(error, "graph-commit-unavailable"),
|
||||
graphCommitHash,
|
||||
headCommitHash: snapshot.headCommitHash,
|
||||
...optionalAnalysisTime(input),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseChangedFiles(output: string): string[] {
|
||||
@@ -59,10 +360,10 @@ function parseChangedFiles(output: string): string[] {
|
||||
* Get the list of files that changed between a given commit and HEAD.
|
||||
* Returns an empty array if there are no changes or if git encounters an error.
|
||||
*/
|
||||
export function getChangedFiles(
|
||||
projectDir: string,
|
||||
lastCommitHash: string,
|
||||
): string[] {
|
||||
export function getChangedFiles(
|
||||
projectDir: string,
|
||||
lastCommitHash: string,
|
||||
): string[] {
|
||||
try {
|
||||
const output = execFileSync("git", ["diff", `${lastCommitHash}..HEAD`, "--name-only"], {
|
||||
cwd: projectDir,
|
||||
@@ -73,135 +374,133 @@ export function getChangedFiles(
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the knowledge graph is stale relative to the current HEAD.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check whether the knowledge graph is stale relative to the current HEAD.
|
||||
*/
|
||||
export function isStale(
|
||||
projectDir: string,
|
||||
lastCommitHash: string,
|
||||
): StalenessResult {
|
||||
const changedFiles = getChangedFiles(projectDir, lastCommitHash);
|
||||
return {
|
||||
stale: changedFiles.length > 0,
|
||||
changedFiles,
|
||||
};
|
||||
const changedFiles = getChangedFiles(projectDir, lastCommitHash);
|
||||
return {
|
||||
stale: changedFiles.length > 0,
|
||||
changedFiles,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe whether a persisted graph can still be trusted for the current HEAD.
|
||||
*
|
||||
* Unknown is intentionally distinct from fresh: if git metadata cannot be read,
|
||||
* callers should warn softly rather than imply the graph is current.
|
||||
* Describe the freshness of multiple persisted graphs against one Git snapshot.
|
||||
*/
|
||||
export function getGraphFreshness(
|
||||
export async function getGraphFreshnessBatch<T extends string>(
|
||||
projectDir: string,
|
||||
inputs: Record<T, GraphFreshnessInput>,
|
||||
): Promise<Record<T, GraphFreshnessResult>> {
|
||||
const entries = Object.entries(inputs) as [T, GraphFreshnessInput][];
|
||||
const results = {} as Record<T, GraphFreshnessResult>;
|
||||
const comparableEntries: [T, GraphFreshnessInput, string][] = [];
|
||||
|
||||
for (const [key, input] of entries) {
|
||||
const graphCommitHash = input.graphCommitHash?.trim();
|
||||
if (!graphCommitHash) {
|
||||
results[key] = {
|
||||
status: "unknown",
|
||||
reason: "missing-graph-commit",
|
||||
...optionalAnalysisTime(input),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
comparableEntries.push([key, input, graphCommitHash]);
|
||||
}
|
||||
|
||||
if (comparableEntries.length === 0) return results;
|
||||
|
||||
let snapshot: ProjectGitSnapshot;
|
||||
try {
|
||||
snapshot = await createProjectGitSnapshot(projectDir);
|
||||
} catch (error) {
|
||||
const reason = unknownReason(error, "git-head-unavailable");
|
||||
for (const [key, input, graphCommitHash] of comparableEntries) {
|
||||
results[key] = {
|
||||
status: "unknown",
|
||||
reason,
|
||||
graphCommitHash,
|
||||
...optionalAnalysisTime(input),
|
||||
};
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
comparableEntries.map(async ([key, input, graphCommitHash]) => {
|
||||
results[key] = await evaluateGraphFreshness(
|
||||
snapshot,
|
||||
input,
|
||||
graphCommitHash,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe whether a persisted graph can still be trusted for the project.
|
||||
*
|
||||
* Unknown is intentionally distinct from fresh: if Git metadata cannot be
|
||||
* read, callers should warn softly rather than imply the graph is current.
|
||||
*/
|
||||
export async function getGraphFreshness(
|
||||
projectDir: string,
|
||||
input: GraphFreshnessInput,
|
||||
): GraphFreshnessResult {
|
||||
const graphCommitHash = input.graphCommitHash?.trim();
|
||||
if (!graphCommitHash) {
|
||||
return {
|
||||
status: "unknown",
|
||||
reason: "missing-graph-commit",
|
||||
lastAnalyzedAt: input.lastAnalyzedAt,
|
||||
};
|
||||
}
|
||||
|
||||
let headCommitHash: string;
|
||||
try {
|
||||
headCommitHash = runGit(projectDir, ["rev-parse", "HEAD"]);
|
||||
} catch {
|
||||
return {
|
||||
status: "unknown",
|
||||
reason: "git-head-unavailable",
|
||||
graphCommitHash,
|
||||
lastAnalyzedAt: input.lastAnalyzedAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (headCommitHash === graphCommitHash) {
|
||||
return {
|
||||
status: "fresh",
|
||||
graphCommitHash,
|
||||
headCommitHash,
|
||||
changedFileCount: 0,
|
||||
changedFiles: [],
|
||||
commitsBehind: 0,
|
||||
lastAnalyzedAt: input.lastAnalyzedAt,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const commitsBehind = Number.parseInt(
|
||||
runGit(projectDir, ["rev-list", "--count", `${graphCommitHash}..HEAD`]),
|
||||
10,
|
||||
);
|
||||
const changedFiles = parseChangedFiles(
|
||||
runGit(projectDir, ["diff", `${graphCommitHash}..HEAD`, "--name-only"]),
|
||||
);
|
||||
return {
|
||||
status: "stale",
|
||||
graphCommitHash,
|
||||
headCommitHash,
|
||||
changedFileCount: changedFiles.length,
|
||||
changedFiles,
|
||||
commitsBehind: Number.isFinite(commitsBehind) ? commitsBehind : 0,
|
||||
lastAnalyzedAt: input.lastAnalyzedAt,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
status: "unknown",
|
||||
reason: "graph-commit-unavailable",
|
||||
graphCommitHash,
|
||||
headCommitHash,
|
||||
lastAnalyzedAt: input.lastAnalyzedAt,
|
||||
};
|
||||
}
|
||||
): Promise<GraphFreshnessResult> {
|
||||
const results = await getGraphFreshnessBatch(projectDir, { graph: input });
|
||||
return results.graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new analysis results into an existing knowledge graph.
|
||||
*
|
||||
* 1. Remove old nodes belonging to changed files (matched by filePath).
|
||||
* 2. Remove old edges where the SOURCE or TARGET node belongs to a changed file.
|
||||
* 3. Add new nodes and edges.
|
||||
* 4. Update project.gitCommitHash and project.analyzedAt.
|
||||
* 5. Return the merged graph.
|
||||
*/
|
||||
export function mergeGraphUpdate(
|
||||
existingGraph: KnowledgeGraph,
|
||||
changedFilePaths: string[],
|
||||
newNodes: GraphNode[],
|
||||
newEdges: GraphEdge[],
|
||||
newCommitHash: string,
|
||||
): KnowledgeGraph {
|
||||
const changedSet = new Set(changedFilePaths);
|
||||
|
||||
// Collect IDs of nodes that belong to changed files (will be removed)
|
||||
const removedNodeIds = new Set(
|
||||
existingGraph.nodes
|
||||
.filter((node) => node.filePath !== undefined && changedSet.has(node.filePath))
|
||||
.map((node) => node.id),
|
||||
);
|
||||
|
||||
// Keep nodes that don't belong to changed files
|
||||
const retainedNodes = existingGraph.nodes.filter(
|
||||
(node) => !removedNodeIds.has(node.id),
|
||||
);
|
||||
|
||||
// Keep edges whose source or target node is not in the removed set
|
||||
const retainedEdges = existingGraph.edges.filter(
|
||||
(edge) => !removedNodeIds.has(edge.source) && !removedNodeIds.has(edge.target),
|
||||
);
|
||||
|
||||
return {
|
||||
...existingGraph,
|
||||
project: {
|
||||
...existingGraph.project,
|
||||
gitCommitHash: newCommitHash,
|
||||
analyzedAt: new Date().toISOString(),
|
||||
},
|
||||
nodes: [...retainedNodes, ...newNodes],
|
||||
edges: [...retainedEdges, ...newEdges],
|
||||
};
|
||||
}
|
||||
*
|
||||
* 1. Remove old nodes belonging to changed files (matched by filePath).
|
||||
* 2. Remove old edges where the SOURCE or TARGET node belongs to a changed file.
|
||||
* 3. Add new nodes and edges.
|
||||
* 4. Update project.gitCommitHash and project.analyzedAt.
|
||||
* 5. Return the merged graph.
|
||||
*/
|
||||
export function mergeGraphUpdate(
|
||||
existingGraph: KnowledgeGraph,
|
||||
changedFilePaths: string[],
|
||||
newNodes: GraphNode[],
|
||||
newEdges: GraphEdge[],
|
||||
newCommitHash: string,
|
||||
): KnowledgeGraph {
|
||||
const changedSet = new Set(changedFilePaths);
|
||||
|
||||
// Collect IDs of nodes that belong to changed files (will be removed)
|
||||
const removedNodeIds = new Set(
|
||||
existingGraph.nodes
|
||||
.filter((node) => node.filePath !== undefined && changedSet.has(node.filePath))
|
||||
.map((node) => node.id),
|
||||
);
|
||||
|
||||
// Keep nodes that don't belong to changed files
|
||||
const retainedNodes = existingGraph.nodes.filter(
|
||||
(node) => !removedNodeIds.has(node.id),
|
||||
);
|
||||
|
||||
// Keep edges whose source or target node is not in the removed set
|
||||
const retainedEdges = existingGraph.edges.filter(
|
||||
(edge) => !removedNodeIds.has(edge.source) && !removedNodeIds.has(edge.target),
|
||||
);
|
||||
|
||||
return {
|
||||
...existingGraph,
|
||||
project: {
|
||||
...existingGraph.project,
|
||||
gitCommitHash: newCommitHash,
|
||||
analyzedAt: new Date().toISOString(),
|
||||
},
|
||||
nodes: [...retainedNodes, ...newNodes],
|
||||
edges: [...retainedEdges, ...newEdges],
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,288 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isDashboardFreshnessReport,
|
||||
requestFreshnessReport,
|
||||
shouldRequestFreshness,
|
||||
startFreshnessRefresh,
|
||||
type DashboardFreshnessReport,
|
||||
} from "../freshness";
|
||||
|
||||
const freshReport: DashboardFreshnessReport = {
|
||||
graphs: {
|
||||
knowledge: {
|
||||
status: "fresh",
|
||||
graphCommitHash: "a".repeat(40),
|
||||
headCommitHash: "a".repeat(40),
|
||||
changedFileCount: 0,
|
||||
changedFiles: [],
|
||||
commitsBehind: 0,
|
||||
commitsAhead: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const staleReport: DashboardFreshnessReport = {
|
||||
graphs: {
|
||||
knowledge: {
|
||||
status: "stale",
|
||||
relation: "behind",
|
||||
graphCommitHash: "a".repeat(40),
|
||||
headCommitHash: "b".repeat(40),
|
||||
changedFileCount: 1,
|
||||
changedFiles: ["src/index.ts"],
|
||||
commitsBehind: 1,
|
||||
commitsAhead: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("isDashboardFreshnessReport", () => {
|
||||
it("accepts complete fresh, dirty, stale, and unknown graph results", () => {
|
||||
expect(isDashboardFreshnessReport(freshReport)).toBe(true);
|
||||
expect(
|
||||
isDashboardFreshnessReport({
|
||||
graphs: {
|
||||
knowledge: {
|
||||
status: "dirty",
|
||||
graphCommitHash: "a".repeat(40),
|
||||
headCommitHash: "a".repeat(40),
|
||||
changedFileCount: 1,
|
||||
changedFiles: ["src/dirty.ts"],
|
||||
commitsBehind: 0,
|
||||
commitsAhead: 0,
|
||||
},
|
||||
domain: staleReport.graphs.knowledge,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isDashboardFreshnessReport({
|
||||
graphs: {
|
||||
knowledge: {
|
||||
status: "unknown",
|
||||
reason: "git-command-timeout",
|
||||
graphCommitHash: "a".repeat(40),
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ graphs: { knowledge: { status: "fresh" } } },
|
||||
{ graphs: { knowledge: { status: "stale" } } },
|
||||
{ graphs: { knowledge: { status: "dirty" } } },
|
||||
{ graphs: { knowledge: { status: "unknown" } } },
|
||||
{ graphs: {} },
|
||||
{ graphs: { knowledge: freshReport.graphs.knowledge, domain: null } },
|
||||
{
|
||||
graphs: {
|
||||
knowledge: {
|
||||
...freshReport.graphs.knowledge,
|
||||
changedFileCount: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
graphs: {
|
||||
knowledge: {
|
||||
...staleReport.graphs.knowledge,
|
||||
relation: "sideways",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
graphs: {
|
||||
knowledge: {
|
||||
...staleReport.graphs.knowledge,
|
||||
changedFiles: [42],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
graphs: {
|
||||
knowledge: {
|
||||
status: "unknown",
|
||||
reason: "unexpected-reason",
|
||||
},
|
||||
},
|
||||
},
|
||||
])("rejects malformed payload %#", (payload) => {
|
||||
expect(isDashboardFreshnessReport(payload)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestFreshnessReport", () => {
|
||||
it("returns only a fully validated response", async () => {
|
||||
const signal = new AbortController().signal;
|
||||
const fetcher = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => freshReport,
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
requestFreshnessReport("/staleness.json", signal, fetcher),
|
||||
).resolves.toEqual(freshReport);
|
||||
expect(fetcher).toHaveBeenCalledWith("/staleness.json", {
|
||||
signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ok: false, json: async () => freshReport },
|
||||
{ ok: true, json: async () => ({ graphs: { knowledge: { status: "fresh" } } }) },
|
||||
])("rejects an unusable endpoint response %#", async (response) => {
|
||||
const fetcher = vi.fn(async () => response) as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
requestFreshnessReport("/staleness.json", new AbortController().signal, fetcher),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRequestFreshness", () => {
|
||||
it("checks local dashboards and demos with an explicit freshness URL", () => {
|
||||
expect(shouldRequestFreshness(false, undefined)).toBe(true);
|
||||
expect(
|
||||
shouldRequestFreshness(true, "https://example.test/staleness.json"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("skips static demos that cannot verify a live Git checkout", () => {
|
||||
expect(shouldRequestFreshness(true, undefined)).toBe(false);
|
||||
expect(shouldRequestFreshness(true, "")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startFreshnessRefresh", () => {
|
||||
it("loads initially and only reloads when the target receives focus", async () => {
|
||||
vi.useFakeTimers();
|
||||
const target = new EventTarget();
|
||||
const load = vi.fn(async () => freshReport);
|
||||
const onResult = vi.fn();
|
||||
|
||||
const stop = startFreshnessRefresh({ target, load, onResult });
|
||||
await flushPromises();
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
|
||||
target.dispatchEvent(new Event("focus"));
|
||||
await flushPromises();
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
expect(onResult).toHaveBeenLastCalledWith(freshReport);
|
||||
|
||||
stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("aborts an in-flight request before replacing it on focus", async () => {
|
||||
const target = new EventTarget();
|
||||
const pending: Array<{
|
||||
signal: AbortSignal;
|
||||
resolve: (report: DashboardFreshnessReport) => void;
|
||||
}> = [];
|
||||
const load = vi.fn(
|
||||
(signal: AbortSignal) =>
|
||||
new Promise<DashboardFreshnessReport>((resolve) => {
|
||||
pending.push({ signal, resolve });
|
||||
}),
|
||||
);
|
||||
const onResult = vi.fn();
|
||||
|
||||
const stop = startFreshnessRefresh({ target, load, onResult });
|
||||
expect(pending).toHaveLength(1);
|
||||
target.dispatchEvent(new Event("focus"));
|
||||
expect(pending).toHaveLength(2);
|
||||
expect(pending[0].signal.aborted).toBe(true);
|
||||
|
||||
pending[0].resolve(staleReport);
|
||||
pending[1].resolve(freshReport);
|
||||
await flushPromises();
|
||||
expect(onResult).toHaveBeenCalledTimes(1);
|
||||
expect(onResult).toHaveBeenCalledWith(freshReport);
|
||||
|
||||
stop();
|
||||
});
|
||||
|
||||
it("replaces stale and fresh states after successive focus events", async () => {
|
||||
const target = new EventTarget();
|
||||
const load = vi
|
||||
.fn<(signal: AbortSignal) => Promise<DashboardFreshnessReport>>()
|
||||
.mockResolvedValueOnce(staleReport)
|
||||
.mockResolvedValueOnce(freshReport)
|
||||
.mockResolvedValueOnce(staleReport);
|
||||
const onResult = vi.fn();
|
||||
|
||||
const stop = startFreshnessRefresh({ target, load, onResult });
|
||||
await flushPromises();
|
||||
expect(onResult).toHaveBeenLastCalledWith(staleReport);
|
||||
|
||||
target.dispatchEvent(new Event("focus"));
|
||||
await flushPromises();
|
||||
expect(onResult).toHaveBeenLastCalledWith(freshReport);
|
||||
|
||||
target.dispatchEvent(new Event("focus"));
|
||||
await flushPromises();
|
||||
expect(onResult).toHaveBeenLastCalledWith(staleReport);
|
||||
expect(onResult).toHaveBeenCalledTimes(3);
|
||||
|
||||
stop();
|
||||
});
|
||||
|
||||
it("publishes an explicit unknown result when loading fails", async () => {
|
||||
const onResult = vi.fn();
|
||||
|
||||
const stop = startFreshnessRefresh({
|
||||
target: new EventTarget(),
|
||||
load: vi.fn(async () => {
|
||||
throw new Error("network unavailable");
|
||||
}),
|
||||
onResult,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
graphs: {
|
||||
knowledge: {
|
||||
status: "unknown",
|
||||
reason: "freshness-request-failed",
|
||||
},
|
||||
},
|
||||
});
|
||||
stop();
|
||||
});
|
||||
|
||||
it("removes the focus listener and aborts on cleanup", async () => {
|
||||
const target = new EventTarget();
|
||||
let initialSignal: AbortSignal | undefined;
|
||||
const load = vi.fn(
|
||||
(signal: AbortSignal) =>
|
||||
new Promise<DashboardFreshnessReport>(() => {
|
||||
initialSignal = signal;
|
||||
}),
|
||||
);
|
||||
|
||||
const stop = startFreshnessRefresh({
|
||||
target,
|
||||
load,
|
||||
onResult: vi.fn(),
|
||||
});
|
||||
stop();
|
||||
|
||||
expect(initialSignal?.aborted).toBe(true);
|
||||
target.dispatchEvent(new Event("focus"));
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+245
-80
@@ -1,98 +1,263 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { execFileSync } from "child_process";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import {
|
||||
createServer,
|
||||
request as httpRequest,
|
||||
type Server,
|
||||
} from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createDashboardDataMiddleware } from "../../vite.config";
|
||||
import { isDashboardFreshnessReport } from "../freshness";
|
||||
|
||||
vi.mock("child_process", () => ({
|
||||
execFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
const execFileSyncMock = vi.mocked(execFileSync);
|
||||
|
||||
async function loadHelpers() {
|
||||
return import("../../vite.config");
|
||||
interface HttpResult {
|
||||
status: number;
|
||||
body: unknown;
|
||||
cacheControl: string | null;
|
||||
}
|
||||
|
||||
describe("dashboard graph freshness endpoint helper", () => {
|
||||
let originalGraphDir: string | undefined;
|
||||
let tempProject: string;
|
||||
let originalGraphDir: string | undefined;
|
||||
let tempProject: string;
|
||||
let baselineCommit: string;
|
||||
const servers: Server[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
execFileSyncMock.mockReset();
|
||||
originalGraphDir = process.env.GRAPH_DIR;
|
||||
tempProject = fs.mkdtempSync(path.join(os.tmpdir(), "ua-dashboard-"));
|
||||
process.env.GRAPH_DIR = tempProject;
|
||||
});
|
||||
function git(...args: string[]): string {
|
||||
return execFileSync("git", args, {
|
||||
cwd: tempProject,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (originalGraphDir === undefined) {
|
||||
delete process.env.GRAPH_DIR;
|
||||
} else {
|
||||
process.env.GRAPH_DIR = originalGraphDir;
|
||||
}
|
||||
fs.rmSync(tempProject, { recursive: true, force: true });
|
||||
});
|
||||
function writeProjectFile(relativePath: string, contents: string): void {
|
||||
const filePath = path.join(tempProject, ...relativePath.split("/"));
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, contents, "utf8");
|
||||
}
|
||||
|
||||
it("reports a stale graph from knowledge graph commit metadata", async () => {
|
||||
const graphDir = path.join(tempProject, ".understand-anything");
|
||||
fs.mkdirSync(graphDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(graphDir, "knowledge-graph.json"),
|
||||
JSON.stringify({
|
||||
project: {
|
||||
gitCommitHash: "graph-commit",
|
||||
analyzedAt: "2026-07-08T08:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
function commitAll(message: string): string {
|
||||
git("add", "--all");
|
||||
git("commit", "-m", message);
|
||||
return git("rev-parse", "HEAD");
|
||||
}
|
||||
|
||||
execFileSyncMock.mockImplementation((_cmd, args) => {
|
||||
const joinedArgs = Array.isArray(args) ? args.join(" ") : "";
|
||||
if (joinedArgs === "rev-parse HEAD") return "head-commit\n";
|
||||
if (joinedArgs === "rev-list --count graph-commit..HEAD") return "3\n";
|
||||
if (joinedArgs === "diff graph-commit..HEAD --name-only") {
|
||||
return "src/auth.ts\nsrc/routes.ts\n";
|
||||
}
|
||||
throw new Error(`Unexpected git command: ${joinedArgs}`);
|
||||
});
|
||||
function graphDirectory(): string {
|
||||
const directory = path.join(tempProject, ".understand-anything");
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
return directory;
|
||||
}
|
||||
|
||||
const { readGraphFreshness } = await loadHelpers();
|
||||
|
||||
expect(readGraphFreshness()).toEqual({
|
||||
statusCode: 200,
|
||||
payload: {
|
||||
status: "stale",
|
||||
graphCommitHash: "graph-commit",
|
||||
headCommitHash: "head-commit",
|
||||
changedFileCount: 2,
|
||||
changedFiles: ["src/auth.ts", "src/routes.ts"],
|
||||
commitsBehind: 3,
|
||||
lastAnalyzedAt: "2026-07-08T08:00:00.000Z",
|
||||
function writeGraph(
|
||||
fileName: "knowledge-graph.json" | "domain-graph.json",
|
||||
commitHash: string,
|
||||
): void {
|
||||
fs.writeFileSync(
|
||||
path.join(graphDirectory(), fileName),
|
||||
JSON.stringify({
|
||||
project: {
|
||||
gitCommitHash: commitHash,
|
||||
analyzedAt: "2026-07-10T00:00:00.000Z",
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function startDashboardServer(accessToken: string): Promise<string> {
|
||||
const middleware = createDashboardDataMiddleware(accessToken);
|
||||
const server = createServer((req, res) => {
|
||||
middleware(req, res, () => {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
servers.push(server);
|
||||
|
||||
it("returns a 404 payload when no knowledge graph exists", async () => {
|
||||
const { readGraphFreshness } = await loadHelpers();
|
||||
|
||||
expect(readGraphFreshness()).toEqual({
|
||||
statusCode: 404,
|
||||
payload: { error: "No knowledge graph found. Run /understand first." },
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Dashboard test server did not expose a TCP port");
|
||||
}
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
it("returns a 500 payload when the knowledge graph cannot be parsed", async () => {
|
||||
const graphDir = path.join(tempProject, ".understand-anything");
|
||||
fs.mkdirSync(graphDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(graphDir, "knowledge-graph.json"), "{not-json");
|
||||
function requestJson(baseUrl: string, requestPath: string): Promise<HttpResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = httpRequest(
|
||||
`${baseUrl}${requestPath}`,
|
||||
{ agent: false },
|
||||
(response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
response.on("end", () => {
|
||||
const text = Buffer.concat(chunks).toString("utf8");
|
||||
resolve({
|
||||
status: response.statusCode ?? 0,
|
||||
body: text.length > 0 ? JSON.parse(text) : null,
|
||||
cacheControl: response.headers["cache-control"] ?? null,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
request.once("error", reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
const { readGraphFreshness } = await loadHelpers();
|
||||
beforeEach(() => {
|
||||
originalGraphDir = process.env.GRAPH_DIR;
|
||||
tempProject = fs.mkdtempSync(path.join(os.tmpdir(), "ua-dashboard-"));
|
||||
process.env.GRAPH_DIR = tempProject;
|
||||
|
||||
expect(readGraphFreshness()).toEqual({
|
||||
statusCode: 500,
|
||||
payload: { error: "Failed to read graph file" },
|
||||
});
|
||||
git("init");
|
||||
git("config", "user.email", "dashboard-tests@example.com");
|
||||
git("config", "user.name", "Dashboard Tests");
|
||||
writeProjectFile("src/index.ts", "export const value = 1;\n");
|
||||
baselineCommit = commitAll("baseline");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const server of servers.splice(0)) {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
if (originalGraphDir === undefined) {
|
||||
delete process.env.GRAPH_DIR;
|
||||
} else {
|
||||
process.env.GRAPH_DIR = originalGraphDir;
|
||||
}
|
||||
fs.rmSync(tempProject, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100,
|
||||
});
|
||||
});
|
||||
|
||||
describe(
|
||||
"dashboard graph freshness endpoint",
|
||||
{ timeout: 15_000 },
|
||||
() => {
|
||||
it("rejects an unauthorized request before serving freshness data", async () => {
|
||||
writeGraph("knowledge-graph.json", baselineCommit);
|
||||
const baseUrl = await startDashboardServer("test-token");
|
||||
|
||||
await expect(requestJson(baseUrl, "/staleness.json")).resolves.toEqual({
|
||||
status: 403,
|
||||
body: { error: "Forbidden: missing or invalid token" },
|
||||
cacheControl: "no-store",
|
||||
});
|
||||
});
|
||||
|
||||
it("serves a knowledge-only report to an authorized request", async () => {
|
||||
writeGraph("knowledge-graph.json", baselineCommit);
|
||||
const baseUrl = await startDashboardServer("test-token");
|
||||
|
||||
const response = await requestJson(
|
||||
baseUrl,
|
||||
"/staleness.json?token=test-token",
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.cacheControl).toBe("no-store");
|
||||
expect(isDashboardFreshnessReport(response.body)).toBe(true);
|
||||
expect(response.cacheControl).toBe("no-store");
|
||||
expect(response.body).toMatchObject({
|
||||
graphs: {
|
||||
knowledge: {
|
||||
status: "fresh",
|
||||
graphCommitHash: baselineCommit,
|
||||
headCommitHash: baselineCommit,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
(response.body as { graphs: Record<string, unknown> }).graphs,
|
||||
).not.toHaveProperty("domain");
|
||||
});
|
||||
|
||||
it("reports knowledge and domain graph freshness independently", async () => {
|
||||
writeProjectFile("src/index.ts", "export const value = 2;\n");
|
||||
const headCommit = commitAll("project change");
|
||||
writeGraph("knowledge-graph.json", headCommit);
|
||||
writeGraph("domain-graph.json", baselineCommit);
|
||||
const baseUrl = await startDashboardServer("test-token");
|
||||
|
||||
const response = await requestJson(
|
||||
baseUrl,
|
||||
"/staleness.json?token=test-token",
|
||||
);
|
||||
expect(isDashboardFreshnessReport(response.body)).toBe(true);
|
||||
expect(response).toMatchObject({
|
||||
status: 200,
|
||||
body: {
|
||||
graphs: {
|
||||
knowledge: {
|
||||
status: "fresh",
|
||||
graphCommitHash: headCommit,
|
||||
},
|
||||
domain: {
|
||||
status: "stale",
|
||||
relation: "behind",
|
||||
graphCommitHash: baselineCommit,
|
||||
headCommitHash: headCommit,
|
||||
commitsBehind: 1,
|
||||
changedFiles: ["src/index.ts"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 404 when the required knowledge graph is missing", async () => {
|
||||
const baseUrl = await startDashboardServer("test-token");
|
||||
|
||||
await expect(
|
||||
requestJson(baseUrl, "/staleness.json?token=test-token"),
|
||||
).resolves.toEqual({
|
||||
status: 404,
|
||||
body: { error: "No knowledge graph found. Run /understand first." },
|
||||
cacheControl: "no-store",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a safe 500 response for invalid knowledge graph JSON", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(graphDirectory(), "knowledge-graph.json"),
|
||||
"{not-json",
|
||||
"utf8",
|
||||
);
|
||||
const baseUrl = await startDashboardServer("test-token");
|
||||
|
||||
await expect(
|
||||
requestJson(baseUrl, "/staleness.json?token=test-token"),
|
||||
).resolves.toEqual({
|
||||
status: 500,
|
||||
body: { error: "Failed to read graph file" },
|
||||
cacheControl: "no-store",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a safe 500 response for invalid optional domain graph JSON", async () => {
|
||||
writeGraph("knowledge-graph.json", baselineCommit);
|
||||
fs.writeFileSync(
|
||||
path.join(graphDirectory(), "domain-graph.json"),
|
||||
"{not-json",
|
||||
"utf8",
|
||||
);
|
||||
const baseUrl = await startDashboardServer("test-token");
|
||||
|
||||
await expect(
|
||||
requestJson(baseUrl, "/staleness.json?token=test-token"),
|
||||
).resolves.toEqual({
|
||||
status: 500,
|
||||
body: { error: "Failed to read graph file" },
|
||||
cacheControl: "no-store",
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,136 +1,137 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import type { GraphIssue } from "@understand-anything/core/schema";
|
||||
import { useDashboardStore } from "../store";
|
||||
import { useI18n } from "../contexts/I18nContext";
|
||||
import GraphView from "./GraphView";
|
||||
import DomainGraphView from "./DomainGraphView";
|
||||
import KnowledgeGraphView from "./KnowledgeGraphView";
|
||||
import SearchBar from "./SearchBar";
|
||||
import NodeInfo from "./NodeInfo";
|
||||
import ProjectOverview from "./ProjectOverview";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import type { GraphIssue } from "@understand-anything/core/schema";
|
||||
import { useDashboardStore } from "../store";
|
||||
import { useI18n } from "../contexts/I18nContext";
|
||||
import GraphView from "./GraphView";
|
||||
import DomainGraphView from "./DomainGraphView";
|
||||
import KnowledgeGraphView from "./KnowledgeGraphView";
|
||||
import SearchBar from "./SearchBar";
|
||||
import NodeInfo from "./NodeInfo";
|
||||
import ProjectOverview from "./ProjectOverview";
|
||||
import FileExplorer from "./FileExplorer";
|
||||
import WarningBanner from "./WarningBanner";
|
||||
import StalenessBanner, { type GraphFreshnessResult } from "./StalenessBanner";
|
||||
import StalenessBanner from "./StalenessBanner";
|
||||
import type { DashboardFreshnessReport } from "../freshness";
|
||||
import MobileBottomNav from "./MobileBottomNav";
|
||||
import type { MobileTab } from "./MobileBottomNav";
|
||||
import MobileDrawer from "./MobileDrawer";
|
||||
|
||||
const CodeViewer = lazy(() => import("./CodeViewer"));
|
||||
const LearnPanel = lazy(() => import("./LearnPanel"));
|
||||
const PathFinderModal = lazy(() => import("./PathFinderModal"));
|
||||
const KeyboardShortcutsHelp = lazy(() => import("./KeyboardShortcutsHelp"));
|
||||
|
||||
interface Props {
|
||||
accessToken: string;
|
||||
showKeyboardHelp: boolean;
|
||||
setShowKeyboardHelp: (value: boolean) => void;
|
||||
import type { MobileTab } from "./MobileBottomNav";
|
||||
import MobileDrawer from "./MobileDrawer";
|
||||
|
||||
const CodeViewer = lazy(() => import("./CodeViewer"));
|
||||
const LearnPanel = lazy(() => import("./LearnPanel"));
|
||||
const PathFinderModal = lazy(() => import("./PathFinderModal"));
|
||||
const KeyboardShortcutsHelp = lazy(() => import("./KeyboardShortcutsHelp"));
|
||||
|
||||
interface Props {
|
||||
accessToken: string;
|
||||
showKeyboardHelp: boolean;
|
||||
setShowKeyboardHelp: (value: boolean) => void;
|
||||
loadError: string | null;
|
||||
allIssues: GraphIssue[];
|
||||
graphFreshness: GraphFreshnessResult | null;
|
||||
graphFreshness: DashboardFreshnessReport | null;
|
||||
shortcuts: import("../hooks/useKeyboardShortcuts").KeyboardShortcut[];
|
||||
}
|
||||
|
||||
export default function MobileLayout({
|
||||
accessToken,
|
||||
showKeyboardHelp,
|
||||
|
||||
export default function MobileLayout({
|
||||
accessToken,
|
||||
showKeyboardHelp,
|
||||
setShowKeyboardHelp,
|
||||
loadError,
|
||||
allIssues,
|
||||
graphFreshness,
|
||||
shortcuts,
|
||||
}: Props) {
|
||||
const graph = useDashboardStore((s) => s.graph);
|
||||
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
|
||||
const tourActive = useDashboardStore((s) => s.tourActive);
|
||||
const persona = useDashboardStore((s) => s.persona);
|
||||
const viewMode = useDashboardStore((s) => s.viewMode);
|
||||
const domainGraph = useDashboardStore((s) => s.domainGraph);
|
||||
const codeViewerOpen = useDashboardStore((s) => s.codeViewerOpen);
|
||||
const closeCodeViewer = useDashboardStore((s) => s.closeCodeViewer);
|
||||
const pathFinderOpen = useDashboardStore((s) => s.pathFinderOpen);
|
||||
const togglePathFinder = useDashboardStore((s) => s.togglePathFinder);
|
||||
const { t } = useI18n();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<MobileTab>("graph");
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
// Auto-pivot to Info when a node is selected — keeps feedback visible
|
||||
// on a small screen where graph and sidebar can't coexist
|
||||
useEffect(() => {
|
||||
if (selectedNodeId) setActiveTab("info");
|
||||
}, [selectedNodeId]);
|
||||
|
||||
// When a code viewer opens (e.g. from the Files tab) keep focus there
|
||||
useEffect(() => {
|
||||
if (codeViewerOpen) setSearchOpen(false);
|
||||
}, [codeViewerOpen]);
|
||||
|
||||
const isLearnMode = tourActive || persona === "junior";
|
||||
const infoContent = (
|
||||
<>
|
||||
{selectedNodeId && <NodeInfo />}
|
||||
{isLearnMode && (
|
||||
<Suspense fallback={null}>
|
||||
<LearnPanel />
|
||||
</Suspense>
|
||||
)}
|
||||
{!selectedNodeId && !isLearnMode && <ProjectOverview />}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex flex-col bg-root text-text-primary noise-overlay">
|
||||
{/* Top bar */}
|
||||
<header className="flex items-center gap-2 px-3 h-12 shrink-0 bg-surface border-b border-border-subtle">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg text-text-secondary hover:text-text-primary hover:bg-elevated transition-colors -ml-1"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h1 className="font-heading text-base flex-1 min-w-0 truncate text-center text-text-primary tracking-wide">
|
||||
{graph?.project.name ?? t.common.appName}
|
||||
</h1>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchOpen((prev) => !prev)}
|
||||
className={`w-9 h-9 flex items-center justify-center rounded-lg transition-colors -mr-1 ${
|
||||
searchOpen
|
||||
? "text-accent bg-accent/15"
|
||||
: "text-text-secondary hover:text-text-primary hover:bg-elevated"
|
||||
}`}
|
||||
aria-label={searchOpen ? "Hide search" : "Show search"}
|
||||
aria-pressed={searchOpen}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
const graph = useDashboardStore((s) => s.graph);
|
||||
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
|
||||
const tourActive = useDashboardStore((s) => s.tourActive);
|
||||
const persona = useDashboardStore((s) => s.persona);
|
||||
const viewMode = useDashboardStore((s) => s.viewMode);
|
||||
const domainGraph = useDashboardStore((s) => s.domainGraph);
|
||||
const codeViewerOpen = useDashboardStore((s) => s.codeViewerOpen);
|
||||
const closeCodeViewer = useDashboardStore((s) => s.closeCodeViewer);
|
||||
const pathFinderOpen = useDashboardStore((s) => s.pathFinderOpen);
|
||||
const togglePathFinder = useDashboardStore((s) => s.togglePathFinder);
|
||||
const { t } = useI18n();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<MobileTab>("graph");
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
// Auto-pivot to Info when a node is selected — keeps feedback visible
|
||||
// on a small screen where graph and sidebar can't coexist
|
||||
useEffect(() => {
|
||||
if (selectedNodeId) setActiveTab("info");
|
||||
}, [selectedNodeId]);
|
||||
|
||||
// When a code viewer opens (e.g. from the Files tab) keep focus there
|
||||
useEffect(() => {
|
||||
if (codeViewerOpen) setSearchOpen(false);
|
||||
}, [codeViewerOpen]);
|
||||
|
||||
const isLearnMode = tourActive || persona === "junior";
|
||||
const infoContent = (
|
||||
<>
|
||||
{selectedNodeId && <NodeInfo />}
|
||||
{isLearnMode && (
|
||||
<Suspense fallback={null}>
|
||||
<LearnPanel />
|
||||
</Suspense>
|
||||
)}
|
||||
{!selectedNodeId && !isLearnMode && <ProjectOverview />}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex flex-col bg-root text-text-primary noise-overlay">
|
||||
{/* Top bar */}
|
||||
<header className="flex items-center gap-2 px-3 h-12 shrink-0 bg-surface border-b border-border-subtle">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg text-text-secondary hover:text-text-primary hover:bg-elevated transition-colors -ml-1"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h1 className="font-heading text-base flex-1 min-w-0 truncate text-center text-text-primary tracking-wide">
|
||||
{graph?.project.name ?? t.common.appName}
|
||||
</h1>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchOpen((prev) => !prev)}
|
||||
className={`w-9 h-9 flex items-center justify-center rounded-lg transition-colors -mr-1 ${
|
||||
searchOpen
|
||||
? "text-accent bg-accent/15"
|
||||
: "text-text-secondary hover:text-text-primary hover:bg-elevated"
|
||||
}`}
|
||||
aria-label={searchOpen ? "Hide search" : "Show search"}
|
||||
aria-pressed={searchOpen}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Search (collapsible) */}
|
||||
{searchOpen && <SearchBar />}
|
||||
|
||||
@@ -139,101 +140,101 @@ export default function MobileLayout({
|
||||
|
||||
{/* Validation warnings */}
|
||||
{allIssues.length > 0 && !loadError && <WarningBanner issues={allIssues} />}
|
||||
|
||||
{/* Load error */}
|
||||
{loadError && (
|
||||
<div className="px-4 py-3 bg-red-900/30 border-b border-red-700 text-red-200 text-sm">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabbed content — all panes stay mounted to preserve layout/state.
|
||||
Inactive panes are kept in the layout (not display:none) so that
|
||||
ReactFlow keeps real dimensions and pinch/pan don't collapse on
|
||||
tab switch. */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<div
|
||||
className={`absolute inset-0 ${
|
||||
activeTab === "graph" ? "" : "invisible pointer-events-none"
|
||||
}`}
|
||||
aria-hidden={activeTab !== "graph"}
|
||||
>
|
||||
{viewMode === "knowledge" ? (
|
||||
<KnowledgeGraphView />
|
||||
) : viewMode === "domain" && domainGraph ? (
|
||||
<DomainGraphView />
|
||||
) : (
|
||||
<GraphView />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`absolute inset-0 overflow-auto bg-surface ${
|
||||
activeTab === "info" ? "" : "invisible pointer-events-none"
|
||||
}`}
|
||||
aria-hidden={activeTab !== "info"}
|
||||
>
|
||||
{infoContent}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`absolute inset-0 overflow-auto bg-surface ${
|
||||
activeTab === "files" ? "" : "invisible pointer-events-none"
|
||||
}`}
|
||||
aria-hidden={activeTab !== "files"}
|
||||
>
|
||||
<FileExplorer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom tab nav */}
|
||||
<MobileBottomNav activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
{/* Drawer */}
|
||||
<MobileDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onTogglePathFinder={togglePathFinder}
|
||||
onShowKeyboardHelp={() => setShowKeyboardHelp(true)}
|
||||
/>
|
||||
|
||||
{/* Code viewer — always fullscreen on mobile */}
|
||||
{codeViewerOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex bg-black/70 backdrop-blur-sm p-2 sm:p-4"
|
||||
onMouseDown={closeCodeViewer}
|
||||
>
|
||||
<div
|
||||
className="flex-1 rounded-lg border border-border-medium bg-surface shadow-2xl overflow-hidden"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<CodeViewer
|
||||
accessToken={accessToken}
|
||||
presentation="modal"
|
||||
onClose={closeCodeViewer}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard help (mobile reads it as a quick reference too) */}
|
||||
{showKeyboardHelp && (
|
||||
<Suspense fallback={null}>
|
||||
<KeyboardShortcutsHelp
|
||||
shortcuts={shortcuts}
|
||||
onClose={() => setShowKeyboardHelp(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Path finder */}
|
||||
{pathFinderOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<PathFinderModal isOpen={pathFinderOpen} onClose={togglePathFinder} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Load error */}
|
||||
{loadError && (
|
||||
<div className="px-4 py-3 bg-red-900/30 border-b border-red-700 text-red-200 text-sm">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabbed content — all panes stay mounted to preserve layout/state.
|
||||
Inactive panes are kept in the layout (not display:none) so that
|
||||
ReactFlow keeps real dimensions and pinch/pan don't collapse on
|
||||
tab switch. */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<div
|
||||
className={`absolute inset-0 ${
|
||||
activeTab === "graph" ? "" : "invisible pointer-events-none"
|
||||
}`}
|
||||
aria-hidden={activeTab !== "graph"}
|
||||
>
|
||||
{viewMode === "knowledge" ? (
|
||||
<KnowledgeGraphView />
|
||||
) : viewMode === "domain" && domainGraph ? (
|
||||
<DomainGraphView />
|
||||
) : (
|
||||
<GraphView />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`absolute inset-0 overflow-auto bg-surface ${
|
||||
activeTab === "info" ? "" : "invisible pointer-events-none"
|
||||
}`}
|
||||
aria-hidden={activeTab !== "info"}
|
||||
>
|
||||
{infoContent}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`absolute inset-0 overflow-auto bg-surface ${
|
||||
activeTab === "files" ? "" : "invisible pointer-events-none"
|
||||
}`}
|
||||
aria-hidden={activeTab !== "files"}
|
||||
>
|
||||
<FileExplorer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom tab nav */}
|
||||
<MobileBottomNav activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
{/* Drawer */}
|
||||
<MobileDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onTogglePathFinder={togglePathFinder}
|
||||
onShowKeyboardHelp={() => setShowKeyboardHelp(true)}
|
||||
/>
|
||||
|
||||
{/* Code viewer — always fullscreen on mobile */}
|
||||
{codeViewerOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex bg-black/70 backdrop-blur-sm p-2 sm:p-4"
|
||||
onMouseDown={closeCodeViewer}
|
||||
>
|
||||
<div
|
||||
className="flex-1 rounded-lg border border-border-medium bg-surface shadow-2xl overflow-hidden"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<CodeViewer
|
||||
accessToken={accessToken}
|
||||
presentation="modal"
|
||||
onClose={closeCodeViewer}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard help (mobile reads it as a quick reference too) */}
|
||||
{showKeyboardHelp && (
|
||||
<Suspense fallback={null}>
|
||||
<KeyboardShortcutsHelp
|
||||
shortcuts={shortcuts}
|
||||
onClose={() => setShowKeyboardHelp(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Path finder */}
|
||||
{pathFinderOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<PathFinderModal isOpen={pathFinderOpen} onClose={togglePathFinder} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,12 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export type GraphFreshnessResult =
|
||||
| {
|
||||
status: "fresh";
|
||||
graphCommitHash: string;
|
||||
headCommitHash: string;
|
||||
changedFileCount: 0;
|
||||
changedFiles: [];
|
||||
commitsBehind: 0;
|
||||
lastAnalyzedAt?: string;
|
||||
}
|
||||
| {
|
||||
status: "stale";
|
||||
graphCommitHash: string;
|
||||
headCommitHash: string;
|
||||
changedFileCount: number;
|
||||
changedFiles: string[];
|
||||
commitsBehind: number;
|
||||
lastAnalyzedAt?: string;
|
||||
}
|
||||
| {
|
||||
status: "unknown";
|
||||
reason:
|
||||
| "missing-graph-commit"
|
||||
| "git-head-unavailable"
|
||||
| "graph-commit-unavailable";
|
||||
graphCommitHash?: string;
|
||||
headCommitHash?: string;
|
||||
lastAnalyzedAt?: string;
|
||||
};
|
||||
type UnknownGraphFreshnessReason = Extract<
|
||||
import type {
|
||||
DashboardFreshnessReport,
|
||||
GraphFreshnessResult,
|
||||
{ status: "unknown" }
|
||||
>["reason"];
|
||||
GraphFreshnessUnknownReason,
|
||||
} from "../freshness";
|
||||
|
||||
interface StalenessBannerProps {
|
||||
freshness: GraphFreshnessResult | null;
|
||||
freshness: DashboardFreshnessReport | null;
|
||||
}
|
||||
|
||||
interface FreshnessBannerContent {
|
||||
@@ -49,42 +20,143 @@ function plural(count: number, singular: string, pluralForm = `${singular}s`) {
|
||||
return `${count} ${count === 1 ? singular : pluralForm}`;
|
||||
}
|
||||
|
||||
export function isGraphFreshnessResult(data: unknown): data is GraphFreshnessResult {
|
||||
if (!data || typeof data !== "object") return false;
|
||||
const status = (data as { status?: unknown }).status;
|
||||
return status === "fresh" || status === "stale" || status === "unknown";
|
||||
type GraphName = "knowledge" | "domain";
|
||||
type GraphEntry = { name: GraphName; result: GraphFreshnessResult };
|
||||
|
||||
const RISK_RANK: Record<GraphFreshnessResult["status"], number> = {
|
||||
fresh: 0,
|
||||
unknown: 1,
|
||||
dirty: 2,
|
||||
stale: 3,
|
||||
};
|
||||
|
||||
const unknownSummary: Record<GraphFreshnessUnknownReason, string> = {
|
||||
"missing-graph-commit": "does not include a Git commit hash to compare with HEAD",
|
||||
"git-head-unavailable": "could not be compared because the dashboard could not read Git HEAD",
|
||||
"graph-commit-unavailable": "references a commit that is not available in this checkout",
|
||||
"git-command-timeout": "could not be checked because Git freshness commands timed out",
|
||||
"freshness-request-failed": "could not be refreshed because the freshness request failed",
|
||||
};
|
||||
|
||||
function graphLabel(name: GraphName): string {
|
||||
return `${name} graph`;
|
||||
}
|
||||
|
||||
function titleSubject(entries: GraphEntry[]): string {
|
||||
if (entries.length === 2) return "Knowledge and domain graphs";
|
||||
return entries[0].name === "knowledge" ? "Knowledge graph" : "Domain graph";
|
||||
}
|
||||
|
||||
function changedFilesSentence(count: number): string {
|
||||
return `${plural(count, "file")} ${count === 1 ? "has" : "have"} changed since analysis.`;
|
||||
}
|
||||
|
||||
function staleSummary(entry: GraphEntry): string {
|
||||
if (entry.result.status !== "stale") return "";
|
||||
const subject = `The ${graphLabel(entry.name)}`;
|
||||
const fileSummary = changedFilesSentence(entry.result.changedFileCount);
|
||||
|
||||
if (entry.result.relation === "behind") {
|
||||
return `${subject} is ${plural(
|
||||
entry.result.commitsBehind,
|
||||
"project commit",
|
||||
)} behind HEAD; ${fileSummary}`;
|
||||
}
|
||||
if (entry.result.relation === "ahead") {
|
||||
return `${subject} comes from a newer project history than HEAD; ${fileSummary}`;
|
||||
}
|
||||
return `${subject} and HEAD come from different project histories; ${fileSummary}`;
|
||||
}
|
||||
|
||||
function dirtySummary(entry: GraphEntry): string {
|
||||
if (entry.result.status !== "dirty") return "";
|
||||
const fileCount = entry.result.changedFileCount;
|
||||
return `${plural(fileCount, "working-tree file")} ${
|
||||
fileCount === 1 ? "has" : "have"
|
||||
} changed and ${fileCount === 1 ? "is" : "are"} not represented by the ${graphLabel(
|
||||
entry.name,
|
||||
)}'s commit metadata.`;
|
||||
}
|
||||
|
||||
function unknownEntrySummary(entry: GraphEntry): string {
|
||||
if (entry.result.status !== "unknown") return "";
|
||||
if (entry.result.reason === "freshness-request-failed") {
|
||||
return "The dashboard could not refresh graph freshness data.";
|
||||
}
|
||||
return `The ${graphLabel(entry.name)} ${unknownSummary[entry.result.reason]}.`;
|
||||
}
|
||||
|
||||
function refreshAction(entries: GraphEntry[]): string {
|
||||
const hasKnowledge = entries.some((entry) => entry.name === "knowledge");
|
||||
const hasDomain = entries.some((entry) => entry.name === "domain");
|
||||
const commands = hasKnowledge && hasDomain
|
||||
? "/understand and /understand-domain"
|
||||
: hasDomain
|
||||
? "/understand-domain"
|
||||
: "/understand";
|
||||
return `Run ${commands} to refresh ${entries.length === 1 ? "it" : "them"} before relying on impact or onboarding answers.`;
|
||||
}
|
||||
|
||||
export function buildFreshnessBanner(
|
||||
freshness: GraphFreshnessResult | null,
|
||||
freshness: DashboardFreshnessReport | null,
|
||||
): FreshnessBannerContent | null {
|
||||
if (!freshness || freshness.status === "fresh") return null;
|
||||
if (!freshness) return null;
|
||||
const entries: GraphEntry[] = [
|
||||
{ name: "knowledge", result: freshness.graphs.knowledge },
|
||||
];
|
||||
if (freshness.graphs.domain) {
|
||||
entries.push({ name: "domain", result: freshness.graphs.domain });
|
||||
}
|
||||
|
||||
if (freshness.status === "stale") {
|
||||
const highestRisk = Math.max(
|
||||
...entries.map((entry) => RISK_RANK[entry.result.status]),
|
||||
);
|
||||
if (highestRisk === RISK_RANK.fresh) return null;
|
||||
|
||||
const affected = entries.filter(
|
||||
(entry) => RISK_RANK[entry.result.status] === highestRisk,
|
||||
);
|
||||
const status = affected[0].result.status;
|
||||
const changedFiles = [
|
||||
...new Set(
|
||||
affected.flatMap((entry) =>
|
||||
"changedFiles" in entry.result ? entry.result.changedFiles : [],
|
||||
),
|
||||
),
|
||||
].sort();
|
||||
|
||||
if (status === "stale") {
|
||||
return {
|
||||
title: "Knowledge graph may be stale",
|
||||
summary: `The graph was generated ${plural(
|
||||
freshness.commitsBehind,
|
||||
"commit",
|
||||
)} before HEAD and ${plural(
|
||||
freshness.changedFileCount,
|
||||
"file",
|
||||
)} have changed since analysis.`,
|
||||
action: "Run /understand to refresh it before relying on impact or onboarding answers.",
|
||||
changedFiles: freshness.changedFiles,
|
||||
title: `${titleSubject(affected)} may be stale`,
|
||||
summary: affected.map(staleSummary).join(" "),
|
||||
action: refreshAction(affected),
|
||||
changedFiles,
|
||||
};
|
||||
}
|
||||
|
||||
const summaryByReason: Record<UnknownGraphFreshnessReason, string> = {
|
||||
"missing-graph-commit": "The graph does not include a git commit hash to compare with HEAD.",
|
||||
"git-head-unavailable": "The dashboard could not read git HEAD for this project.",
|
||||
"graph-commit-unavailable": "The graph commit is not available in this checkout.",
|
||||
};
|
||||
if (status === "dirty") {
|
||||
return {
|
||||
title: `${titleSubject(affected)} ${
|
||||
affected.length === 1 ? "has" : "have"
|
||||
} working-tree changes`,
|
||||
summary: affected.map(dirtySummary).join(" "),
|
||||
action: refreshAction(affected),
|
||||
changedFiles,
|
||||
};
|
||||
}
|
||||
|
||||
const requestFailed = affected.some(
|
||||
(entry) =>
|
||||
entry.result.status === "unknown" &&
|
||||
entry.result.reason === "freshness-request-failed",
|
||||
);
|
||||
|
||||
return {
|
||||
title: "Graph freshness could not be verified",
|
||||
summary: summaryByReason[freshness.reason],
|
||||
action: "Run /understand to regenerate the graph with current git metadata.",
|
||||
title: `${titleSubject(affected)} freshness could not be verified`,
|
||||
summary: [...new Set(affected.map(unknownEntrySummary))].join(" "),
|
||||
action: requestFailed
|
||||
? "Refocus the window to retry the freshness check."
|
||||
: refreshAction(affected),
|
||||
changedFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
+148
-40
@@ -1,61 +1,169 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildFreshnessBanner, isGraphFreshnessResult } from "../StalenessBanner";
|
||||
import type {
|
||||
DashboardFreshnessReport,
|
||||
GraphFreshnessResult,
|
||||
} from "../../freshness";
|
||||
import { buildFreshnessBanner } from "../StalenessBanner";
|
||||
|
||||
const fresh: GraphFreshnessResult = {
|
||||
status: "fresh",
|
||||
graphCommitHash: "a".repeat(40),
|
||||
headCommitHash: "a".repeat(40),
|
||||
changedFileCount: 0,
|
||||
changedFiles: [],
|
||||
commitsBehind: 0,
|
||||
commitsAhead: 0,
|
||||
};
|
||||
|
||||
function report(
|
||||
knowledge: GraphFreshnessResult,
|
||||
domain?: GraphFreshnessResult,
|
||||
): DashboardFreshnessReport {
|
||||
return { graphs: domain ? { knowledge, domain } : { knowledge } };
|
||||
}
|
||||
|
||||
describe("buildFreshnessBanner", () => {
|
||||
it("returns no banner for fresh or missing freshness data", () => {
|
||||
expect(buildFreshnessBanner(null)).toBeNull();
|
||||
expect(
|
||||
buildFreshnessBanner({
|
||||
status: "fresh",
|
||||
graphCommitHash: "abc123",
|
||||
headCommitHash: "abc123",
|
||||
changedFileCount: 0,
|
||||
changedFiles: [],
|
||||
commitsBehind: 0,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(buildFreshnessBanner(report(fresh))).toBeNull();
|
||||
});
|
||||
|
||||
it("summarizes stale graph state with commit and file counts", () => {
|
||||
const banner = buildFreshnessBanner({
|
||||
status: "stale",
|
||||
graphCommitHash: "graph-commit",
|
||||
headCommitHash: "head-commit",
|
||||
changedFileCount: 2,
|
||||
changedFiles: ["src/auth.ts", "src/routes.ts"],
|
||||
commitsBehind: 3,
|
||||
lastAnalyzedAt: "2026-07-08T08:00:00.000Z",
|
||||
});
|
||||
it("uses singular grammar for a graph one project commit behind", () => {
|
||||
const banner = buildFreshnessBanner(
|
||||
report({
|
||||
status: "stale",
|
||||
relation: "behind",
|
||||
graphCommitHash: "a".repeat(40),
|
||||
headCommitHash: "b".repeat(40),
|
||||
changedFileCount: 1,
|
||||
changedFiles: ["src/auth.ts"],
|
||||
commitsBehind: 1,
|
||||
commitsAhead: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(banner).toEqual({
|
||||
title: "Knowledge graph may be stale",
|
||||
summary:
|
||||
"The graph was generated 3 commits before HEAD and 2 files have changed since analysis.",
|
||||
action: "Run /understand to refresh it before relying on impact or onboarding answers.",
|
||||
changedFiles: ["src/auth.ts", "src/routes.ts"],
|
||||
"The knowledge graph is 1 project commit behind HEAD; 1 file has changed since analysis.",
|
||||
action:
|
||||
"Run /understand to refresh it before relying on impact or onboarding answers.",
|
||||
changedFiles: ["src/auth.ts"],
|
||||
});
|
||||
});
|
||||
|
||||
it("explains unknown freshness when commit metadata is missing", () => {
|
||||
it("explains an ahead domain graph with the correct refresh command", () => {
|
||||
const banner = buildFreshnessBanner(
|
||||
report(fresh, {
|
||||
status: "stale",
|
||||
relation: "ahead",
|
||||
graphCommitHash: "b".repeat(40),
|
||||
headCommitHash: "a".repeat(40),
|
||||
changedFileCount: 2,
|
||||
changedFiles: ["domain/a.ts", "domain/b.ts"],
|
||||
commitsBehind: 0,
|
||||
commitsAhead: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(banner).toMatchObject({
|
||||
title: "Domain graph may be stale",
|
||||
summary:
|
||||
"The domain graph comes from a newer project history than HEAD; 2 files have changed since analysis.",
|
||||
action:
|
||||
"Run /understand-domain to refresh it before relying on impact or onboarding answers.",
|
||||
});
|
||||
});
|
||||
|
||||
it("explains divergent histories", () => {
|
||||
const banner = buildFreshnessBanner(
|
||||
report({
|
||||
status: "stale",
|
||||
relation: "diverged",
|
||||
graphCommitHash: "a".repeat(40),
|
||||
headCommitHash: "b".repeat(40),
|
||||
changedFileCount: 2,
|
||||
changedFiles: ["src/a.ts", "src/b.ts"],
|
||||
commitsBehind: 1,
|
||||
commitsAhead: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(banner?.summary).toBe(
|
||||
"The knowledge graph and HEAD come from different project histories; 2 files have changed since analysis.",
|
||||
);
|
||||
});
|
||||
|
||||
it("prioritizes stale over dirty and unknown graph results", () => {
|
||||
const banner = buildFreshnessBanner(
|
||||
report(
|
||||
{
|
||||
status: "dirty",
|
||||
graphCommitHash: "a".repeat(40),
|
||||
headCommitHash: "a".repeat(40),
|
||||
changedFileCount: 1,
|
||||
changedFiles: ["src/dirty.ts"],
|
||||
commitsBehind: 0,
|
||||
commitsAhead: 0,
|
||||
},
|
||||
{
|
||||
status: "stale",
|
||||
relation: "behind",
|
||||
graphCommitHash: "a".repeat(40),
|
||||
headCommitHash: "b".repeat(40),
|
||||
changedFileCount: 1,
|
||||
changedFiles: ["domain/stale.ts"],
|
||||
commitsBehind: 1,
|
||||
commitsAhead: 0,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(banner?.title).toBe("Domain graph may be stale");
|
||||
expect(banner?.changedFiles).toEqual(["domain/stale.ts"]);
|
||||
});
|
||||
|
||||
it("names both graphs when they share the highest dirty risk", () => {
|
||||
const dirtyKnowledge: GraphFreshnessResult = {
|
||||
status: "dirty",
|
||||
graphCommitHash: "a".repeat(40),
|
||||
headCommitHash: "a".repeat(40),
|
||||
changedFileCount: 1,
|
||||
changedFiles: ["src/shared.ts"],
|
||||
commitsBehind: 0,
|
||||
commitsAhead: 0,
|
||||
};
|
||||
const banner = buildFreshnessBanner(
|
||||
report(dirtyKnowledge, {
|
||||
...dirtyKnowledge,
|
||||
changedFileCount: 2,
|
||||
changedFiles: ["src/shared.ts", "domain/model.ts"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(banner?.title).toBe(
|
||||
"Knowledge and domain graphs have working-tree changes",
|
||||
);
|
||||
expect(banner?.changedFiles).toEqual([
|
||||
"domain/model.ts",
|
||||
"src/shared.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("makes endpoint failures visible instead of treating them as fresh", () => {
|
||||
const banner = buildFreshnessBanner({
|
||||
status: "unknown",
|
||||
reason: "missing-graph-commit",
|
||||
lastAnalyzedAt: "2026-07-08T08:00:00.000Z",
|
||||
graphs: {
|
||||
knowledge: {
|
||||
status: "unknown",
|
||||
reason: "freshness-request-failed",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(banner).toEqual({
|
||||
title: "Graph freshness could not be verified",
|
||||
summary: "The graph does not include a git commit hash to compare with HEAD.",
|
||||
action: "Run /understand to regenerate the graph with current git metadata.",
|
||||
expect(banner).toMatchObject({
|
||||
title: "Knowledge graph freshness could not be verified",
|
||||
summary: "The dashboard could not refresh graph freshness data.",
|
||||
changedFiles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes graph freshness payloads from the dashboard endpoint", () => {
|
||||
expect(isGraphFreshnessResult({ status: "fresh" })).toBe(true);
|
||||
expect(isGraphFreshnessResult({ status: "stale" })).toBe(true);
|
||||
expect(isGraphFreshnessResult({ status: "unknown" })).toBe(true);
|
||||
expect(isGraphFreshnessResult({ error: "Forbidden" })).toBe(false);
|
||||
expect(isGraphFreshnessResult(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
export type GraphFreshnessRelation = "behind" | "ahead" | "diverged";
|
||||
|
||||
export type GraphFreshnessUnknownReason =
|
||||
| "missing-graph-commit"
|
||||
| "git-head-unavailable"
|
||||
| "graph-commit-unavailable"
|
||||
| "git-command-timeout"
|
||||
| "freshness-request-failed";
|
||||
|
||||
export type GraphFreshnessResult =
|
||||
| {
|
||||
status: "fresh";
|
||||
graphCommitHash: string;
|
||||
headCommitHash: string;
|
||||
changedFileCount: 0;
|
||||
changedFiles: [];
|
||||
commitsBehind: 0;
|
||||
commitsAhead: 0;
|
||||
lastAnalyzedAt?: string;
|
||||
}
|
||||
| {
|
||||
status: "dirty";
|
||||
graphCommitHash: string;
|
||||
headCommitHash: string;
|
||||
changedFileCount: number;
|
||||
changedFiles: string[];
|
||||
commitsBehind: 0;
|
||||
commitsAhead: 0;
|
||||
lastAnalyzedAt?: string;
|
||||
}
|
||||
| {
|
||||
status: "stale";
|
||||
relation: GraphFreshnessRelation;
|
||||
graphCommitHash: string;
|
||||
headCommitHash: string;
|
||||
changedFileCount: number;
|
||||
changedFiles: string[];
|
||||
commitsBehind: number;
|
||||
commitsAhead: number;
|
||||
lastAnalyzedAt?: string;
|
||||
}
|
||||
| {
|
||||
status: "unknown";
|
||||
reason: GraphFreshnessUnknownReason;
|
||||
graphCommitHash?: string;
|
||||
headCommitHash?: string;
|
||||
lastAnalyzedAt?: string;
|
||||
};
|
||||
|
||||
export interface DashboardFreshnessReport {
|
||||
graphs: {
|
||||
knowledge: GraphFreshnessResult;
|
||||
domain?: GraphFreshnessResult;
|
||||
};
|
||||
}
|
||||
|
||||
const UNKNOWN_REASONS = new Set<GraphFreshnessUnknownReason>([
|
||||
"missing-graph-commit",
|
||||
"git-head-unavailable",
|
||||
"graph-commit-unavailable",
|
||||
"git-command-timeout",
|
||||
"freshness-request-failed",
|
||||
]);
|
||||
|
||||
const RELATIONS = new Set<GraphFreshnessRelation>([
|
||||
"behind",
|
||||
"ahead",
|
||||
"diverged",
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
function isOptionalString(record: Record<string, unknown>, key: string): boolean {
|
||||
return !(key in record) || typeof record[key] === "string";
|
||||
}
|
||||
|
||||
function isOptionalHash(record: Record<string, unknown>, key: string): boolean {
|
||||
return !(key in record) || isNonEmptyString(record[key]);
|
||||
}
|
||||
|
||||
function isNonNegativeInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
function isChangedFileList(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((file) => typeof file === "string");
|
||||
}
|
||||
|
||||
export function isGraphFreshnessResult(
|
||||
value: unknown,
|
||||
): value is GraphFreshnessResult {
|
||||
if (!isRecord(value) || typeof value.status !== "string") return false;
|
||||
if (!isOptionalString(value, "lastAnalyzedAt")) return false;
|
||||
|
||||
if (value.status === "unknown") {
|
||||
return (
|
||||
typeof value.reason === "string" &&
|
||||
UNKNOWN_REASONS.has(value.reason as GraphFreshnessUnknownReason) &&
|
||||
isOptionalHash(value, "graphCommitHash") &&
|
||||
isOptionalHash(value, "headCommitHash")
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!isNonEmptyString(value.graphCommitHash) ||
|
||||
!isNonEmptyString(value.headCommitHash) ||
|
||||
!isNonNegativeInteger(value.changedFileCount) ||
|
||||
!isChangedFileList(value.changedFiles) ||
|
||||
value.changedFileCount !== value.changedFiles.length ||
|
||||
!isNonNegativeInteger(value.commitsBehind) ||
|
||||
!isNonNegativeInteger(value.commitsAhead)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.status === "fresh") {
|
||||
return (
|
||||
value.changedFileCount === 0 &&
|
||||
value.commitsBehind === 0 &&
|
||||
value.commitsAhead === 0
|
||||
);
|
||||
}
|
||||
|
||||
if (value.status === "dirty") {
|
||||
return (
|
||||
value.changedFileCount > 0 &&
|
||||
value.commitsBehind === 0 &&
|
||||
value.commitsAhead === 0
|
||||
);
|
||||
}
|
||||
|
||||
if (value.status === "stale") {
|
||||
return (
|
||||
value.changedFileCount > 0 &&
|
||||
typeof value.relation === "string" &&
|
||||
RELATIONS.has(value.relation as GraphFreshnessRelation)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isDashboardFreshnessReport(
|
||||
value: unknown,
|
||||
): value is DashboardFreshnessReport {
|
||||
if (!isRecord(value) || !isRecord(value.graphs)) return false;
|
||||
if (!isGraphFreshnessResult(value.graphs.knowledge)) return false;
|
||||
return (
|
||||
!("domain" in value.graphs) ||
|
||||
isGraphFreshnessResult(value.graphs.domain)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldRequestFreshness(
|
||||
demoMode: boolean,
|
||||
demoFreshnessUrl?: string,
|
||||
): boolean {
|
||||
return !demoMode || Boolean(demoFreshnessUrl);
|
||||
}
|
||||
|
||||
export async function requestFreshnessReport(
|
||||
url: string,
|
||||
signal: AbortSignal,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<DashboardFreshnessReport> {
|
||||
const response = await fetcher(url, { signal, cache: "no-store" });
|
||||
if (!response.ok) throw new Error("Freshness request failed");
|
||||
|
||||
const payload: unknown = await response.json();
|
||||
if (!isDashboardFreshnessReport(payload)) {
|
||||
throw new Error("Freshness response was malformed");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
interface FreshnessRefreshOptions {
|
||||
target: Pick<EventTarget, "addEventListener" | "removeEventListener">;
|
||||
load: (signal: AbortSignal) => Promise<DashboardFreshnessReport>;
|
||||
onResult: (report: DashboardFreshnessReport) => void;
|
||||
}
|
||||
|
||||
function requestFailedReport(): DashboardFreshnessReport {
|
||||
return {
|
||||
graphs: {
|
||||
knowledge: {
|
||||
status: "unknown",
|
||||
reason: "freshness-request-failed",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return isRecord(error) && error.name === "AbortError";
|
||||
}
|
||||
|
||||
export function startFreshnessRefresh({
|
||||
target,
|
||||
load,
|
||||
onResult,
|
||||
}: FreshnessRefreshOptions): () => void {
|
||||
let activeController: AbortController | null = null;
|
||||
let stopped = false;
|
||||
|
||||
const refresh = () => {
|
||||
activeController?.abort();
|
||||
const controller = new AbortController();
|
||||
activeController = controller;
|
||||
|
||||
void load(controller.signal)
|
||||
.then((report) => {
|
||||
if (
|
||||
!stopped &&
|
||||
activeController === controller &&
|
||||
!controller.signal.aborted
|
||||
) {
|
||||
onResult(report);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (
|
||||
stopped ||
|
||||
activeController !== controller ||
|
||||
controller.signal.aborted ||
|
||||
isAbortError(error)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onResult(requestFailedReport());
|
||||
});
|
||||
};
|
||||
|
||||
const handleFocus: EventListener = () => refresh();
|
||||
target.addEventListener("focus", handleFocus);
|
||||
refresh();
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
target.removeEventListener("focus", handleFocus);
|
||||
activeController?.abort();
|
||||
};
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig, type UserConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import crypto from "crypto";
|
||||
import { getGraphFreshness } from "../core/src/staleness";
|
||||
|
||||
// Generate a one-time token when the server process starts.
|
||||
// This token is printed to the terminal and must be in the URL
|
||||
// to fetch knowledge-graph.json or diff-overlay.json.
|
||||
const ACCESS_TOKEN = process.env.UNDERSTAND_ACCESS_TOKEN || crypto.randomBytes(16).toString("hex");
|
||||
const MAX_SOURCE_FILE_BYTES = 1024 * 1024;
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "http";
|
||||
import {
|
||||
getGraphFreshnessBatch,
|
||||
type GraphFreshnessInput,
|
||||
type GraphFreshnessResult,
|
||||
} from "../core/src/staleness";
|
||||
|
||||
// Generate a one-time token when the server process starts.
|
||||
// This token is printed to the terminal and must be in the URL
|
||||
// to fetch knowledge-graph.json or diff-overlay.json.
|
||||
const ACCESS_TOKEN = process.env.UNDERSTAND_ACCESS_TOKEN || crypto.randomBytes(16).toString("hex");
|
||||
const MAX_SOURCE_FILE_BYTES = 1024 * 1024;
|
||||
|
||||
function graphFileCandidates(fileName: string): string[] {
|
||||
const graphDir = process.env.GRAPH_DIR;
|
||||
if (graphDir) {
|
||||
@@ -23,187 +28,252 @@ function graphFileCandidates(fileName: string): string[] {
|
||||
path.resolve(process.cwd(), `../../../.understand-anything/${fileName}`),
|
||||
];
|
||||
}
|
||||
|
||||
function findGraphFile(fileName: string): string | null {
|
||||
return graphFileCandidates(fileName).find((candidate) => fs.existsSync(candidate)) ?? null;
|
||||
}
|
||||
|
||||
function projectRootFromGraphFile(candidate: string): string {
|
||||
return path.dirname(path.dirname(candidate));
|
||||
}
|
||||
|
||||
function normalizeGraphPath(filePath: string, projectRoot: string): string | null {
|
||||
const rawPath = path.isAbsolute(filePath)
|
||||
? filePath.startsWith(projectRoot)
|
||||
? path.relative(projectRoot, filePath)
|
||||
: null
|
||||
: filePath;
|
||||
if (rawPath === null) return null;
|
||||
const normalized = path.normalize(rawPath);
|
||||
if (
|
||||
!normalized ||
|
||||
normalized === "." ||
|
||||
normalized.includes("\0") ||
|
||||
normalized === ".." ||
|
||||
normalized.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(normalized)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return normalized.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function graphFilePathSet(graphFile: string, projectRoot: string): Set<string> {
|
||||
const allowed = new Set<string>();
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(graphFile, "utf-8")) as {
|
||||
nodes?: Array<Record<string, unknown>>;
|
||||
};
|
||||
for (const node of raw.nodes ?? []) {
|
||||
if (typeof node.filePath !== "string") continue;
|
||||
const normalized = normalizeGraphPath(node.filePath, projectRoot);
|
||||
if (normalized) allowed.add(normalized);
|
||||
}
|
||||
} catch {
|
||||
return allowed;
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
function detectLanguage(filePath: string): string {
|
||||
const ext = path.extname(filePath).slice(1).toLowerCase();
|
||||
const byExt: Record<string, string> = {
|
||||
bash: "bash",
|
||||
c: "c",
|
||||
cc: "cpp",
|
||||
cpp: "cpp",
|
||||
cs: "csharp",
|
||||
css: "css",
|
||||
go: "go",
|
||||
h: "c",
|
||||
hpp: "cpp",
|
||||
html: "markup",
|
||||
java: "java",
|
||||
js: "javascript",
|
||||
jsx: "jsx",
|
||||
json: "json",
|
||||
md: "markdown",
|
||||
mjs: "javascript",
|
||||
py: "python",
|
||||
rb: "ruby",
|
||||
rs: "rust",
|
||||
sh: "bash",
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
txt: "text",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
};
|
||||
return byExt[ext] ?? "text";
|
||||
}
|
||||
|
||||
function sendJson(res: import("http").ServerResponse, statusCode: number, payload: unknown) {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function rejectFileRequest(message: string, statusCode = 400) {
|
||||
return { statusCode, payload: { error: message } };
|
||||
}
|
||||
|
||||
function readSourceFile(url: URL) {
|
||||
const requestedPath = url.searchParams.get("path") ?? "";
|
||||
if (!requestedPath) return rejectFileRequest("Missing path");
|
||||
if (requestedPath.includes("\0")) return rejectFileRequest("Invalid path");
|
||||
if (path.isAbsolute(requestedPath)) return rejectFileRequest("Absolute paths are not allowed");
|
||||
|
||||
const normalizedPath = path.normalize(requestedPath);
|
||||
if (
|
||||
normalizedPath === "." ||
|
||||
normalizedPath.startsWith(`..${path.sep}`) ||
|
||||
normalizedPath === ".." ||
|
||||
path.isAbsolute(normalizedPath)
|
||||
) {
|
||||
return rejectFileRequest("Path must stay inside the project");
|
||||
}
|
||||
|
||||
const graphFile = findGraphFile("knowledge-graph.json");
|
||||
if (!graphFile) {
|
||||
return rejectFileRequest("No knowledge graph found. Run /understand first.", 404);
|
||||
}
|
||||
|
||||
const projectRoot = projectRootFromGraphFile(graphFile);
|
||||
const absoluteFile = path.resolve(projectRoot, normalizedPath);
|
||||
const relativeToRoot = path.relative(projectRoot, absoluteFile);
|
||||
if (
|
||||
!relativeToRoot ||
|
||||
relativeToRoot.startsWith(`..${path.sep}`) ||
|
||||
relativeToRoot === ".." ||
|
||||
path.isAbsolute(relativeToRoot)
|
||||
) {
|
||||
return rejectFileRequest("Path must stay inside the project");
|
||||
}
|
||||
const safeRelativePath = relativeToRoot.split(path.sep).join("/");
|
||||
if (!graphFilePathSet(graphFile, projectRoot).has(safeRelativePath)) {
|
||||
return rejectFileRequest("File is not in the knowledge graph", 404);
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(absoluteFile);
|
||||
} catch {
|
||||
return rejectFileRequest("File not found", 404);
|
||||
}
|
||||
|
||||
if (!stat.isFile()) return rejectFileRequest("Path is not a file");
|
||||
if (stat.size > MAX_SOURCE_FILE_BYTES) {
|
||||
return rejectFileRequest("File is too large to preview", 413);
|
||||
}
|
||||
|
||||
const buffer = fs.readFileSync(absoluteFile);
|
||||
if (buffer.includes(0)) return rejectFileRequest("Binary files cannot be previewed", 415);
|
||||
|
||||
const content = buffer.toString("utf8");
|
||||
return {
|
||||
statusCode: 200,
|
||||
payload: {
|
||||
path: safeRelativePath,
|
||||
language: detectLanguage(relativeToRoot),
|
||||
content,
|
||||
sizeBytes: buffer.byteLength,
|
||||
lineCount: content.length === 0 ? 0 : content.split(/\r\n|\n|\r/).length,
|
||||
},
|
||||
};
|
||||
|
||||
function findGraphFile(fileName: string): string | null {
|
||||
return graphFileCandidates(fileName).find((candidate) => fs.existsSync(candidate)) ?? null;
|
||||
}
|
||||
|
||||
export function readGraphFreshness() {
|
||||
function projectRootFromGraphFile(candidate: string): string {
|
||||
return path.dirname(path.dirname(candidate));
|
||||
}
|
||||
|
||||
function normalizeGraphPath(filePath: string, projectRoot: string): string | null {
|
||||
const rawPath = path.isAbsolute(filePath)
|
||||
? filePath.startsWith(projectRoot)
|
||||
? path.relative(projectRoot, filePath)
|
||||
: null
|
||||
: filePath;
|
||||
if (rawPath === null) return null;
|
||||
const normalized = path.normalize(rawPath);
|
||||
if (
|
||||
!normalized ||
|
||||
normalized === "." ||
|
||||
normalized.includes("\0") ||
|
||||
normalized === ".." ||
|
||||
normalized.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(normalized)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return normalized.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function graphFilePathSet(graphFile: string, projectRoot: string): Set<string> {
|
||||
const allowed = new Set<string>();
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(graphFile, "utf-8")) as {
|
||||
nodes?: Array<Record<string, unknown>>;
|
||||
};
|
||||
for (const node of raw.nodes ?? []) {
|
||||
if (typeof node.filePath !== "string") continue;
|
||||
const normalized = normalizeGraphPath(node.filePath, projectRoot);
|
||||
if (normalized) allowed.add(normalized);
|
||||
}
|
||||
} catch {
|
||||
return allowed;
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
function detectLanguage(filePath: string): string {
|
||||
const ext = path.extname(filePath).slice(1).toLowerCase();
|
||||
const byExt: Record<string, string> = {
|
||||
bash: "bash",
|
||||
c: "c",
|
||||
cc: "cpp",
|
||||
cpp: "cpp",
|
||||
cs: "csharp",
|
||||
css: "css",
|
||||
go: "go",
|
||||
h: "c",
|
||||
hpp: "cpp",
|
||||
html: "markup",
|
||||
java: "java",
|
||||
js: "javascript",
|
||||
jsx: "jsx",
|
||||
json: "json",
|
||||
md: "markdown",
|
||||
mjs: "javascript",
|
||||
py: "python",
|
||||
rb: "ruby",
|
||||
rs: "rust",
|
||||
sh: "bash",
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
txt: "text",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
};
|
||||
return byExt[ext] ?? "text";
|
||||
}
|
||||
|
||||
function sendJson(res: import("http").ServerResponse, statusCode: number, payload: unknown) {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function rejectFileRequest(message: string, statusCode = 400) {
|
||||
return { statusCode, payload: { error: message } };
|
||||
}
|
||||
|
||||
function readSourceFile(url: URL) {
|
||||
const requestedPath = url.searchParams.get("path") ?? "";
|
||||
if (!requestedPath) return rejectFileRequest("Missing path");
|
||||
if (requestedPath.includes("\0")) return rejectFileRequest("Invalid path");
|
||||
if (path.isAbsolute(requestedPath)) return rejectFileRequest("Absolute paths are not allowed");
|
||||
|
||||
const normalizedPath = path.normalize(requestedPath);
|
||||
if (
|
||||
normalizedPath === "." ||
|
||||
normalizedPath.startsWith(`..${path.sep}`) ||
|
||||
normalizedPath === ".." ||
|
||||
path.isAbsolute(normalizedPath)
|
||||
) {
|
||||
return rejectFileRequest("Path must stay inside the project");
|
||||
}
|
||||
|
||||
const graphFile = findGraphFile("knowledge-graph.json");
|
||||
if (!graphFile) {
|
||||
return rejectFileRequest("No knowledge graph found. Run /understand first.", 404);
|
||||
}
|
||||
|
||||
let graph: {
|
||||
const projectRoot = projectRootFromGraphFile(graphFile);
|
||||
const absoluteFile = path.resolve(projectRoot, normalizedPath);
|
||||
const relativeToRoot = path.relative(projectRoot, absoluteFile);
|
||||
if (
|
||||
!relativeToRoot ||
|
||||
relativeToRoot.startsWith(`..${path.sep}`) ||
|
||||
relativeToRoot === ".." ||
|
||||
path.isAbsolute(relativeToRoot)
|
||||
) {
|
||||
return rejectFileRequest("Path must stay inside the project");
|
||||
}
|
||||
const safeRelativePath = relativeToRoot.split(path.sep).join("/");
|
||||
if (!graphFilePathSet(graphFile, projectRoot).has(safeRelativePath)) {
|
||||
return rejectFileRequest("File is not in the knowledge graph", 404);
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(absoluteFile);
|
||||
} catch {
|
||||
return rejectFileRequest("File not found", 404);
|
||||
}
|
||||
|
||||
if (!stat.isFile()) return rejectFileRequest("Path is not a file");
|
||||
if (stat.size > MAX_SOURCE_FILE_BYTES) {
|
||||
return rejectFileRequest("File is too large to preview", 413);
|
||||
}
|
||||
|
||||
const buffer = fs.readFileSync(absoluteFile);
|
||||
if (buffer.includes(0)) return rejectFileRequest("Binary files cannot be previewed", 415);
|
||||
|
||||
const content = buffer.toString("utf8");
|
||||
return {
|
||||
statusCode: 200,
|
||||
payload: {
|
||||
path: safeRelativePath,
|
||||
language: detectLanguage(relativeToRoot),
|
||||
content,
|
||||
sizeBytes: buffer.byteLength,
|
||||
lineCount: content.length === 0 ? 0 : content.split(/\r\n|\n|\r/).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface DashboardFreshnessReport {
|
||||
graphs: {
|
||||
knowledge: GraphFreshnessResult;
|
||||
domain?: GraphFreshnessResult;
|
||||
};
|
||||
}
|
||||
|
||||
function readGraphMetadata(graphFile: string): GraphFreshnessInput {
|
||||
const graph = JSON.parse(fs.readFileSync(graphFile, "utf-8")) as {
|
||||
project?: {
|
||||
gitCommitHash?: unknown;
|
||||
analyzedAt?: unknown;
|
||||
};
|
||||
};
|
||||
return {
|
||||
graphCommitHash:
|
||||
typeof graph.project?.gitCommitHash === "string"
|
||||
? graph.project.gitCommitHash
|
||||
: undefined,
|
||||
lastAnalyzedAt:
|
||||
typeof graph.project?.analyzedAt === "string"
|
||||
? graph.project.analyzedAt
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readGraphFreshness() {
|
||||
const graphFile = findGraphFile("knowledge-graph.json");
|
||||
if (!graphFile) {
|
||||
return rejectFileRequest("No knowledge graph found. Run /understand first.", 404);
|
||||
}
|
||||
|
||||
const domainGraphFile = path.join(path.dirname(graphFile), "domain-graph.json");
|
||||
let knowledgeInput: GraphFreshnessInput;
|
||||
let domainInput: GraphFreshnessInput | undefined;
|
||||
try {
|
||||
graph = JSON.parse(fs.readFileSync(graphFile, "utf-8"));
|
||||
knowledgeInput = readGraphMetadata(graphFile);
|
||||
domainInput = fs.existsSync(domainGraphFile)
|
||||
? readGraphMetadata(domainGraphFile)
|
||||
: undefined;
|
||||
} catch {
|
||||
return rejectFileRequest("Failed to read graph file", 500);
|
||||
}
|
||||
|
||||
const project = graph.project;
|
||||
const projectRoot = projectRootFromGraphFile(graphFile);
|
||||
let graphs: DashboardFreshnessReport["graphs"];
|
||||
if (domainInput) {
|
||||
graphs = await getGraphFreshnessBatch(projectRoot, {
|
||||
knowledge: knowledgeInput,
|
||||
domain: domainInput,
|
||||
});
|
||||
} else {
|
||||
const result = await getGraphFreshnessBatch(projectRoot, {
|
||||
knowledge: knowledgeInput,
|
||||
});
|
||||
graphs = { knowledge: result.knowledge };
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
payload: getGraphFreshness(projectRootFromGraphFile(graphFile), {
|
||||
graphCommitHash:
|
||||
typeof project?.gitCommitHash === "string" ? project.gitCommitHash : undefined,
|
||||
lastAnalyzedAt:
|
||||
typeof project?.analyzedAt === "string" ? project.analyzedAt : undefined,
|
||||
}),
|
||||
payload: { graphs } satisfies DashboardFreshnessReport,
|
||||
};
|
||||
}
|
||||
|
||||
type DashboardDataMiddleware = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
next: () => void,
|
||||
) => void;
|
||||
|
||||
export function createDashboardDataMiddleware(
|
||||
accessToken: string,
|
||||
): DashboardDataMiddleware {
|
||||
return (req, res, next) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1:5173");
|
||||
if (url.pathname !== "/staleness.json") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
|
||||
if (url.searchParams.get("token") !== accessToken) {
|
||||
sendJson(res, 403, { error: "Forbidden: missing or invalid token" });
|
||||
return;
|
||||
}
|
||||
|
||||
void readGraphFreshness()
|
||||
.then((result) => sendJson(res, result.statusCode, result.payload))
|
||||
.catch(() => {
|
||||
sendJson(res, 500, { error: "Failed to read graph freshness" });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -218,191 +288,186 @@ const config: DashboardViteConfig = {
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/__tests__/**/*.test.ts"],
|
||||
},
|
||||
|
||||
// FIX 1 — bind only to localhost, not 0.0.0.0
|
||||
// This blocks access from any other device on the same LAN / WiFi.
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 5173,
|
||||
open: `/?token=${ACCESS_TOKEN}`,
|
||||
},
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
"@understand-anything/core/schema": path.resolve(__dirname, "../core/dist/schema.js"),
|
||||
"@understand-anything/core/search": path.resolve(__dirname, "../core/dist/search.js"),
|
||||
"@understand-anything/core/types": path.resolve(__dirname, "../core/dist/types.js"),
|
||||
},
|
||||
},
|
||||
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (!id.includes("node_modules")) return;
|
||||
if (/[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/.test(id)) {
|
||||
return "react-vendor";
|
||||
}
|
||||
if (id.includes("node_modules/@xyflow/")) return "xyflow";
|
||||
// ELK is ~1.6MB raw — split into its own chunk so it doesn't
|
||||
// bloat the main bundle. graphology is similarly large.
|
||||
if (id.includes("node_modules/elkjs/")) return "elk";
|
||||
if (id.includes("node_modules/graphology")) return "graphology";
|
||||
if (
|
||||
id.includes("node_modules/@dagrejs/") ||
|
||||
id.includes("node_modules/d3-force/")
|
||||
) {
|
||||
return "graph-layout";
|
||||
}
|
||||
if (
|
||||
id.includes("node_modules/react-markdown/") ||
|
||||
id.includes("node_modules/hast-util-to-jsx-runtime/") ||
|
||||
/[\\/]node_modules[\\/](remark|rehype|mdast|hast|unist|micromark|decode-named-character-reference|property-information|space-separated-tokens|comma-separated-tokens|html-url-attributes|devlop|bail|ccount|character-entities|is-plain-obj|trim-lines|trough|unified|vfile|zwitch)/.test(id)
|
||||
) {
|
||||
return "markdown";
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
{
|
||||
name: "serve-knowledge-graph",
|
||||
configureServer(server) {
|
||||
// Print the access URL once so the developer can open it.
|
||||
server.httpServer?.once("listening", () => {
|
||||
const address = server.httpServer?.address();
|
||||
const port = typeof address === "object" && address ? address.port : 5173;
|
||||
console.log(
|
||||
`\n 🔑 Dashboard URL: http://127.0.0.1:${port}/?token=${ACCESS_TOKEN}\n`
|
||||
);
|
||||
});
|
||||
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1:5173");
|
||||
const pathname = url.pathname;
|
||||
const isProtectedEndpoint =
|
||||
pathname === "/knowledge-graph.json" ||
|
||||
pathname === "/domain-graph.json" ||
|
||||
},
|
||||
|
||||
// FIX 1 — bind only to localhost, not 0.0.0.0
|
||||
// This blocks access from any other device on the same LAN / WiFi.
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 5173,
|
||||
open: `/?token=${ACCESS_TOKEN}`,
|
||||
},
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
"@understand-anything/core/schema": path.resolve(__dirname, "../core/dist/schema.js"),
|
||||
"@understand-anything/core/search": path.resolve(__dirname, "../core/dist/search.js"),
|
||||
"@understand-anything/core/types": path.resolve(__dirname, "../core/dist/types.js"),
|
||||
},
|
||||
},
|
||||
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (!id.includes("node_modules")) return;
|
||||
if (/[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/.test(id)) {
|
||||
return "react-vendor";
|
||||
}
|
||||
if (id.includes("node_modules/@xyflow/")) return "xyflow";
|
||||
// ELK is ~1.6MB raw — split into its own chunk so it doesn't
|
||||
// bloat the main bundle. graphology is similarly large.
|
||||
if (id.includes("node_modules/elkjs/")) return "elk";
|
||||
if (id.includes("node_modules/graphology")) return "graphology";
|
||||
if (
|
||||
id.includes("node_modules/@dagrejs/") ||
|
||||
id.includes("node_modules/d3-force/")
|
||||
) {
|
||||
return "graph-layout";
|
||||
}
|
||||
if (
|
||||
id.includes("node_modules/react-markdown/") ||
|
||||
id.includes("node_modules/hast-util-to-jsx-runtime/") ||
|
||||
/[\\/]node_modules[\\/](remark|rehype|mdast|hast|unist|micromark|decode-named-character-reference|property-information|space-separated-tokens|comma-separated-tokens|html-url-attributes|devlop|bail|ccount|character-entities|is-plain-obj|trim-lines|trough|unified|vfile|zwitch)/.test(id)
|
||||
) {
|
||||
return "markdown";
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
{
|
||||
name: "serve-knowledge-graph",
|
||||
configureServer(server) {
|
||||
// Print the access URL once so the developer can open it.
|
||||
server.httpServer?.once("listening", () => {
|
||||
const address = server.httpServer?.address();
|
||||
const port = typeof address === "object" && address ? address.port : 5173;
|
||||
console.log(
|
||||
`\n 🔑 Dashboard URL: http://127.0.0.1:${port}/?token=${ACCESS_TOKEN}\n`
|
||||
);
|
||||
});
|
||||
|
||||
server.middlewares.use(createDashboardDataMiddleware(ACCESS_TOKEN));
|
||||
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1:5173");
|
||||
const pathname = url.pathname;
|
||||
const isProtectedEndpoint =
|
||||
pathname === "/knowledge-graph.json" ||
|
||||
pathname === "/domain-graph.json" ||
|
||||
pathname === "/diff-overlay.json" ||
|
||||
pathname === "/meta.json" ||
|
||||
pathname === "/config.json" ||
|
||||
pathname === "/staleness.json" ||
|
||||
pathname === "/file-content.json";
|
||||
|
||||
if (!isProtectedEndpoint) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// FIX 3 — require the one-time token on all data endpoints.
|
||||
// Requests without a matching ?token= get a 403.
|
||||
if (url.searchParams.get("token") !== ACCESS_TOKEN) {
|
||||
sendJson(res, 403, { error: "Forbidden: missing or invalid token" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/file-content.json") {
|
||||
const result = readSourceFile(url);
|
||||
sendJson(res, result.statusCode, result.payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/config.json") {
|
||||
const configCandidates = graphFileCandidates("config.json");
|
||||
for (const candidate of configCandidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(candidate, "utf-8"));
|
||||
sendJson(res, 200, raw);
|
||||
return;
|
||||
} catch {
|
||||
sendJson(res, 500, { error: "Failed to read config file" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
sendJson(res, 200, { autoUpdate: false, outputLanguage: "en" });
|
||||
|
||||
if (!isProtectedEndpoint) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/staleness.json") {
|
||||
const result = readGraphFreshness();
|
||||
// FIX 3 — require the one-time token on all data endpoints.
|
||||
// Requests without a matching ?token= get a 403.
|
||||
if (url.searchParams.get("token") !== ACCESS_TOKEN) {
|
||||
sendJson(res, 403, { error: "Forbidden: missing or invalid token" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/file-content.json") {
|
||||
const result = readSourceFile(url);
|
||||
sendJson(res, result.statusCode, result.payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/config.json") {
|
||||
const configCandidates = graphFileCandidates("config.json");
|
||||
for (const candidate of configCandidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(candidate, "utf-8"));
|
||||
sendJson(res, 200, raw);
|
||||
return;
|
||||
} catch {
|
||||
sendJson(res, 500, { error: "Failed to read config file" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
sendJson(res, 200, { autoUpdate: false, outputLanguage: "en" });
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName =
|
||||
pathname === "/diff-overlay.json"
|
||||
? "diff-overlay.json"
|
||||
: pathname === "/meta.json"
|
||||
? "meta.json"
|
||||
: pathname === "/domain-graph.json"
|
||||
? "domain-graph.json"
|
||||
: "knowledge-graph.json";
|
||||
|
||||
const candidates = graphFileCandidates(fileName);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
|
||||
// FIX 2 — sanitise absolute file paths before sending the JSON.
|
||||
// Nodes can contain filePath values like /Users/alice/company/src/auth.ts.
|
||||
// We convert those to relative paths (src/auth.ts) so the developer's
|
||||
// home directory and company directory layout are not leaked.
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(candidate, "utf-8")) as {
|
||||
nodes?: Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
// Derive the project root from the candidate path so we can
|
||||
// make file paths relative to it.
|
||||
const projectRoot = projectRootFromGraphFile(candidate);
|
||||
|
||||
if (Array.isArray(raw.nodes)) {
|
||||
raw.nodes = raw.nodes.map((node) => {
|
||||
if (typeof node.filePath !== "string") return node;
|
||||
const abs = node.filePath;
|
||||
// Only relativise paths that actually sit inside projectRoot.
|
||||
// Leave external or already-relative paths untouched.
|
||||
const rel = abs.startsWith(projectRoot)
|
||||
? abs.slice(projectRoot.length).replace(/^[\\/]/, "")
|
||||
: path.isAbsolute(abs)
|
||||
? path.basename(abs) // absolute but outside root — use filename only
|
||||
: abs; // already relative — keep as-is
|
||||
return { ...node, filePath: rel };
|
||||
});
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(raw));
|
||||
} catch (err) {
|
||||
// If we cannot parse or sanitise the file, refuse to serve it
|
||||
// rather than accidentally leaking raw content.
|
||||
console.error("[understand-anything] Failed to sanitise graph file:", err);
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Failed to read graph file" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No matching file found on disk.
|
||||
res.statusCode = 404;
|
||||
if (pathname === "/knowledge-graph.json") {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "No knowledge graph found. Run /understand first." }));
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
pathname === "/diff-overlay.json"
|
||||
? "diff-overlay.json"
|
||||
: pathname === "/meta.json"
|
||||
? "meta.json"
|
||||
: pathname === "/domain-graph.json"
|
||||
? "domain-graph.json"
|
||||
: "knowledge-graph.json";
|
||||
|
||||
const candidates = graphFileCandidates(fileName);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
|
||||
// FIX 2 — sanitise absolute file paths before sending the JSON.
|
||||
// Nodes can contain filePath values like /Users/alice/company/src/auth.ts.
|
||||
// We convert those to relative paths (src/auth.ts) so the developer's
|
||||
// home directory and company directory layout are not leaked.
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(candidate, "utf-8")) as {
|
||||
nodes?: Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
// Derive the project root from the candidate path so we can
|
||||
// make file paths relative to it.
|
||||
const projectRoot = projectRootFromGraphFile(candidate);
|
||||
|
||||
if (Array.isArray(raw.nodes)) {
|
||||
raw.nodes = raw.nodes.map((node) => {
|
||||
if (typeof node.filePath !== "string") return node;
|
||||
const abs = node.filePath;
|
||||
// Only relativise paths that actually sit inside projectRoot.
|
||||
// Leave external or already-relative paths untouched.
|
||||
const rel = abs.startsWith(projectRoot)
|
||||
? abs.slice(projectRoot.length).replace(/^[\\/]/, "")
|
||||
: path.isAbsolute(abs)
|
||||
? path.basename(abs) // absolute but outside root — use filename only
|
||||
: abs; // already relative — keep as-is
|
||||
return { ...node, filePath: rel };
|
||||
});
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(raw));
|
||||
} catch (err) {
|
||||
// If we cannot parse or sanitise the file, refuse to serve it
|
||||
// rather than accidentally leaking raw content.
|
||||
console.error("[understand-anything] Failed to sanitise graph file:", err);
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Failed to read graph file" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No matching file found on disk.
|
||||
res.statusCode = 404;
|
||||
if (pathname === "/knowledge-graph.json") {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "No knowledge graph found. Run /understand first." }));
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,39 +1,52 @@
|
||||
---
|
||||
name: understand-chat
|
||||
description: Use when you need to ask questions about a codebase or understand code using a knowledge graph
|
||||
argument-hint: "[query]"
|
||||
---
|
||||
|
||||
# /understand-chat
|
||||
|
||||
Answer questions about this codebase using the knowledge graph at `.understand-anything/knowledge-graph.json`.
|
||||
|
||||
## Graph Structure Reference
|
||||
|
||||
The knowledge graph JSON has this structure:
|
||||
- `project` — {name, description, languages, frameworks, analyzedAt, gitCommitHash}
|
||||
- `nodes[]` — each has {id, type, name, filePath?, summary, tags[], complexity, languageNotes?}
|
||||
- Code node types: file, function, class, module, concept
|
||||
- Non-code node types: config, document, service, table, endpoint, pipeline, schema, resource
|
||||
- Domain/knowledge node types: domain, flow, step, article, entity, topic, claim, source
|
||||
- IDs use the node type as prefix, e.g. `file:path`, `function:path:name`, `config:path`, `article:path`
|
||||
- `edges[]` — each has {source, target, type, direction, weight}
|
||||
- Key types: imports, contains, calls, depends_on, configures, documents, deploys, triggers, contains_flow, flow_step, related, cites
|
||||
- `layers[]` — each has {id, name, description, nodeIds[]}
|
||||
- `tour[]` — each has {order, title, description, nodeIds[]}
|
||||
|
||||
## How to Read Efficiently
|
||||
|
||||
1. Use Grep to search within the JSON for relevant entries BEFORE reading the full file
|
||||
2. Only read sections you need — don't dump the entire graph into context
|
||||
3. Node names and summaries are the most useful fields for understanding
|
||||
4. Edges tell you how components connect — follow imports and calls for dependency chains
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Check that `.understand-anything/knowledge-graph.json` exists in the current project root. If not, tell the user to run `/understand` first.
|
||||
|
||||
2. **Check graph freshness** — read `project.gitCommitHash` from the graph metadata and run `git rev-parse HEAD` in the project root. If both values exist and differ, warn the user before answering that the knowledge graph may be stale and newer code may be missing from the answer. Suggest: Run `/understand` to refresh the graph. If git metadata is missing or unavailable, continue with a brief best-effort warning instead of blocking.
|
||||
---
|
||||
name: understand-chat
|
||||
description: Use when you need to ask questions about a codebase or understand code using a knowledge graph
|
||||
argument-hint: "[query]"
|
||||
---
|
||||
|
||||
# /understand-chat
|
||||
|
||||
Answer questions about this codebase using the knowledge graph at `.understand-anything/knowledge-graph.json`.
|
||||
|
||||
## Graph Structure Reference
|
||||
|
||||
The knowledge graph JSON has this structure:
|
||||
- `project` — {name, description, languages, frameworks, analyzedAt, gitCommitHash}
|
||||
- `nodes[]` — each has {id, type, name, filePath?, summary, tags[], complexity, languageNotes?}
|
||||
- Code node types: file, function, class, module, concept
|
||||
- Non-code node types: config, document, service, table, endpoint, pipeline, schema, resource
|
||||
- Domain/knowledge node types: domain, flow, step, article, entity, topic, claim, source
|
||||
- IDs use the node type as prefix, e.g. `file:path`, `function:path:name`, `config:path`, `article:path`
|
||||
- `edges[]` — each has {source, target, type, direction, weight}
|
||||
- Key types: imports, contains, calls, depends_on, configures, documents, deploys, triggers, contains_flow, flow_step, related, cites
|
||||
- `layers[]` — each has {id, name, description, nodeIds[]}
|
||||
- `tour[]` — each has {order, title, description, nodeIds[]}
|
||||
|
||||
## How to Read Efficiently
|
||||
|
||||
1. Use Grep to search within the JSON for relevant entries BEFORE reading the full file
|
||||
2. Only read sections you need — don't dump the entire graph into context
|
||||
3. Node names and summaries are the most useful fields for understanding
|
||||
4. Edges tell you how components connect — follow imports and calls for dependency chains
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Check that `.understand-anything/knowledge-graph.json` exists in the current project root. If not, tell the user to run `/understand` first.
|
||||
|
||||
2. **Check graph freshness before using graph-derived context**:
|
||||
- Read `project.gitCommitHash` from the graph metadata as `GRAPH_COMMIT_RAW`. Resolve it as a commit before using it in any Git diff, then compare it with `git rev-parse HEAD` and inspect project-scoped committed and working-tree changes from the project root:
|
||||
```bash
|
||||
GRAPH_COMMIT=$(git rev-parse --verify --end-of-options "${GRAPH_COMMIT_RAW}^{commit}" 2>/dev/null)
|
||||
git rev-parse HEAD
|
||||
git diff --name-only "$GRAPH_COMMIT" HEAD -- .
|
||||
git diff --cached --name-only -- .
|
||||
git diff --name-only -- .
|
||||
git ls-files --others --exclude-standard -- .
|
||||
```
|
||||
- The `-- .` pathspec is required: commits that only touch a sibling monorepo project must not make this graph stale. A hash mismatch alone is not stale when the project diff is empty.
|
||||
- Ignore `.understand-anything/` paths in every command's output because they are generated graph artifacts, not project source drift.
|
||||
- If the committed diff or any working-tree command reports project files, warn before answering that graph-derived context may omit those changes. Suggest: Run `/understand` to refresh the graph.
|
||||
- Run the commit diff only when `GRAPH_COMMIT_RAW` resolves successfully. If the graph commit or Git metadata is missing, invalid, or unavailable, give a brief best-effort warning and continue instead of blocking.
|
||||
|
||||
3. **Read project metadata only** — use Grep or Read with a line limit to extract just the `"project"` section from the top of the file for context (name, description, languages, frameworks).
|
||||
|
||||
|
||||
@@ -1,72 +1,85 @@
|
||||
---
|
||||
name: understand-diff
|
||||
description: Use when you need to analyze git diffs or pull requests to understand what changed, affected components, and risks
|
||||
---
|
||||
|
||||
# /understand-diff
|
||||
|
||||
Analyze the current code changes against the knowledge graph at `.understand-anything/knowledge-graph.json`.
|
||||
|
||||
## Graph Structure Reference
|
||||
|
||||
The knowledge graph JSON has this structure:
|
||||
- `project` — {name, description, languages, frameworks, analyzedAt, gitCommitHash}
|
||||
- `nodes[]` — each has {id, type, name, filePath?, summary, tags[], complexity, languageNotes?}
|
||||
- Code node types: file, function, class, module, concept
|
||||
- Non-code node types: config, document, service, table, endpoint, pipeline, schema, resource
|
||||
- Domain/knowledge node types: domain, flow, step, article, entity, topic, claim, source
|
||||
- IDs use the node type as prefix, e.g. `file:path`, `function:path:name`, `config:path`, `article:path`
|
||||
- `edges[]` — each has {source, target, type, direction, weight}
|
||||
- Key types: imports, contains, calls, depends_on, configures, documents, deploys, triggers, contains_flow, flow_step, related, cites
|
||||
- `layers[]` — each has {id, name, description, nodeIds[]}
|
||||
- `tour[]` — each has {order, title, description, nodeIds[]}
|
||||
|
||||
## How to Read Efficiently
|
||||
|
||||
1. Use Grep to search within the JSON for relevant entries BEFORE reading the full file
|
||||
2. Only read sections you need — don't dump the entire graph into context
|
||||
3. Node names and summaries are the most useful fields for understanding
|
||||
4. Edges tell you how components connect — follow imports and calls for dependency chains
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Check that `.understand-anything/knowledge-graph.json` exists. If not, tell the user to run `/understand` first.
|
||||
|
||||
2. **Get the changed files list** (do NOT read the graph yet):
|
||||
- If on a branch with uncommitted changes: `git diff --name-only`
|
||||
- If on a feature branch: `git diff main...HEAD --name-only` (or the base branch)
|
||||
- If the user specifies a PR number: get the diff from that PR
|
||||
|
||||
3. **Read project metadata and check graph freshness** — use Grep or Read with a line limit to extract just the `"project"` section for context, including `gitCommitHash`. Run `git rev-parse HEAD` in the project root. If both values exist and differ, warn the user before the diff analysis that the knowledge graph may be stale and impact analysis may miss newer components. Suggest: Run `/understand` to refresh the graph. If git metadata is missing or unavailable, continue with a brief best-effort warning instead of blocking.
|
||||
---
|
||||
name: understand-diff
|
||||
description: Use when you need to analyze git diffs or pull requests to understand what changed, affected components, and risks
|
||||
---
|
||||
|
||||
# /understand-diff
|
||||
|
||||
Analyze the current code changes against the knowledge graph at `.understand-anything/knowledge-graph.json`.
|
||||
|
||||
## Graph Structure Reference
|
||||
|
||||
The knowledge graph JSON has this structure:
|
||||
- `project` — {name, description, languages, frameworks, analyzedAt, gitCommitHash}
|
||||
- `nodes[]` — each has {id, type, name, filePath?, summary, tags[], complexity, languageNotes?}
|
||||
- Code node types: file, function, class, module, concept
|
||||
- Non-code node types: config, document, service, table, endpoint, pipeline, schema, resource
|
||||
- Domain/knowledge node types: domain, flow, step, article, entity, topic, claim, source
|
||||
- IDs use the node type as prefix, e.g. `file:path`, `function:path:name`, `config:path`, `article:path`
|
||||
- `edges[]` — each has {source, target, type, direction, weight}
|
||||
- Key types: imports, contains, calls, depends_on, configures, documents, deploys, triggers, contains_flow, flow_step, related, cites
|
||||
- `layers[]` — each has {id, name, description, nodeIds[]}
|
||||
- `tour[]` — each has {order, title, description, nodeIds[]}
|
||||
|
||||
## How to Read Efficiently
|
||||
|
||||
1. Use Grep to search within the JSON for relevant entries BEFORE reading the full file
|
||||
2. Only read sections you need — don't dump the entire graph into context
|
||||
3. Node names and summaries are the most useful fields for understanding
|
||||
4. Edges tell you how components connect — follow imports and calls for dependency chains
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Check that `.understand-anything/knowledge-graph.json` exists. If not, tell the user to run `/understand` first.
|
||||
|
||||
2. **Get the changed files list** (do NOT read the graph yet):
|
||||
- If on a branch with uncommitted changes: `git diff --name-only`
|
||||
- If on a feature branch: `git diff main...HEAD --name-only` (or the base branch)
|
||||
- If the user specifies a PR number: get the diff from that PR
|
||||
|
||||
3. **Read project metadata and check graph freshness** — use Grep or Read with a line limit to extract the `"project"` section, including `gitCommitHash` as `GRAPH_COMMIT_RAW`, then:
|
||||
- Resolve it as a commit before using it in any Git diff. From the project root, compare the resolved commit with `git rev-parse HEAD` and inspect project-scoped committed and working-tree changes:
|
||||
```bash
|
||||
GRAPH_COMMIT=$(git rev-parse --verify --end-of-options "${GRAPH_COMMIT_RAW}^{commit}" 2>/dev/null)
|
||||
git rev-parse HEAD
|
||||
git diff --name-only "$GRAPH_COMMIT" HEAD -- .
|
||||
git diff --cached --name-only -- .
|
||||
git diff --name-only -- .
|
||||
git ls-files --others --exclude-standard -- .
|
||||
```
|
||||
- The `-- .` pathspec is required: commits that only touch a sibling monorepo project must not make this graph stale. A hash mismatch alone is not stale when the project diff is empty.
|
||||
- Ignore `.understand-anything/` paths in every command's output because they are generated graph artifacts, not project source drift.
|
||||
- If the committed diff or any working-tree command reports project files, warn before impact analysis that the graph may omit those changes. Suggest: Run `/understand` to refresh the graph.
|
||||
- Run the commit diff only when `GRAPH_COMMIT_RAW` resolves successfully. If the graph commit or Git metadata is missing, invalid, or unavailable, give a brief best-effort warning and continue instead of blocking.
|
||||
|
||||
4. **Find nodes for changed files** — for each changed file path, use Grep to search the knowledge graph for:
|
||||
- Nodes with matching `"filePath"` values (e.g., `grep "changed/file/path"`)
|
||||
- This finds file-level nodes (including non-code types) AND function/class nodes defined in those files
|
||||
- Note the `id` values of all matched nodes
|
||||
|
||||
5. **Find connected edges (1-hop)** — for each matched node ID, Grep for that ID in the edges to find:
|
||||
- What imports or depends on the changed nodes (upstream callers)
|
||||
- What the changed nodes import or call (downstream dependencies)
|
||||
- These are the "affected components" — things that might break or need updating
|
||||
|
||||
6. **Identify affected layers** — Grep for the matched node IDs in the `"layers"` section to determine which architectural layers are touched.
|
||||
|
||||
7. **Provide structured analysis**:
|
||||
- **Changed Components**: What was directly modified (with summaries from matched nodes)
|
||||
- **Affected Components**: What might be impacted (from 1-hop edges)
|
||||
- **Affected Layers**: Which architectural layers are touched and cross-layer concerns
|
||||
- **Risk Assessment**: Based on node `complexity` values, number of cross-layer edges, and blast radius (number of affected components)
|
||||
- Suggest what to review carefully and any potential issues
|
||||
|
||||
8. **Write diff overlay for dashboard** — after producing the analysis, write the diff data to `.understand-anything/diff-overlay.json` so the dashboard can visualize changed and affected components. The file contains:
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"baseBranch": "<the base branch used>",
|
||||
"generatedAt": "<ISO timestamp>",
|
||||
"changedFiles": ["<list of changed file paths>"],
|
||||
"changedNodeIds": ["<node IDs from step 4>"],
|
||||
"affectedNodeIds": ["<node IDs from step 5, excluding changedNodeIds>"]
|
||||
}
|
||||
```
|
||||
After writing, tell the user they can run `/understand-anything:understand-dashboard` to see the diff overlay visually.
|
||||
|
||||
5. **Find connected edges (1-hop)** — for each matched node ID, Grep for that ID in the edges to find:
|
||||
- What imports or depends on the changed nodes (upstream callers)
|
||||
- What the changed nodes import or call (downstream dependencies)
|
||||
- These are the "affected components" — things that might break or need updating
|
||||
|
||||
6. **Identify affected layers** — Grep for the matched node IDs in the `"layers"` section to determine which architectural layers are touched.
|
||||
|
||||
7. **Provide structured analysis**:
|
||||
- **Changed Components**: What was directly modified (with summaries from matched nodes)
|
||||
- **Affected Components**: What might be impacted (from 1-hop edges)
|
||||
- **Affected Layers**: Which architectural layers are touched and cross-layer concerns
|
||||
- **Risk Assessment**: Based on node `complexity` values, number of cross-layer edges, and blast radius (number of affected components)
|
||||
- Suggest what to review carefully and any potential issues
|
||||
|
||||
8. **Write diff overlay for dashboard** — after producing the analysis, write the diff data to `.understand-anything/diff-overlay.json` so the dashboard can visualize changed and affected components. The file contains:
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"baseBranch": "<the base branch used>",
|
||||
"generatedAt": "<ISO timestamp>",
|
||||
"changedFiles": ["<list of changed file paths>"],
|
||||
"changedNodeIds": ["<node IDs from step 4>"],
|
||||
"affectedNodeIds": ["<node IDs from step 5, excluding changedNodeIds>"]
|
||||
}
|
||||
```
|
||||
After writing, tell the user they can run `/understand-anything:understand-dashboard` to see the diff overlay visually.
|
||||
|
||||
@@ -89,8 +89,22 @@ Use `$PLUGIN_ROOT` for every reference to agent definitions in subsequent phases
|
||||
### Phase 1: Detect Existing Graph
|
||||
|
||||
1. Check if `$PROJECT_ROOT/.understand-anything/knowledge-graph.json` exists
|
||||
2. If it exists AND `--full` was NOT passed → proceed to Phase 3 (derive from graph)
|
||||
3. Otherwise → proceed to Phase 2 (lightweight scan)
|
||||
2. If it exists AND `--full` was NOT passed, check freshness before deriving from it:
|
||||
- Read `project.gitCommitHash` from the graph metadata as `GRAPH_COMMIT_RAW`. Change to `$PROJECT_ROOT`, resolve it as a commit before using it in any Git diff, compare the resolved commit with `git rev-parse HEAD`, and inspect project-scoped committed and working-tree changes:
|
||||
```bash
|
||||
GRAPH_COMMIT=$(git rev-parse --verify --end-of-options "${GRAPH_COMMIT_RAW}^{commit}" 2>/dev/null)
|
||||
git rev-parse HEAD
|
||||
git diff --name-only "$GRAPH_COMMIT" HEAD -- .
|
||||
git diff --cached --name-only -- .
|
||||
git diff --name-only -- .
|
||||
git ls-files --others --exclude-standard -- .
|
||||
```
|
||||
- The `-- .` pathspec is required: commits that only touch a sibling monorepo project must not make this graph stale. A hash mismatch alone is not stale when the project diff is empty.
|
||||
- Ignore `.understand-anything/` paths in every command's output because they are generated graph artifacts, not project source drift.
|
||||
- If the committed diff or any working-tree command reports project files, warn that domain extraction may omit those changes. Suggest: Run `/understand` to refresh the knowledge graph.
|
||||
- Run the commit diff only when `GRAPH_COMMIT_RAW` resolves successfully. If the graph commit or Git metadata is missing, invalid, or unavailable, give a brief best-effort warning and continue instead of blocking.
|
||||
3. After that preflight, proceed to Phase 3 (derive from graph).
|
||||
4. Otherwise, proceed to Phase 2 (lightweight scan). When `--full` is used, skip this preflight because the command performs a fresh scan instead of consuming the existing graph.
|
||||
|
||||
### Phase 2: Lightweight Scan (Path 1)
|
||||
|
||||
|
||||
@@ -1,39 +1,52 @@
|
||||
---
|
||||
name: understand-explain
|
||||
description: Use when you need a deep-dive explanation of a specific file, function, or module in the codebase
|
||||
argument-hint: "[file-path]"
|
||||
---
|
||||
|
||||
# /understand-explain
|
||||
|
||||
Provide a thorough, in-depth explanation of a specific code component.
|
||||
|
||||
## Graph Structure Reference
|
||||
|
||||
The knowledge graph JSON has this structure:
|
||||
- `project` — {name, description, languages, frameworks, analyzedAt, gitCommitHash}
|
||||
- `nodes[]` — each has {id, type, name, filePath?, summary, tags[], complexity, languageNotes?}
|
||||
- Code node types: file, function, class, module, concept
|
||||
- Non-code node types: config, document, service, table, endpoint, pipeline, schema, resource
|
||||
- Domain/knowledge node types: domain, flow, step, article, entity, topic, claim, source
|
||||
- IDs use the node type as prefix, e.g. `file:path`, `function:path:name`, `config:path`, `article:path`
|
||||
- `edges[]` — each has {source, target, type, direction, weight}
|
||||
- Key types: imports, contains, calls, depends_on, configures, documents, deploys, triggers, contains_flow, flow_step, related, cites
|
||||
- `layers[]` — each has {id, name, description, nodeIds[]}
|
||||
- `tour[]` — each has {order, title, description, nodeIds[]}
|
||||
|
||||
## How to Read Efficiently
|
||||
|
||||
1. Use Grep to search within the JSON for relevant entries BEFORE reading the full file
|
||||
2. Only read sections you need — don't dump the entire graph into context
|
||||
3. Node names and summaries are the most useful fields for understanding
|
||||
4. Edges tell you how components connect — follow imports and calls for dependency chains
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Check that `.understand-anything/knowledge-graph.json` exists. If not, tell the user to run `/understand` first.
|
||||
|
||||
2. **Check graph freshness** — read `project.gitCommitHash` from the graph metadata and run `git rev-parse HEAD` in the project root. If both values exist and differ, warn the user before explaining that the knowledge graph may be stale and newer code may be missing from graph-derived context. Suggest: Run `/understand` to refresh the graph. If git metadata is missing or unavailable, continue with a brief best-effort warning instead of blocking.
|
||||
---
|
||||
name: understand-explain
|
||||
description: Use when you need a deep-dive explanation of a specific file, function, or module in the codebase
|
||||
argument-hint: "[file-path]"
|
||||
---
|
||||
|
||||
# /understand-explain
|
||||
|
||||
Provide a thorough, in-depth explanation of a specific code component.
|
||||
|
||||
## Graph Structure Reference
|
||||
|
||||
The knowledge graph JSON has this structure:
|
||||
- `project` — {name, description, languages, frameworks, analyzedAt, gitCommitHash}
|
||||
- `nodes[]` — each has {id, type, name, filePath?, summary, tags[], complexity, languageNotes?}
|
||||
- Code node types: file, function, class, module, concept
|
||||
- Non-code node types: config, document, service, table, endpoint, pipeline, schema, resource
|
||||
- Domain/knowledge node types: domain, flow, step, article, entity, topic, claim, source
|
||||
- IDs use the node type as prefix, e.g. `file:path`, `function:path:name`, `config:path`, `article:path`
|
||||
- `edges[]` — each has {source, target, type, direction, weight}
|
||||
- Key types: imports, contains, calls, depends_on, configures, documents, deploys, triggers, contains_flow, flow_step, related, cites
|
||||
- `layers[]` — each has {id, name, description, nodeIds[]}
|
||||
- `tour[]` — each has {order, title, description, nodeIds[]}
|
||||
|
||||
## How to Read Efficiently
|
||||
|
||||
1. Use Grep to search within the JSON for relevant entries BEFORE reading the full file
|
||||
2. Only read sections you need — don't dump the entire graph into context
|
||||
3. Node names and summaries are the most useful fields for understanding
|
||||
4. Edges tell you how components connect — follow imports and calls for dependency chains
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Check that `.understand-anything/knowledge-graph.json` exists. If not, tell the user to run `/understand` first.
|
||||
|
||||
2. **Check graph freshness before using graph-derived context**:
|
||||
- Read `project.gitCommitHash` from the graph metadata as `GRAPH_COMMIT_RAW`. Resolve it as a commit before using it in any Git diff, then compare it with `git rev-parse HEAD` and inspect project-scoped committed and working-tree changes from the project root:
|
||||
```bash
|
||||
GRAPH_COMMIT=$(git rev-parse --verify --end-of-options "${GRAPH_COMMIT_RAW}^{commit}" 2>/dev/null)
|
||||
git rev-parse HEAD
|
||||
git diff --name-only "$GRAPH_COMMIT" HEAD -- .
|
||||
git diff --cached --name-only -- .
|
||||
git diff --name-only -- .
|
||||
git ls-files --others --exclude-standard -- .
|
||||
```
|
||||
- The `-- .` pathspec is required: commits that only touch a sibling monorepo project must not make this graph stale. A hash mismatch alone is not stale when the project diff is empty.
|
||||
- Ignore `.understand-anything/` paths in every command's output because they are generated graph artifacts, not project source drift.
|
||||
- If the committed diff or any working-tree command reports project files, warn before explaining that graph-derived context may omit those changes. Suggest: Run `/understand` to refresh the graph.
|
||||
- Run the commit diff only when `GRAPH_COMMIT_RAW` resolves successfully. If the graph commit or Git metadata is missing, invalid, or unavailable, give a brief best-effort warning and continue instead of blocking.
|
||||
|
||||
3. **Find the target node** — use Grep to search the knowledge graph for the component: "$ARGUMENTS"
|
||||
- For file paths (e.g., `src/auth/login.ts`): search for `"filePath"` matches
|
||||
@@ -57,4 +70,4 @@ The knowledge graph JSON has this structure:
|
||||
- External connections (what it imports, what calls it, what it depends on — from edges)
|
||||
- Data flow (inputs → processing → outputs — from source code)
|
||||
- Explain clearly, assuming the reader may not know the programming language
|
||||
- Highlight any patterns, idioms, or complexity worth understanding
|
||||
- Highlight any patterns, idioms, or complexity worth understanding
|
||||
|
||||
@@ -1,38 +1,51 @@
|
||||
---
|
||||
name: understand-onboard
|
||||
description: Use when you need to generate an onboarding guide for new team members joining a project
|
||||
---
|
||||
|
||||
# /understand-onboard
|
||||
|
||||
Generate a comprehensive onboarding guide from the project's knowledge graph.
|
||||
|
||||
## Graph Structure Reference
|
||||
|
||||
The knowledge graph JSON has this structure:
|
||||
- `project` — {name, description, languages, frameworks, analyzedAt, gitCommitHash}
|
||||
- `nodes[]` — each has {id, type, name, filePath?, summary, tags[], complexity, languageNotes?}
|
||||
- Code node types: file, function, class, module, concept
|
||||
- Non-code node types: config, document, service, table, endpoint, pipeline, schema, resource
|
||||
- Domain/knowledge node types: domain, flow, step, article, entity, topic, claim, source
|
||||
- IDs use the node type as prefix, e.g. `file:path`, `function:path:name`, `config:path`, `article:path`
|
||||
- `edges[]` — each has {source, target, type, direction, weight}
|
||||
- Key types: imports, contains, calls, depends_on, configures, documents, deploys, triggers, contains_flow, flow_step, related, cites
|
||||
- `layers[]` — each has {id, name, description, nodeIds[]}
|
||||
- `tour[]` — each has {order, title, description, nodeIds[]}
|
||||
|
||||
## How to Read Efficiently
|
||||
|
||||
1. Use Grep to search within the JSON for relevant entries BEFORE reading the full file
|
||||
2. Only read sections you need — don't dump the entire graph into context
|
||||
3. Node names and summaries are the most useful fields for understanding
|
||||
4. Edges tell you how components connect — follow imports and calls for dependency chains
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Check that `.understand-anything/knowledge-graph.json` exists. If not, tell the user to run `/understand` first.
|
||||
|
||||
2. **Check graph freshness** — read `project.gitCommitHash` from the graph metadata and run `git rev-parse HEAD` in the project root. If both values exist and differ, warn the user before generating the guide that the knowledge graph may be stale and newer code may be missing from onboarding content. Suggest: Run `/understand` to refresh the graph. If git metadata is missing or unavailable, continue with a brief best-effort warning instead of blocking.
|
||||
---
|
||||
name: understand-onboard
|
||||
description: Use when you need to generate an onboarding guide for new team members joining a project
|
||||
---
|
||||
|
||||
# /understand-onboard
|
||||
|
||||
Generate a comprehensive onboarding guide from the project's knowledge graph.
|
||||
|
||||
## Graph Structure Reference
|
||||
|
||||
The knowledge graph JSON has this structure:
|
||||
- `project` — {name, description, languages, frameworks, analyzedAt, gitCommitHash}
|
||||
- `nodes[]` — each has {id, type, name, filePath?, summary, tags[], complexity, languageNotes?}
|
||||
- Code node types: file, function, class, module, concept
|
||||
- Non-code node types: config, document, service, table, endpoint, pipeline, schema, resource
|
||||
- Domain/knowledge node types: domain, flow, step, article, entity, topic, claim, source
|
||||
- IDs use the node type as prefix, e.g. `file:path`, `function:path:name`, `config:path`, `article:path`
|
||||
- `edges[]` — each has {source, target, type, direction, weight}
|
||||
- Key types: imports, contains, calls, depends_on, configures, documents, deploys, triggers, contains_flow, flow_step, related, cites
|
||||
- `layers[]` — each has {id, name, description, nodeIds[]}
|
||||
- `tour[]` — each has {order, title, description, nodeIds[]}
|
||||
|
||||
## How to Read Efficiently
|
||||
|
||||
1. Use Grep to search within the JSON for relevant entries BEFORE reading the full file
|
||||
2. Only read sections you need — don't dump the entire graph into context
|
||||
3. Node names and summaries are the most useful fields for understanding
|
||||
4. Edges tell you how components connect — follow imports and calls for dependency chains
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Check that `.understand-anything/knowledge-graph.json` exists. If not, tell the user to run `/understand` first.
|
||||
|
||||
2. **Check graph freshness before using graph-derived context**:
|
||||
- Read `project.gitCommitHash` from the graph metadata as `GRAPH_COMMIT_RAW`. Resolve it as a commit before using it in any Git diff, then compare it with `git rev-parse HEAD` and inspect project-scoped committed and working-tree changes from the project root:
|
||||
```bash
|
||||
GRAPH_COMMIT=$(git rev-parse --verify --end-of-options "${GRAPH_COMMIT_RAW}^{commit}" 2>/dev/null)
|
||||
git rev-parse HEAD
|
||||
git diff --name-only "$GRAPH_COMMIT" HEAD -- .
|
||||
git diff --cached --name-only -- .
|
||||
git diff --name-only -- .
|
||||
git ls-files --others --exclude-standard -- .
|
||||
```
|
||||
- The `-- .` pathspec is required: commits that only touch a sibling monorepo project must not make this graph stale. A hash mismatch alone is not stale when the project diff is empty.
|
||||
- Ignore `.understand-anything/` paths in every command's output because they are generated graph artifacts, not project source drift.
|
||||
- If the committed diff or any working-tree command reports project files, warn before generating the guide that onboarding content may omit those changes. Suggest: Run `/understand` to refresh the graph.
|
||||
- Run the commit diff only when `GRAPH_COMMIT_RAW` resolves successfully. If the graph commit or Git metadata is missing, invalid, or unavailable, give a brief best-effort warning and continue instead of blocking.
|
||||
|
||||
3. **Read project metadata** — use Grep or Read with a line limit to extract the `"project"` section (name, description, languages, frameworks).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user