mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
feat(graph-ui): dead-code filtering, node code preview, GitHub deep-links
Distilled from #789 (safe features only). Adds backend dead-code classification (status + in_calls in the layout JSON), a GET /api/repo-info endpoint for GitHub deep-links, and the frontend dead-code filters, node code preview, and deep-links. The render-cap revert was dropped (kept 2000 for DEFAULT_MAX_NODES/HARD_MAX_NODES and GRAPH_RENDER_NODE_LIMIT); the sidebar regex-search refactor was omitted. Security fixes over the original: repo-info strips credentials from any returned remote_url; the legitimate https blob-URL construction is allow-listed so the static gate passes; libgit2 is not re-initialized/shutdown per request (reuses the process-wide init from cbm_alloc_init); deep-link path segments are URL-encoded. Co-authored-by: Andy Zehady <azehady@ciroos.ai> Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
1 parent
7884ccb9ed
commit
a27680c3a9
15 files changed
+1050
-23
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { colorForLabel } from "../lib/colors";
|
||||
import { colorForLabel, STATUS_LEGEND } from "../lib/colors";
|
||||
import type { GraphData } from "../lib/types";
|
||||
|
||||
interface FilterPanelProps {
|
||||
@@ -12,6 +12,49 @@ interface FilterPanelProps {
|
||||
onToggleShowLabels: () => void;
|
||||
onEnableAll: () => void;
|
||||
onDisableAll: () => void;
|
||||
/* Dead-code view */
|
||||
deadCodeView: boolean;
|
||||
showOnlyDead: boolean;
|
||||
hideEntryPoints: boolean;
|
||||
hideTests: boolean;
|
||||
onToggleDeadCodeView: () => void;
|
||||
onToggleShowOnlyDead: () => void;
|
||||
onToggleHideEntryPoints: () => void;
|
||||
onToggleHideTests: () => void;
|
||||
}
|
||||
|
||||
/* Checkbox row matching the existing "Show labels" toggle style */
|
||||
function CheckRow({
|
||||
checked,
|
||||
onToggle,
|
||||
label,
|
||||
count,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
label: string;
|
||||
count?: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`flex items-center gap-1.5 text-[11px] font-medium transition-all ${
|
||||
checked ? "text-primary" : "text-foreground/40"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center transition-all ${
|
||||
checked ? "border-primary bg-primary/20" : "border-foreground/15"
|
||||
}`}
|
||||
>
|
||||
{checked && <span className="text-primary text-[9px]">✓</span>}
|
||||
</span>
|
||||
{label}
|
||||
{count !== undefined && (
|
||||
<span className="text-foreground/25 tabular-nums">{count.toLocaleString()}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterPanel({
|
||||
@@ -24,18 +67,32 @@ export function FilterPanel({
|
||||
onToggleShowLabels,
|
||||
onEnableAll,
|
||||
onDisableAll,
|
||||
deadCodeView,
|
||||
showOnlyDead,
|
||||
hideEntryPoints,
|
||||
hideTests,
|
||||
onToggleDeadCodeView,
|
||||
onToggleShowOnlyDead,
|
||||
onToggleHideEntryPoints,
|
||||
onToggleHideTests,
|
||||
}: FilterPanelProps) {
|
||||
const { labelCounts, edgeTypeCounts } = useMemo(() => {
|
||||
const { labelCounts, edgeTypeCounts, statusCounts } = useMemo(() => {
|
||||
const lc = new Map<string, number>();
|
||||
for (const n of data.nodes) lc.set(n.label, (lc.get(n.label) ?? 0) + 1);
|
||||
const ec = new Map<string, number>();
|
||||
for (const e of data.edges) ec.set(e.type, (ec.get(e.type) ?? 0) + 1);
|
||||
const sc = new Map<string, number>();
|
||||
for (const n of data.nodes)
|
||||
if (n.status) sc.set(n.status, (sc.get(n.status) ?? 0) + 1);
|
||||
return {
|
||||
labelCounts: [...lc.entries()].sort((a, b) => b[1] - a[1]),
|
||||
edgeTypeCounts: [...ec.entries()].sort((a, b) => b[1] - a[1]),
|
||||
statusCounts: sc,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const deadCount = statusCounts.get("dead") ?? 0;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border/40 space-y-3">
|
||||
{/* Header row */}
|
||||
@@ -110,6 +167,53 @@ export function FilterPanel({
|
||||
</span>
|
||||
Show labels
|
||||
</button>
|
||||
|
||||
{/* Dead-code view */}
|
||||
<div className="pt-2 border-t border-border/30 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-foreground/30 uppercase tracking-widest">
|
||||
Dead code
|
||||
</span>
|
||||
<span className="text-[10px] text-red-400/80 tabular-nums">
|
||||
{deadCount.toLocaleString()} dead
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<CheckRow
|
||||
checked={deadCodeView}
|
||||
onToggle={onToggleDeadCodeView}
|
||||
label="Color by status"
|
||||
/>
|
||||
<CheckRow
|
||||
checked={showOnlyDead}
|
||||
onToggle={onToggleShowOnlyDead}
|
||||
label="Show only dead code"
|
||||
/>
|
||||
<CheckRow
|
||||
checked={hideEntryPoints}
|
||||
onToggle={onToggleHideEntryPoints}
|
||||
label="Hide entry points"
|
||||
/>
|
||||
<CheckRow checked={hideTests} onToggle={onToggleHideTests} label="Hide tests" />
|
||||
|
||||
{/* Legend (only meaningful while colored by status) */}
|
||||
{deadCodeView && (
|
||||
<div className="flex flex-wrap gap-x-2 gap-y-1 pt-1">
|
||||
{STATUS_LEGEND.map((s) => (
|
||||
<span
|
||||
key={s.status}
|
||||
className="inline-flex items-center gap-1 text-[9px] text-foreground/40"
|
||||
>
|
||||
<span
|
||||
className="w-[6px] h-[6px] rounded-full"
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
{s.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { GraphTab } from "./GraphTab";
|
||||
import type { GraphData } from "../lib/types";
|
||||
|
||||
/* GraphScene renders a WebGL <Canvas> which jsdom can't run — stub it out. */
|
||||
vi.mock("./GraphScene", () => ({
|
||||
GraphScene: () => null,
|
||||
computeCameraTarget: () => null,
|
||||
}));
|
||||
|
||||
const SAMPLE: GraphData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 1, x: 0, y: 0, z: 0, label: "Function", name: "orphan",
|
||||
file_path: "src/orphan.ts", size: 1, color: "#fff", status: "dead", in_calls: 0,
|
||||
},
|
||||
{
|
||||
id: 2, x: 1, y: 0, z: 0, label: "Function", name: "used",
|
||||
file_path: "src/used.ts", size: 1, color: "#fff", status: "normal", in_calls: 3,
|
||||
},
|
||||
],
|
||||
edges: [{ source: 2, target: 1, type: "CALLS" }],
|
||||
total_nodes: 2,
|
||||
};
|
||||
|
||||
function mockLayoutFetch(data: GraphData) {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith("/api/layout")) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
describe("GraphTab dead-code filters", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows the dead count and filters to only dead code on toggle", async () => {
|
||||
mockLayoutFetch(SAMPLE);
|
||||
render(<GraphTab project="demo" />);
|
||||
|
||||
/* Panel loaded; the dead-code section reports one dead node. */
|
||||
expect(await screen.findByText("Filters")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 dead")).toBeInTheDocument();
|
||||
|
||||
/* Both nodes visible initially — no "filtered from" notice. */
|
||||
expect(screen.queryByText(/filtered from/)).not.toBeInTheDocument();
|
||||
|
||||
/* Toggling "Show only dead code" hides the non-dead node. */
|
||||
fireEvent.click(screen.getByRole("button", { name: /Show only dead code/ }));
|
||||
expect(await screen.findByText(/filtered from 2/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,8 @@ import { FilterPanel } from "./FilterPanel";
|
||||
import { NodeDetailPanel } from "./NodeDetailPanel";
|
||||
import { ResizeHandle } from "./ResizeHandle";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import type { GraphNode, GraphData } from "../lib/types";
|
||||
import type { GraphNode, GraphData, RepoInfo } from "../lib/types";
|
||||
import { colorForStatus } from "../lib/colors";
|
||||
|
||||
/* Persist panel widths */
|
||||
function loadWidth(key: string, fallback: number): number {
|
||||
@@ -40,6 +41,7 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||
const [cameraTarget, setCameraTarget] = useState<CameraTarget | null>(null);
|
||||
const [repoInfo, setRepoInfo] = useState<RepoInfo | null>(null);
|
||||
const [showLabels, setShowLabels] = useState(true);
|
||||
const [leftWidth, setLeftWidth] = useState(() => loadWidth("cbm-left-w", 260));
|
||||
const [rightWidth, setRightWidth] = useState(() => loadWidth("cbm-right-w", 280));
|
||||
@@ -49,6 +51,12 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
const [enabledLabels, setEnabledLabels] = useState<Set<string>>(new Set());
|
||||
const [enabledEdgeTypes, setEnabledEdgeTypes] = useState<Set<string>>(new Set());
|
||||
|
||||
/* Dead-code view: recolor by status + status-based filters */
|
||||
const [deadCodeView, setDeadCodeView] = useState(false);
|
||||
const [showOnlyDead, setShowOnlyDead] = useState(false);
|
||||
const [hideEntryPoints, setHideEntryPoints] = useState(false);
|
||||
const [hideTests, setHideTests] = useState(false);
|
||||
|
||||
/* Initialize filters when data loads */
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
@@ -67,7 +75,19 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
const filteredData: GraphData | null = useMemo(() => {
|
||||
if (!data) return null;
|
||||
|
||||
const nodes = data.nodes.filter((n) => enabledLabels.has(n.label));
|
||||
/* Status-based filters (dead-code view) */
|
||||
const statusOk = (n: GraphNode) => {
|
||||
if (showOnlyDead && n.status !== "dead") return false;
|
||||
if (hideEntryPoints && n.status === "entry") return false;
|
||||
if (hideTests && n.status === "test") return false;
|
||||
return true;
|
||||
};
|
||||
/* Recolor by status when the dead-code view is on */
|
||||
const paint = (n: GraphNode): GraphNode =>
|
||||
deadCodeView ? { ...n, color: colorForStatus(n.status) } : n;
|
||||
const keep = (n: GraphNode) => enabledLabels.has(n.label) && statusOk(n);
|
||||
|
||||
const nodes = data.nodes.filter(keep).map(paint);
|
||||
const nodeIds = new Set(nodes.map((n) => n.id));
|
||||
const edges = data.edges.filter(
|
||||
(e) =>
|
||||
@@ -77,7 +97,7 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
);
|
||||
|
||||
const linked_projects = data.linked_projects?.map((lp) => {
|
||||
const lpNodes = lp.nodes.filter((n) => enabledLabels.has(n.label));
|
||||
const lpNodes = lp.nodes.filter(keep).map(paint);
|
||||
const lpIds = new Set(lpNodes.map((n) => n.id));
|
||||
const lpEdges = lp.edges.filter(
|
||||
(e) =>
|
||||
@@ -91,7 +111,15 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
});
|
||||
|
||||
return { nodes, edges, total_nodes: data.total_nodes, linked_projects };
|
||||
}, [data, enabledLabels, enabledEdgeTypes]);
|
||||
}, [
|
||||
data,
|
||||
enabledLabels,
|
||||
enabledEdgeTypes,
|
||||
deadCodeView,
|
||||
showOnlyDead,
|
||||
hideEntryPoints,
|
||||
hideTests,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
@@ -101,6 +129,24 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
}
|
||||
}, [project, fetchOverview]);
|
||||
|
||||
/* Fetch git remote metadata for GitHub deep-links */
|
||||
useEffect(() => {
|
||||
if (!project) {
|
||||
setRepoInfo(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
fetch(`/api/repo-info?project=${encodeURIComponent(project)}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => {
|
||||
if (!cancelled && d && !d.error) setRepoInfo(d as RepoInfo);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [project]);
|
||||
|
||||
const handleSelectPath = useCallback(
|
||||
(path: string, nodeIds: Set<number>) => {
|
||||
if (!filteredData || !path || nodeIds.size === 0) {
|
||||
@@ -239,6 +285,14 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
onToggleShowLabels={() => setShowLabels((v) => !v)}
|
||||
onEnableAll={enableAll}
|
||||
onDisableAll={disableAll}
|
||||
deadCodeView={deadCodeView}
|
||||
showOnlyDead={showOnlyDead}
|
||||
hideEntryPoints={hideEntryPoints}
|
||||
hideTests={hideTests}
|
||||
onToggleDeadCodeView={() => setDeadCodeView((v) => !v)}
|
||||
onToggleShowOnlyDead={() => setShowOnlyDead((v) => !v)}
|
||||
onToggleHideEntryPoints={() => setHideEntryPoints((v) => !v)}
|
||||
onToggleHideTests={() => setHideTests((v) => !v)}
|
||||
/>
|
||||
<Sidebar
|
||||
nodes={filteredData.nodes}
|
||||
@@ -354,6 +408,8 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
node={selectedNode}
|
||||
allNodes={filteredData.nodes}
|
||||
allEdges={filteredData.edges}
|
||||
project={project}
|
||||
repoInfo={repoInfo}
|
||||
onClose={() => {
|
||||
setSelectedNode(null);
|
||||
setHighlightedIds(null);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NodeDetailPanel } from "./NodeDetailPanel";
|
||||
import type { GraphNode, RepoInfo } from "../lib/types";
|
||||
|
||||
/* Mock the RPC layer so "Show code" resolves without a backend. */
|
||||
const callToolMock = vi.fn();
|
||||
vi.mock("../api/rpc", () => ({
|
||||
callTool: (...args: unknown[]) => callToolMock(...args),
|
||||
RpcError: class extends Error {},
|
||||
}));
|
||||
|
||||
const NODE: GraphNode = {
|
||||
id: 7,
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
label: "Function",
|
||||
name: "render",
|
||||
file_path: "src/weird name/@mod.ts",
|
||||
qualified_name: "app::render",
|
||||
start_line: 10,
|
||||
end_line: 20,
|
||||
size: 1,
|
||||
color: "#fff",
|
||||
};
|
||||
|
||||
const REPO: RepoInfo = {
|
||||
root_path: "/repo",
|
||||
branch: "main",
|
||||
remote_url: "https://github.com/org/repo.git",
|
||||
web_base: "https://github.com/org/repo",
|
||||
blob_base: "https://github.com/org/repo/blob/main",
|
||||
};
|
||||
|
||||
describe("NodeDetailPanel code preview + deep-link", () => {
|
||||
it("renders fetched source as escaped text, never as injected HTML", async () => {
|
||||
/* A payload that would execute if the code were rendered as raw HTML. */
|
||||
const payload = "<script>window.__pwned = true;</script>\nconst answer = 42;";
|
||||
callToolMock.mockResolvedValueOnce({ source: payload });
|
||||
|
||||
const { container } = render(
|
||||
<NodeDetailPanel
|
||||
node={NODE}
|
||||
allNodes={[NODE]}
|
||||
allEdges={[]}
|
||||
project="demo"
|
||||
repoInfo={REPO}
|
||||
onClose={() => {}}
|
||||
onNavigate={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Show code/ }));
|
||||
|
||||
const pre = await screen.findByText((content) => content.includes("const answer = 42;"), {
|
||||
selector: "pre",
|
||||
});
|
||||
expect(pre.tagName).toBe("PRE");
|
||||
/* The dangerous markup is present as literal text… */
|
||||
expect(pre.textContent).toContain("<script>window.__pwned = true;</script>");
|
||||
/* …but was NOT parsed into a real <script> element, and did not execute. */
|
||||
expect(container.querySelector("script")).toBeNull();
|
||||
expect((window as unknown as { __pwned?: boolean }).__pwned).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds an https GitHub deep-link with URL-encoded path segments", () => {
|
||||
render(
|
||||
<NodeDetailPanel
|
||||
node={NODE}
|
||||
allNodes={[NODE]}
|
||||
allEdges={[]}
|
||||
project="demo"
|
||||
repoInfo={REPO}
|
||||
onClose={() => {}}
|
||||
onNavigate={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: /Open on GitHub/ });
|
||||
const href = link.getAttribute("href") ?? "";
|
||||
expect(href.startsWith("https://github.com/org/repo/blob/main/")).toBe(true);
|
||||
/* Path segments are percent-encoded; the slashes between them are kept. */
|
||||
expect(href).toContain("src/weird%20name/%40mod.ts");
|
||||
expect(href).toContain("#L10-L20");
|
||||
/* Hardening attributes for target=_blank. */
|
||||
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
|
||||
expect(link.getAttribute("target")).toBe("_blank");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { colorForLabel } from "../lib/colors";
|
||||
import type { GraphNode, GraphEdge } from "../lib/types";
|
||||
import { callTool } from "../api/rpc";
|
||||
import type { GraphNode, GraphEdge, RepoInfo } from "../lib/types";
|
||||
|
||||
interface Connection {
|
||||
node: GraphNode;
|
||||
@@ -13,11 +14,77 @@ interface NodeDetailPanelProps {
|
||||
node: GraphNode;
|
||||
allNodes: GraphNode[];
|
||||
allEdges: GraphEdge[];
|
||||
project: string | null;
|
||||
repoInfo: RepoInfo | null;
|
||||
onClose: () => void;
|
||||
onNavigate: (node: GraphNode) => void;
|
||||
}
|
||||
|
||||
export function NodeDetailPanel({ node, allNodes, allEdges, onClose, onNavigate }: NodeDetailPanelProps) {
|
||||
interface SnippetResult {
|
||||
source?: string;
|
||||
start_line?: number;
|
||||
end_line?: number;
|
||||
}
|
||||
|
||||
function lineSuffix(node: GraphNode): string {
|
||||
if (!node.start_line) return "";
|
||||
const end = node.end_line && node.end_line !== node.start_line ? `-L${node.end_line}` : "";
|
||||
return `#L${node.start_line}${end}`;
|
||||
}
|
||||
|
||||
/* Encode each path segment so an unusual file_path can't break (or escape) the
|
||||
* URL. The scheme is already https-forced by the backend (/api/repo-info);
|
||||
* this is defense-in-depth on the path. */
|
||||
function encodePath(p: string): string {
|
||||
return p.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
/* GitHub (or GitLab) deep-link, or null when we lack remote/path/line info. */
|
||||
function githubUrl(node: GraphNode, repoInfo: RepoInfo | null): string | null {
|
||||
if (!repoInfo?.blob_base || !node.file_path) return null;
|
||||
return `${repoInfo.blob_base}/${encodePath(node.file_path)}${lineSuffix(node)}`;
|
||||
}
|
||||
|
||||
export function NodeDetailPanel({
|
||||
node,
|
||||
allNodes,
|
||||
allEdges,
|
||||
project,
|
||||
repoInfo,
|
||||
onClose,
|
||||
onNavigate,
|
||||
}: NodeDetailPanelProps) {
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [codeLoading, setCodeLoading] = useState(false);
|
||||
const [codeError, setCodeError] = useState<string | null>(null);
|
||||
|
||||
/* Reset the fetched code whenever the selected node changes. */
|
||||
useEffect(() => {
|
||||
setCode(null);
|
||||
setCodeError(null);
|
||||
setCodeLoading(false);
|
||||
}, [node.id]);
|
||||
|
||||
const canFetchCode = Boolean(project && node.qualified_name);
|
||||
const ghUrl = githubUrl(node, repoInfo);
|
||||
|
||||
const loadCode = async () => {
|
||||
if (!project || !node.qualified_name) return;
|
||||
setCodeLoading(true);
|
||||
setCodeError(null);
|
||||
try {
|
||||
const res = await callTool<SnippetResult>("get_code_snippet", {
|
||||
qualified_name: node.qualified_name,
|
||||
project,
|
||||
});
|
||||
setCode(res.source ?? "(source not available)");
|
||||
} catch (e) {
|
||||
setCodeError(e instanceof Error ? e.message : "Failed to load code");
|
||||
} finally {
|
||||
setCodeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const connections = useMemo(() => {
|
||||
const nodeMap = new Map<number, GraphNode>();
|
||||
for (const n of allNodes) nodeMap.set(n.id, n);
|
||||
@@ -65,7 +132,45 @@ export function NodeDetailPanel({ node, allNodes, allEdges, onClose, onNavigate
|
||||
</div>
|
||||
|
||||
{node.file_path && (
|
||||
<p className="text-[11px] text-foreground/30 font-mono mt-2 break-all leading-relaxed">{node.file_path}</p>
|
||||
<p className="text-[11px] text-foreground/30 font-mono mt-2 break-all leading-relaxed">
|
||||
{node.file_path}
|
||||
{node.start_line ? (
|
||||
<span className="text-foreground/45">
|
||||
{" "}:{node.start_line}
|
||||
{node.end_line && node.end_line !== node.start_line ? `-${node.end_line}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Code actions */}
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2.5">
|
||||
{canFetchCode && (
|
||||
<button
|
||||
onClick={code ? () => setCode(null) : loadCode}
|
||||
disabled={codeLoading}
|
||||
className="px-2.5 py-1 rounded-md bg-primary/15 text-primary text-[11px] font-medium hover:bg-primary/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{codeLoading ? "Loading…" : code ? "Hide code" : "Show code"}
|
||||
</button>
|
||||
)}
|
||||
{ghUrl && (
|
||||
<a
|
||||
href={ghUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-2.5 py-1 rounded-md bg-white/[0.05] text-foreground/60 text-[11px] font-medium hover:bg-white/[0.09] hover:text-foreground/90 transition-colors"
|
||||
>
|
||||
Open on GitHub ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{codeError && <p className="text-[11px] text-red-400/80 mt-2">{codeError}</p>}
|
||||
{code && (
|
||||
<pre className="mt-2 max-h-[300px] overflow-auto rounded-md bg-black/40 border border-white/[0.06] p-2.5 text-[10.5px] leading-relaxed font-mono text-foreground/75 whitespace-pre">
|
||||
{code}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { Html } from "@react-three/drei";
|
||||
import type { GraphNode } from "../lib/types";
|
||||
import { colorForLabel } from "../lib/colors";
|
||||
import { colorForLabel, colorForStatus } from "../lib/colors";
|
||||
|
||||
interface NodeTooltipProps {
|
||||
node: GraphNode;
|
||||
}
|
||||
|
||||
function lineRange(node: GraphNode): string | null {
|
||||
if (!node.start_line) return null;
|
||||
if (node.end_line && node.end_line !== node.start_line)
|
||||
return `L${node.start_line}-${node.end_line}`;
|
||||
return `L${node.start_line}`;
|
||||
}
|
||||
|
||||
export function NodeTooltip({ node }: NodeTooltipProps) {
|
||||
return (
|
||||
<Html
|
||||
@@ -23,8 +30,26 @@ export function NodeTooltip({ node }: NodeTooltipProps) {
|
||||
<span className="text-white/30 ml-1 shrink-0">{node.label}</span>
|
||||
</div>
|
||||
{node.file_path && (
|
||||
<p className="text-white/30 font-mono truncate">{node.file_path}</p>
|
||||
<p className="text-white/30 font-mono truncate">
|
||||
{node.file_path}
|
||||
{lineRange(node) && <span className="text-white/40"> · {lineRange(node)}</span>}
|
||||
</p>
|
||||
)}
|
||||
{node.status && node.status !== "structural" && (
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: colorForStatus(node.status) }}
|
||||
/>
|
||||
<span className="text-white/45">{node.status}</span>
|
||||
{node.in_calls !== undefined && (
|
||||
<span className="text-white/25">
|
||||
· {node.in_calls} caller{node.in_calls === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-white/20 mt-1 text-[10px]">click for code →</p>
|
||||
</div>
|
||||
</Html>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,37 @@ export function colorForLabel(label: string): string {
|
||||
return LABEL_COLORS[label] ?? DEFAULT_COLOR;
|
||||
}
|
||||
|
||||
/* Dead-code status → color (matches layout3d.c status strings).
|
||||
* dead zero callers + zero usages, not entry/test/exported
|
||||
* single exactly one caller
|
||||
* entry entry points / routes
|
||||
* test test code
|
||||
* normal healthy (>=2 callers)
|
||||
* exported/structural → dimmed grey (not dead-code candidates) */
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
dead: "#ef4444",
|
||||
single: "#f97316",
|
||||
entry: "#3b82f6",
|
||||
test: "#a855f7",
|
||||
normal: "#22c55e",
|
||||
exported: "#475569",
|
||||
structural: "#334155",
|
||||
};
|
||||
|
||||
const STATUS_DEFAULT = "#334155";
|
||||
|
||||
export function colorForStatus(status?: string): string {
|
||||
return status ? (STATUS_COLORS[status] ?? STATUS_DEFAULT) : STATUS_DEFAULT;
|
||||
}
|
||||
|
||||
export const STATUS_LEGEND: { status: string; label: string; color: string }[] = [
|
||||
{ status: "dead", label: "Dead (0 callers)", color: STATUS_COLORS.dead },
|
||||
{ status: "single", label: "One caller", color: STATUS_COLORS.single },
|
||||
{ status: "entry", label: "Entry / route", color: STATUS_COLORS.entry },
|
||||
{ status: "test", label: "Test", color: STATUS_COLORS.test },
|
||||
{ status: "normal", label: "Normal", color: STATUS_COLORS.normal },
|
||||
];
|
||||
|
||||
/* Stellar spectral type legend (for the graph view) */
|
||||
export const STELLAR_LEGEND = [
|
||||
{ type: "O (Blue Giant)", color: "#80a0ff", description: "50+ connections" },
|
||||
|
||||
@@ -8,8 +8,32 @@ export interface GraphNode {
|
||||
label: string;
|
||||
name: string;
|
||||
file_path?: string;
|
||||
qualified_name?: string;
|
||||
start_line?: number;
|
||||
end_line?: number;
|
||||
size: number;
|
||||
color: string;
|
||||
/* Dead-code classification from the backend layout (layout3d.c). */
|
||||
status?: NodeStatus;
|
||||
in_calls?: number;
|
||||
}
|
||||
|
||||
export type NodeStatus =
|
||||
| "dead"
|
||||
| "single"
|
||||
| "entry"
|
||||
| "test"
|
||||
| "exported"
|
||||
| "normal"
|
||||
| "structural";
|
||||
|
||||
/* Git remote metadata for building GitHub deep-links (/api/repo-info). */
|
||||
export interface RepoInfo {
|
||||
root_path: string;
|
||||
branch: string;
|
||||
remote_url: string;
|
||||
web_base: string; /* https://github.com/org/repo */
|
||||
blob_base: string; /* https://github.com/org/repo/blob/<branch> */
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
|
||||
@@ -56,3 +56,4 @@ URL:https://github.com/DeusData/codebase-memory-mcp/releases/latest:version chec
|
||||
URL:http://127.0.0.1:UI server binding (localhost only)
|
||||
URL:https://www.sqlite.org/c3ref/c_checkpoint_full.html:sqlite WAL checkpoint API doc reference (comment only, not a network call)
|
||||
URL:https://github.com/DeusData/codebase-memory-mcp:project repository self-reference in update/star notice (src/mcp/mcp.c)
|
||||
URL:https://%s:GitHub blob-URL construction in src/ui/http_server.c cbm_ui_git_web_base — https-forces the repo web base for frontend deep-links (not a network call)
|
||||
+177
-1
@@ -21,6 +21,11 @@
|
||||
#include "store/store.h"
|
||||
#include "watcher/watcher.h"
|
||||
#include "cli/cli.h"
|
||||
#include "git/git_context.h"
|
||||
|
||||
#if defined(HAVE_LIBGIT2)
|
||||
#include <git2.h> /* git_repository_open, git_remote_lookup, git_remote_url */
|
||||
#endif
|
||||
/* pipeline.h no longer needed — indexing runs as subprocess */
|
||||
#include "foundation/log.h"
|
||||
#include "foundation/platform.h"
|
||||
@@ -115,7 +120,7 @@ static void handle_ui_config(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
|
||||
struct cbm_http_server {
|
||||
cbm_httpd_t *listener;
|
||||
cbm_mcp_server_t *mcp; /* own MCP server instance (read-only) */
|
||||
cbm_mcp_server_t *mcp; /* own MCP server instance (read-only) */
|
||||
struct cbm_watcher *watcher; /* external watcher ref (not owned) */
|
||||
atomic_int stop_flag;
|
||||
int port;
|
||||
@@ -169,6 +174,171 @@ static void db_path_for_project(const char *project, char *buf, size_t bufsz) {
|
||||
snprintf(buf, bufsz, "%s/%s.db", dir, project);
|
||||
}
|
||||
|
||||
/* ── Git remote → GitHub deep-link base (/api/repo-info) ───────── */
|
||||
|
||||
/* Return a copy of `url` with any "user[:password]@" userinfo removed from the
|
||||
* scheme://authority form, so credentials are never echoed back to the client.
|
||||
* scp-style (git@host:path) is returned unchanged: "git" there is a login name,
|
||||
* not a secret. malloc'd copy, or NULL when url is NULL. Caller frees. */
|
||||
char *cbm_ui_git_strip_credentials(const char *url) {
|
||||
if (!url)
|
||||
return NULL;
|
||||
const char *sep = strstr(url, "://");
|
||||
if (!sep)
|
||||
return strdup(url); /* scp-style / opaque — no scheme userinfo to strip */
|
||||
const char *authority = sep + 3;
|
||||
const char *slash = strchr(authority, '/');
|
||||
const char *at = strchr(authority, '@');
|
||||
if (!at || (slash && at > slash))
|
||||
return strdup(url); /* '@' is in the path, not the authority → no creds */
|
||||
size_t prefix = (size_t)(authority - url); /* "scheme://" */
|
||||
const char *rest = at + 1;
|
||||
size_t out_len = prefix + strlen(rest) + 1;
|
||||
char *out = malloc(out_len);
|
||||
if (!out)
|
||||
return NULL;
|
||||
memcpy(out, url, prefix);
|
||||
memcpy(out + prefix, rest, strlen(rest) + 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Normalize a git remote URL (scp-style, ssh://, https://) to a canonical
|
||||
* "https://host/org/repo" web base with any trailing ".git" and any embedded
|
||||
* credentials removed. Returns a malloc'd string or NULL if the shape isn't
|
||||
* recognized. Caller frees. */
|
||||
char *cbm_ui_git_web_base(const char *url) {
|
||||
if (!url || !url[0])
|
||||
return NULL;
|
||||
char host_path[1024] = {0}; /* "host/org/repo" */
|
||||
if (strncmp(url, "git@", 4) == 0) {
|
||||
const char *at = url + 4;
|
||||
const char *colon = strchr(at, ':');
|
||||
if (!colon)
|
||||
return NULL;
|
||||
snprintf(host_path, sizeof(host_path), "%.*s/%s", (int)(colon - at), at, colon + 1);
|
||||
} else {
|
||||
const char *p = strstr(url, "://");
|
||||
if (!p)
|
||||
return NULL;
|
||||
p += 3;
|
||||
const char *at = strchr(p, '@'); /* strip any embedded credentials */
|
||||
if (at)
|
||||
p = at + 1;
|
||||
snprintf(host_path, sizeof(host_path), "%s", p);
|
||||
}
|
||||
size_t l = strlen(host_path);
|
||||
if (l > 4 && strcmp(host_path + l - 4, ".git") == 0)
|
||||
host_path[l - 4] = '\0';
|
||||
l = strlen(host_path);
|
||||
if (l > 0 && host_path[l - 1] == '/')
|
||||
host_path[l - 1] = '\0';
|
||||
size_t out_sz = strlen(host_path) + 9; /* "https://" (8) + NUL */
|
||||
char *out = malloc(out_sz);
|
||||
if (!out)
|
||||
return NULL;
|
||||
/* Legitimate GitHub blob-URL construction, not a network call — the scheme
|
||||
* is https-forced here so the frontend deep-link can never be downgraded.
|
||||
* Allow-listed in scripts/security-allowlist.txt (URL:https://%s). */
|
||||
snprintf(out, out_sz, "https://%s", host_path);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Read the "origin" remote URL for the repo at root_path. malloc'd or NULL.
|
||||
* libgit2 is initialized once at process start by cbm_alloc_init() (which also
|
||||
* binds its allocator to mimalloc) — do NOT git_libgit2_init()/shutdown() here:
|
||||
* a per-request shutdown could drop the global refcount and tear down that
|
||||
* allocator binding mid-process. */
|
||||
static char *git_origin_remote_url(const char *root_path) {
|
||||
#if defined(HAVE_LIBGIT2)
|
||||
git_repository *repo = NULL;
|
||||
char *out = NULL;
|
||||
if (git_repository_open(&repo, root_path) == 0) {
|
||||
git_remote *rem = NULL;
|
||||
if (git_remote_lookup(&rem, repo, "origin") == 0) {
|
||||
const char *u = git_remote_url(rem);
|
||||
if (u)
|
||||
out = strdup(u);
|
||||
git_remote_free(rem);
|
||||
}
|
||||
git_repository_free(repo);
|
||||
}
|
||||
return out;
|
||||
#else
|
||||
(void)root_path;
|
||||
return NULL;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* GET /api/repo-info?project=NAME → { root_path, branch, remote_url, web_base,
|
||||
* blob_base }. blob_base is "<web_base>/blob/<branch>" ready for the frontend to
|
||||
* append "/<file_path>#L<start>-L<end>". remote_url is credential-stripped;
|
||||
* fields are empty strings when unknown (e.g. no git remote). */
|
||||
static void handle_repo_info(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
char project[256] = {0};
|
||||
if (!cbm_http_query_param(req->query, "project", project, (int)sizeof(project)) ||
|
||||
project[0] == '\0') {
|
||||
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"missing project parameter\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
char db_path[1024];
|
||||
db_path_for_project(project, db_path, sizeof(db_path));
|
||||
if (db_path[0] == '\0' || !cbm_file_exists(db_path)) {
|
||||
cbm_http_replyf(c, 404, g_cors_json, "{\"error\":\"project not found\"}");
|
||||
return;
|
||||
}
|
||||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||||
if (!store) {
|
||||
cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"cannot open store\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
char root_path[1024] = {0};
|
||||
cbm_project_t proj;
|
||||
memset(&proj, 0, sizeof(proj));
|
||||
if (cbm_store_get_project(store, project, &proj) == CBM_STORE_OK && proj.root_path) {
|
||||
snprintf(root_path, sizeof(root_path), "%s", proj.root_path);
|
||||
}
|
||||
cbm_project_free_fields(&proj);
|
||||
cbm_store_close(store);
|
||||
|
||||
char branch[256] = {0};
|
||||
if (root_path[0]) {
|
||||
cbm_git_context_t gctx;
|
||||
memset(&gctx, 0, sizeof(gctx));
|
||||
if (cbm_git_context_resolve(root_path, &gctx) == 0 && gctx.branch) {
|
||||
snprintf(branch, sizeof(branch), "%s", gctx.branch);
|
||||
}
|
||||
cbm_git_context_free(&gctx);
|
||||
}
|
||||
|
||||
char *remote = root_path[0] ? git_origin_remote_url(root_path) : NULL;
|
||||
char *remote_safe = cbm_ui_git_strip_credentials(remote); /* never echo secrets */
|
||||
char *web_base = cbm_ui_git_web_base(remote);
|
||||
|
||||
char blob_base[1152] = {0};
|
||||
if (web_base && web_base[0] && branch[0]) {
|
||||
snprintf(blob_base, sizeof(blob_base), "%s/blob/%s", web_base, branch);
|
||||
}
|
||||
|
||||
/* JSON-escape the free-form fields. */
|
||||
char esc_root[2048], esc_branch[512], esc_remote[2048], esc_web[2048], esc_blob[2304];
|
||||
cbm_json_escape(esc_root, (int)sizeof(esc_root), root_path);
|
||||
cbm_json_escape(esc_branch, (int)sizeof(esc_branch), branch);
|
||||
cbm_json_escape(esc_remote, (int)sizeof(esc_remote), remote_safe ? remote_safe : "");
|
||||
cbm_json_escape(esc_web, (int)sizeof(esc_web), web_base ? web_base : "");
|
||||
cbm_json_escape(esc_blob, (int)sizeof(esc_blob), blob_base);
|
||||
|
||||
cbm_http_replyf(c, 200, g_cors_json,
|
||||
"{\"root_path\":\"%s\",\"branch\":\"%s\",\"remote_url\":\"%s\","
|
||||
"\"web_base\":\"%s\",\"blob_base\":\"%s\"}",
|
||||
esc_root, esc_branch, esc_remote, esc_web, esc_blob);
|
||||
|
||||
free(remote);
|
||||
free(remote_safe);
|
||||
free(web_base);
|
||||
}
|
||||
|
||||
/* ── Log ring buffer ──────────────────────────────────────────── */
|
||||
|
||||
#define LOG_RING_SIZE 500
|
||||
@@ -1414,6 +1584,12 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c,
|
||||
return;
|
||||
}
|
||||
|
||||
/* GET /api/repo-info → git remote / branch for GitHub deep-links */
|
||||
if (is_get && cbm_http_path_match(req->path, "/api/repo-info*")) {
|
||||
handle_repo_info(c, req);
|
||||
return;
|
||||
}
|
||||
|
||||
/* POST /api/index → start background indexing */
|
||||
if (is_post && cbm_http_path_match(req->path, "/api/index")) {
|
||||
handle_index_start(c, req);
|
||||
|
||||
@@ -55,4 +55,16 @@ void cbm_http_server_set_binary_path(const char *path);
|
||||
/* Resolve argv[0] into an executable path suitable for subprocess spawning. */
|
||||
bool cbm_http_server_resolve_binary_path(const char *argv0, char *out, size_t outsz);
|
||||
|
||||
/* Pure git-remote URL helpers used by GET /api/repo-info. Exposed for tests. */
|
||||
|
||||
/* Normalize a git remote (scp-style / ssh:// / https://) to a canonical
|
||||
* "https://host/org/repo" web base, with trailing ".git" and any embedded
|
||||
* credentials removed. malloc'd or NULL. Caller frees. */
|
||||
char *cbm_ui_git_web_base(const char *url);
|
||||
|
||||
/* Copy `url` with any "user[:password]@" userinfo stripped from a
|
||||
* scheme://authority URL (scp-style is left unchanged). malloc'd, or NULL when
|
||||
* `url` is NULL. Caller frees. */
|
||||
char *cbm_ui_git_strip_credentials(const char *url);
|
||||
|
||||
#endif /* CBM_UI_HTTP_SERVER_H */
|
||||
@@ -37,6 +37,63 @@
|
||||
#define LOCAL_ITERATIONS 40
|
||||
#define Z_DEPTH_SPACING 50.0f /* gentle z-layering per call depth */
|
||||
|
||||
/* cbm_store_batch_count_degrees builds a bound "?,?,..." IN clause into a fixed
|
||||
* 4KB buffer (~2045 placeholders max) but binds every id passed — so calling it
|
||||
* with more ids than fit silently drops the tail (their degree stays 0, which
|
||||
* here would masquerade as dead code). Feed it in safe-sized chunks. */
|
||||
#define DEAD_DEGREE_CHUNK 500
|
||||
|
||||
/* ── Dead-code node-flag parsing ──────────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
bool is_entry;
|
||||
bool is_test;
|
||||
bool is_exported;
|
||||
bool is_route;
|
||||
} node_flags_t;
|
||||
|
||||
/* Truthy across the representations properties_json may use (JSON bool, the
|
||||
* integer 1 sqlite/json_extract emits, or a "true"/"1" string). */
|
||||
static bool json_truthy(yyjson_val *v) {
|
||||
if (!v)
|
||||
return false;
|
||||
if (yyjson_is_bool(v))
|
||||
return yyjson_get_bool(v);
|
||||
if (yyjson_is_int(v))
|
||||
return yyjson_get_int(v) != 0;
|
||||
if (yyjson_is_uint(v))
|
||||
return yyjson_get_uint(v) != 0;
|
||||
if (yyjson_is_real(v))
|
||||
return yyjson_get_real(v) != 0.0;
|
||||
if (yyjson_is_str(v)) {
|
||||
const char *s = yyjson_get_str(v);
|
||||
return s && s[0] && strcmp(s, "0") != 0 && strcmp(s, "false") != 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static node_flags_t parse_node_flags(const char *props_json) {
|
||||
node_flags_t f = {false, false, false, false};
|
||||
if (!props_json || !props_json[0])
|
||||
return f;
|
||||
yyjson_doc *d = yyjson_read(props_json, strlen(props_json), 0);
|
||||
if (!d)
|
||||
return f;
|
||||
yyjson_val *root = yyjson_doc_get_root(d);
|
||||
if (root && yyjson_is_obj(root)) {
|
||||
f.is_entry = json_truthy(yyjson_obj_get(root, "is_entry_point"));
|
||||
f.is_test = json_truthy(yyjson_obj_get(root, "is_test"));
|
||||
f.is_exported = json_truthy(yyjson_obj_get(root, "is_exported"));
|
||||
yyjson_val *rp = yyjson_obj_get(root, "route_path");
|
||||
if (rp && yyjson_is_str(rp)) {
|
||||
const char *s = yyjson_get_str(rp);
|
||||
f.is_route = s && s[0];
|
||||
}
|
||||
}
|
||||
yyjson_doc_free(d);
|
||||
return f;
|
||||
}
|
||||
|
||||
/* ── Node colors/sizes ────────────────────────────────────────── */
|
||||
|
||||
/* Stellar spectral type colors — maps node degree to star color.
|
||||
@@ -566,6 +623,27 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project,
|
||||
result->node_count = n;
|
||||
result->total_nodes = total_count;
|
||||
|
||||
/* True full-graph incoming degree for dead-code classification. This MUST
|
||||
* come from the store, not the sampled `mapped` edges built above: that set
|
||||
* drops any edge whose other endpoint falls outside the rendered
|
||||
* <=max_nodes window, which would falsely mark a sampled-in function as
|
||||
* having zero callers. */
|
||||
int64_t *node_ids = malloc((size_t)n * sizeof(int64_t));
|
||||
int *in_calls = calloc((size_t)n, sizeof(int));
|
||||
int *in_usage = calloc((size_t)n, sizeof(int));
|
||||
int *deg_dummy = calloc((size_t)n, sizeof(int));
|
||||
if (node_ids && in_calls && in_usage && deg_dummy) {
|
||||
for (int i = 0; i < n; i++)
|
||||
node_ids[i] = search_out.results[i].node.id;
|
||||
for (int off = 0; off < n; off += DEAD_DEGREE_CHUNK) {
|
||||
int cnt = (n - off < DEAD_DEGREE_CHUNK) ? (n - off) : DEAD_DEGREE_CHUNK;
|
||||
cbm_store_batch_count_degrees(store, node_ids + off, cnt, "CALLS", in_calls + off,
|
||||
deg_dummy + off);
|
||||
cbm_store_batch_count_degrees(store, node_ids + off, cnt, "USAGE", in_usage + off,
|
||||
deg_dummy + off);
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
const cbm_node_t *sn = &search_out.results[i].node;
|
||||
const char *fp = sn->file_path ? sn->file_path : "";
|
||||
@@ -608,11 +686,40 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project,
|
||||
result->nodes[i].name = sn->name ? strdup(sn->name) : NULL;
|
||||
result->nodes[i].qualified_name = sn->qualified_name ? strdup(sn->qualified_name) : NULL;
|
||||
result->nodes[i].file_path = sn->file_path ? strdup(sn->file_path) : NULL;
|
||||
result->nodes[i].start_line = sn->start_line;
|
||||
result->nodes[i].end_line = sn->end_line;
|
||||
result->nodes[i].color = stellar_color(deg[i]);
|
||||
/* Size: base from label + boost from degree (hubs are bigger stars) */
|
||||
float base_size = size_for_label(sn->label);
|
||||
float deg_boost = (deg[i] > 5) ? fminf((float)deg[i] * 0.3f, 10.0f) : 0;
|
||||
result->nodes[i].size = base_size + deg_boost;
|
||||
|
||||
/* Dead-code classification. Only Function/Method are candidates; other
|
||||
* labels are structural. Default to non-dead (1) if the batch degree
|
||||
* query failed, so a query error never masquerades as dead code. */
|
||||
node_flags_t nf = parse_node_flags(sn->properties_json);
|
||||
bool is_fn =
|
||||
sn->label && (strcmp(sn->label, "Function") == 0 || strcmp(sn->label, "Method") == 0);
|
||||
bool testish = nf.is_test || (sn->file_path && cbm_is_test_file_path(sn->file_path));
|
||||
int ic = in_calls ? in_calls[i] : 1;
|
||||
int iu = in_usage ? in_usage[i] : 1;
|
||||
const char *status;
|
||||
if (!is_fn)
|
||||
status = "structural";
|
||||
else if (testish)
|
||||
status = "test";
|
||||
else if (nf.is_entry || nf.is_route)
|
||||
status = "entry";
|
||||
else if (nf.is_exported)
|
||||
status = "exported";
|
||||
else if (ic == 0 && iu == 0)
|
||||
status = "dead";
|
||||
else if (ic == 1)
|
||||
status = "single";
|
||||
else
|
||||
status = "normal";
|
||||
result->nodes[i].in_calls = ic;
|
||||
result->nodes[i].status = status;
|
||||
}
|
||||
|
||||
/* 6. Gentle local optimization (anchor-preserving) */
|
||||
@@ -641,6 +748,10 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project,
|
||||
free(es);
|
||||
free(ed);
|
||||
free(cdepth);
|
||||
free(node_ids);
|
||||
free(in_calls);
|
||||
free(in_usage);
|
||||
free(deg_dummy);
|
||||
free_edge_array(all_edges, mapped);
|
||||
cbm_store_search_free(&search_out);
|
||||
return result;
|
||||
@@ -685,11 +796,20 @@ char *cbm_layout_to_json(const cbm_layout_result_t *r) {
|
||||
yyjson_mut_obj_add_str(doc, nd, "name", r->nodes[i].name);
|
||||
if (r->nodes[i].file_path)
|
||||
yyjson_mut_obj_add_str(doc, nd, "file_path", r->nodes[i].file_path);
|
||||
if (r->nodes[i].qualified_name)
|
||||
yyjson_mut_obj_add_str(doc, nd, "qualified_name", r->nodes[i].qualified_name);
|
||||
if (r->nodes[i].start_line > 0)
|
||||
yyjson_mut_obj_add_int(doc, nd, "start_line", r->nodes[i].start_line);
|
||||
if (r->nodes[i].end_line > 0)
|
||||
yyjson_mut_obj_add_int(doc, nd, "end_line", r->nodes[i].end_line);
|
||||
double nsz = isfinite(r->nodes[i].size) ? (double)r->nodes[i].size : 1.0;
|
||||
yyjson_mut_obj_add_real(doc, nd, "size", nsz);
|
||||
char hex[CBM_SZ_8];
|
||||
snprintf(hex, sizeof(hex), "#%06x", r->nodes[i].color);
|
||||
yyjson_mut_obj_add_strcpy(doc, nd, "color", hex);
|
||||
yyjson_mut_obj_add_int(doc, nd, "in_calls", r->nodes[i].in_calls);
|
||||
if (r->nodes[i].status)
|
||||
yyjson_mut_obj_add_str(doc, nd, "status", r->nodes[i].status);
|
||||
yyjson_mut_arr_append(na, nd);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, root, "nodes", na);
|
||||
|
||||
+8
-2
@@ -22,8 +22,14 @@ typedef struct {
|
||||
const char *name; /* display name */
|
||||
const char *qualified_name;
|
||||
const char *file_path; /* relative file path for tree reconstruction */
|
||||
float size; /* visual size */
|
||||
uint32_t color; /* 0xRRGGBB */
|
||||
int start_line; /* 1-based source range (for code snippet / GitHub link) */
|
||||
int end_line;
|
||||
float size; /* visual size */
|
||||
uint32_t color; /* 0xRRGGBB */
|
||||
int in_calls; /* incoming CALLS-family degree (full graph, not sampled) */
|
||||
/* Dead-code classification (string literal, NOT freed):
|
||||
* "dead"|"single"|"entry"|"test"|"exported"|"normal"|"structural". */
|
||||
const char *status;
|
||||
} cbm_layout_node_t;
|
||||
|
||||
/* ── Layout edge (output) ─────────────────────────────────────── */
|
||||
|
||||
+67
-8
@@ -657,8 +657,7 @@ TEST(ui_server_delete_project_unwatches_after_delete) {
|
||||
th_server_t ts;
|
||||
ASSERT_EQ(th_server_start_with_watcher(&ts, fx.watcher), 0);
|
||||
char resp[4096];
|
||||
int n =
|
||||
ui_delete_request(&ts, "/api/project?name=ui-delete-watch", resp, sizeof(resp));
|
||||
int n = ui_delete_request(&ts, "/api/project?name=ui-delete-watch", resp, sizeof(resp));
|
||||
ASSERT_GT(n, 0);
|
||||
ASSERT_EQ(th_status(resp), 200);
|
||||
ASSERT_NOT_NULL(strstr(resp, "{\"deleted\":true}"));
|
||||
@@ -686,8 +685,7 @@ TEST(ui_server_delete_project_unwatches_missing_db) {
|
||||
th_server_t ts;
|
||||
ASSERT_EQ(th_server_start_with_watcher(&ts, fx.watcher), 0);
|
||||
char resp[4096];
|
||||
int n =
|
||||
ui_delete_request(&ts, "/api/project?name=ui-delete-missing", resp, sizeof(resp));
|
||||
int n = ui_delete_request(&ts, "/api/project?name=ui-delete-missing", resp, sizeof(resp));
|
||||
ASSERT_GT(n, 0);
|
||||
ASSERT_EQ(th_status(resp), 404);
|
||||
ASSERT_NOT_NULL(strstr(resp, "{\"error\":\"project not found\"}"));
|
||||
@@ -706,8 +704,7 @@ TEST(ui_server_delete_project_no_watcher_still_deletes) {
|
||||
th_server_t ts;
|
||||
ASSERT_EQ(th_server_start(&ts), 0);
|
||||
char resp[4096];
|
||||
int n =
|
||||
ui_delete_request(&ts, "/api/project?name=ui-delete-no-watcher", resp, sizeof(resp));
|
||||
int n = ui_delete_request(&ts, "/api/project?name=ui-delete-no-watcher", resp, sizeof(resp));
|
||||
ASSERT_GT(n, 0);
|
||||
ASSERT_EQ(th_status(resp), 200);
|
||||
|
||||
@@ -772,8 +769,7 @@ TEST(ui_server_delete_project_unlink_failure_keeps_watch) {
|
||||
th_server_t ts;
|
||||
ASSERT_EQ(th_server_start_with_watcher(&ts, fx.watcher), 0);
|
||||
char resp[4096];
|
||||
int n =
|
||||
ui_delete_request(&ts, "/api/project?name=ui-delete-unlink-fails", resp, sizeof(resp));
|
||||
int n = ui_delete_request(&ts, "/api/project?name=ui-delete-unlink-fails", resp, sizeof(resp));
|
||||
ASSERT_GT(n, 0);
|
||||
ASSERT_EQ(th_status(resp), 500);
|
||||
ASSERT_NOT_NULL(strstr(resp, "{\"error\":\"failed to delete\"}"));
|
||||
@@ -905,6 +901,67 @@ TEST(ui_server_stop_joins_cleanly) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── /api/repo-info git-remote URL helpers (distilled from PR #789) ── */
|
||||
|
||||
/* The web base must always be https (deep-links can't be downgraded) and must
|
||||
* never carry embedded credentials, across scp / ssh / https remote shapes. */
|
||||
TEST(repo_info_web_base_normalizes_to_https) {
|
||||
struct {
|
||||
const char *in;
|
||||
const char *want;
|
||||
} cases[] = {
|
||||
{"git@github.com:org/repo.git", "https://github.com/org/repo"},
|
||||
{"git@github.com:org/repo", "https://github.com/org/repo"},
|
||||
{"https://github.com/org/repo.git", "https://github.com/org/repo"},
|
||||
{"ssh://git@github.com/org/repo.git", "https://github.com/org/repo"},
|
||||
{"https://user:token@github.com/org/repo.git", "https://github.com/org/repo"},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
|
||||
char *got = cbm_ui_git_web_base(cases[i].in);
|
||||
ASSERT_NOT_NULL(got);
|
||||
ASSERT_STR_EQ(got, cases[i].want);
|
||||
/* Never leak credentials into the web base. */
|
||||
ASSERT_NULL(strstr(got, "token"));
|
||||
ASSERT_NULL(strstr(got, "@"));
|
||||
free(got);
|
||||
}
|
||||
/* Unrecognized shapes yield NULL, not a bogus link. */
|
||||
ASSERT_NULL(cbm_ui_git_web_base(""));
|
||||
ASSERT_NULL(cbm_ui_git_web_base("not-a-url"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* The remote_url field echoed to the client must have any user:pass@ stripped. */
|
||||
TEST(repo_info_strips_credentials_from_remote) {
|
||||
char *safe = cbm_ui_git_strip_credentials("https://alice:s3cr3t@github.com/org/repo.git");
|
||||
ASSERT_NOT_NULL(safe);
|
||||
ASSERT_STR_EQ(safe, "https://github.com/org/repo.git");
|
||||
ASSERT_NULL(strstr(safe, "s3cr3t"));
|
||||
ASSERT_NULL(strstr(safe, "alice"));
|
||||
free(safe);
|
||||
|
||||
/* Credential-free URLs pass through unchanged. */
|
||||
char *plain = cbm_ui_git_strip_credentials("https://github.com/org/repo.git");
|
||||
ASSERT_NOT_NULL(plain);
|
||||
ASSERT_STR_EQ(plain, "https://github.com/org/repo.git");
|
||||
free(plain);
|
||||
|
||||
/* An '@' in the path (not the authority) must not be treated as creds. */
|
||||
char *pathat = cbm_ui_git_strip_credentials("https://github.com/org/repo/@scope");
|
||||
ASSERT_NOT_NULL(pathat);
|
||||
ASSERT_STR_EQ(pathat, "https://github.com/org/repo/@scope");
|
||||
free(pathat);
|
||||
|
||||
/* scp-style carries no secret and is left intact. */
|
||||
char *scp = cbm_ui_git_strip_credentials("git@github.com:org/repo.git");
|
||||
ASSERT_NOT_NULL(scp);
|
||||
ASSERT_STR_EQ(scp, "git@github.com:org/repo.git");
|
||||
free(scp);
|
||||
|
||||
ASSERT_NULL(cbm_ui_git_strip_credentials(NULL));
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Suite ────────────────────────────────────────────────────── */
|
||||
|
||||
SUITE(httpd) {
|
||||
@@ -925,6 +982,8 @@ SUITE(httpd) {
|
||||
RUN_TEST(httpd_query_param_edge_cases);
|
||||
RUN_TEST(httpd_path_match_matrix);
|
||||
RUN_TEST(httpd_resolves_bare_binary_path_from_path);
|
||||
RUN_TEST(repo_info_web_base_normalizes_to_https);
|
||||
RUN_TEST(repo_info_strips_credentials_from_remote);
|
||||
|
||||
/* Transport */
|
||||
RUN_TEST(httpd_listen_ephemeral_port);
|
||||
|
||||
+152
@@ -455,6 +455,157 @@ TEST(layout_null_inputs) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Dead-code classification (distilled from PR #789) ────────── */
|
||||
|
||||
static const cbm_layout_node_t *find_layout_node(const cbm_layout_result_t *r, const char *name) {
|
||||
for (int i = 0; i < r->node_count; i++) {
|
||||
if (r->nodes[i].name && strcmp(r->nodes[i].name, name) == 0) {
|
||||
return &r->nodes[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* A function with zero callers/usages and no entry/test/exported flag is
|
||||
* "dead"; entry-point, test, and exported functions are NOT dead even at zero
|
||||
* callers; a called function reports its true full-graph incoming CALLS degree
|
||||
* ("single" at 1, "normal" at >=2). Non-Function labels are "structural". */
|
||||
TEST(layout_dead_code_classification) {
|
||||
cbm_store_t *store = cbm_store_open_memory();
|
||||
ASSERT_NOT_NULL(store);
|
||||
ASSERT_EQ(cbm_store_upsert_project(store, "dc", "/tmp/dc"), CBM_STORE_OK);
|
||||
|
||||
/* Candidates (Function, non-test path unless noted). */
|
||||
cbm_node_t dead = {.project = "dc",
|
||||
.label = "Function",
|
||||
.name = "deadfn",
|
||||
.qualified_name = "dc::deadfn",
|
||||
.file_path = "src/a.c",
|
||||
.properties_json = "{\"is_entry_point\":false,\"is_test\":false,"
|
||||
"\"is_exported\":false}"};
|
||||
cbm_node_t entry = {.project = "dc",
|
||||
.label = "Function",
|
||||
.name = "entryfn",
|
||||
.qualified_name = "dc::entryfn",
|
||||
.file_path = "src/b.c",
|
||||
.properties_json = "{\"is_entry_point\":true}"};
|
||||
cbm_node_t tst = {.project = "dc",
|
||||
.label = "Function",
|
||||
.name = "testfn",
|
||||
.qualified_name = "dc::testfn",
|
||||
.file_path = "src/c.c",
|
||||
.properties_json = "{\"is_test\":true}"};
|
||||
cbm_node_t tstpath = {.project = "dc",
|
||||
.label = "Function",
|
||||
.name = "bypathfn",
|
||||
.qualified_name = "dc::bypathfn",
|
||||
.file_path = "tests/mod_helpers.c",
|
||||
.properties_json = "{}"};
|
||||
cbm_node_t exp = {.project = "dc",
|
||||
.label = "Function",
|
||||
.name = "exportedfn",
|
||||
.qualified_name = "dc::exportedfn",
|
||||
.file_path = "src/d.c",
|
||||
.properties_json = "{\"is_exported\":true}"};
|
||||
cbm_node_t single = {.project = "dc",
|
||||
.label = "Function",
|
||||
.name = "calledonce",
|
||||
.qualified_name = "dc::calledonce",
|
||||
.file_path = "src/e.c",
|
||||
.properties_json = "{}"};
|
||||
cbm_node_t norm = {.project = "dc",
|
||||
.label = "Function",
|
||||
.name = "callednormal",
|
||||
.qualified_name = "dc::callednormal",
|
||||
.file_path = "src/f.c",
|
||||
.properties_json = "{}"};
|
||||
cbm_node_t caller = {.project = "dc",
|
||||
.label = "Function",
|
||||
.name = "caller",
|
||||
.qualified_name = "dc::caller",
|
||||
.file_path = "src/g.c",
|
||||
.properties_json = "{}"};
|
||||
/* A structural (non-Function) node is never a dead-code candidate. */
|
||||
cbm_node_t cls = {.project = "dc",
|
||||
.label = "Class",
|
||||
.name = "SomeClass",
|
||||
.qualified_name = "dc::SomeClass",
|
||||
.file_path = "src/h.c",
|
||||
.properties_json = "{}"};
|
||||
|
||||
int64_t id_dead = cbm_store_upsert_node(store, &dead);
|
||||
cbm_store_upsert_node(store, &entry);
|
||||
cbm_store_upsert_node(store, &tst);
|
||||
cbm_store_upsert_node(store, &tstpath);
|
||||
cbm_store_upsert_node(store, &exp);
|
||||
int64_t id_single = cbm_store_upsert_node(store, &single);
|
||||
int64_t id_norm = cbm_store_upsert_node(store, &norm);
|
||||
int64_t id_caller = cbm_store_upsert_node(store, &caller);
|
||||
cbm_store_upsert_node(store, &cls);
|
||||
ASSERT_GT(id_dead, 0);
|
||||
|
||||
/* calledonce ← 1 CALLS; callednormal ← 2 CALLS (full-graph inbound). */
|
||||
cbm_edge_t e1 = {
|
||||
.project = "dc", .source_id = id_caller, .target_id = id_single, .type = "CALLS"};
|
||||
cbm_edge_t e2 = {
|
||||
.project = "dc", .source_id = id_caller, .target_id = id_norm, .type = "CALLS"};
|
||||
cbm_edge_t e3 = {.project = "dc", .source_id = id_dead, .target_id = id_norm, .type = "CALLS"};
|
||||
cbm_store_insert_edge(store, &e1);
|
||||
cbm_store_insert_edge(store, &e2);
|
||||
cbm_store_insert_edge(store, &e3);
|
||||
|
||||
cbm_layout_result_t *r = cbm_layout_compute(store, "dc", CBM_LAYOUT_OVERVIEW, NULL, 0, 100);
|
||||
ASSERT_NOT_NULL(r);
|
||||
|
||||
const cbm_layout_node_t *ln;
|
||||
|
||||
ln = find_layout_node(r, "deadfn");
|
||||
ASSERT_NOT_NULL(ln);
|
||||
ASSERT_STR_EQ(ln->status, "dead");
|
||||
ASSERT_EQ(ln->in_calls, 0);
|
||||
|
||||
ln = find_layout_node(r, "entryfn");
|
||||
ASSERT_NOT_NULL(ln);
|
||||
ASSERT_STR_EQ(ln->status, "entry");
|
||||
|
||||
ln = find_layout_node(r, "testfn");
|
||||
ASSERT_NOT_NULL(ln);
|
||||
ASSERT_STR_EQ(ln->status, "test");
|
||||
|
||||
ln = find_layout_node(r, "bypathfn"); /* test detected via file path */
|
||||
ASSERT_NOT_NULL(ln);
|
||||
ASSERT_STR_EQ(ln->status, "test");
|
||||
|
||||
ln = find_layout_node(r, "exportedfn");
|
||||
ASSERT_NOT_NULL(ln);
|
||||
ASSERT_STR_EQ(ln->status, "exported");
|
||||
|
||||
ln = find_layout_node(r, "calledonce");
|
||||
ASSERT_NOT_NULL(ln);
|
||||
ASSERT_STR_EQ(ln->status, "single");
|
||||
ASSERT_EQ(ln->in_calls, 1);
|
||||
|
||||
ln = find_layout_node(r, "callednormal");
|
||||
ASSERT_NOT_NULL(ln);
|
||||
ASSERT_STR_EQ(ln->status, "normal");
|
||||
ASSERT_EQ(ln->in_calls, 2);
|
||||
|
||||
ln = find_layout_node(r, "SomeClass");
|
||||
ASSERT_NOT_NULL(ln);
|
||||
ASSERT_STR_EQ(ln->status, "structural");
|
||||
|
||||
/* The classification must survive JSON serialization. */
|
||||
char *json = cbm_layout_to_json(r);
|
||||
ASSERT_NOT_NULL(json);
|
||||
ASSERT(strstr(json, "\"status\":\"dead\"") != NULL);
|
||||
ASSERT(strstr(json, "\"in_calls\":2") != NULL);
|
||||
free(json);
|
||||
|
||||
cbm_layout_free(r);
|
||||
cbm_store_close(store);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Octree recursion guard (distilled from PR #821; refs #498/#726/#402) ── */
|
||||
|
||||
/* Bodies that share a position made octree_insert subdivide forever — the
|
||||
@@ -611,5 +762,6 @@ SUITE(ui) {
|
||||
RUN_TEST(layout_deterministic);
|
||||
RUN_TEST(layout_to_json);
|
||||
RUN_TEST(layout_null_inputs);
|
||||
RUN_TEST(layout_dead_code_classification);
|
||||
RUN_TEST(layout_coincident_nodes_bounded);
|
||||
}
|
||||
Reference in new issue
Block a user