mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Merge pull request #657 from DeusData/feat/mcp-protocol-2025-11-25
feat(mcp): add structured tool metadata
This commit is contained in:
35 files changed
+1867
-312
No files matched your search
+15
-12
@@ -3,16 +3,17 @@ import { GraphTab } from "./components/GraphTab";
|
||||
import { StatsTab } from "./components/StatsTab";
|
||||
import { ControlTab } from "./components/ControlTab";
|
||||
import type { TabId } from "./lib/types";
|
||||
|
||||
const TABS: { id: TabId; label: string }[] = [
|
||||
{ id: "graph", label: "Graph" },
|
||||
{ id: "stats", label: "Projects" },
|
||||
{ id: "control", label: "Control" },
|
||||
];
|
||||
import { useUiMessages } from "./lib/i18n";
|
||||
|
||||
export function App() {
|
||||
const t = useUiMessages();
|
||||
const [activeTab, setActiveTab] = useState<TabId>("stats");
|
||||
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
||||
const tabs: { id: TabId; label: string }[] = [
|
||||
{ id: "graph", label: t.tabs.graph },
|
||||
{ id: "stats", label: t.tabs.projects },
|
||||
{ id: "control", label: t.tabs.control },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-background text-foreground">
|
||||
@@ -28,17 +29,17 @@ export function App() {
|
||||
|
||||
{/* Tabs inline in header */}
|
||||
<nav className="flex items-center gap-0.5">
|
||||
{TABS.map((t) => (
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-3 py-1 rounded-md text-[12px] font-medium transition-all ${
|
||||
activeTab === t.id
|
||||
activeTab === tab.id
|
||||
? "bg-primary/15 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-white/[0.04]"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
@@ -46,7 +47,9 @@ export function App() {
|
||||
|
||||
{selectedProject && (
|
||||
<div className="flex items-center gap-2 px-3 py-1 rounded-lg bg-white/[0.04] border border-border/30">
|
||||
<span className="text-[10px] text-foreground/30 uppercase tracking-wider">Graph</span>
|
||||
<span className="text-[10px] text-foreground/30 uppercase tracking-wider">
|
||||
{t.graph.selectedLabel}
|
||||
</span>
|
||||
<span className="text-[11px] text-primary font-mono truncate max-w-[300px]">
|
||||
{selectedProject}
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import type { ProcessInfo } from "../lib/types";
|
||||
import { useUiMessages } from "../lib/i18n";
|
||||
|
||||
/* ── Gauge component ────────────────────────────────────── */
|
||||
|
||||
@@ -30,6 +31,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
|
||||
proc: ProcessInfo; selected: boolean;
|
||||
onSelect: () => void; onKill: () => void;
|
||||
}) {
|
||||
const t = useUiMessages();
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
@@ -46,7 +48,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
|
||||
PID {proc.pid}
|
||||
</span>
|
||||
{proc.is_self && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-primary/15 text-primary font-medium">THIS</span>
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-primary/15 text-primary font-medium">{t.control.thisProcess}</span>
|
||||
)}
|
||||
</div>
|
||||
{!proc.is_self && (
|
||||
@@ -54,7 +56,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
|
||||
onClick={(e) => { e.stopPropagation(); onKill(); }}
|
||||
className="px-2 py-1 rounded-lg text-[10px] text-foreground/20 hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||
>
|
||||
Kill
|
||||
{t.control.kill}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -69,7 +71,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
|
||||
<p className="text-[13px] font-semibold tabular-nums text-foreground/70">{proc.rss_mb.toFixed(0)} MB</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[9px] text-foreground/20 uppercase">Uptime</p>
|
||||
<p className="text-[9px] text-foreground/20 uppercase">{t.control.uptime}</p>
|
||||
<p className="text-[13px] font-semibold tabular-nums text-foreground/70">{proc.elapsed}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,6 +84,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
|
||||
/* ── Log viewer ─────────────────────────────────────────── */
|
||||
|
||||
function LogViewer() {
|
||||
const t = useUiMessages();
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -100,13 +103,13 @@ function LogViewer() {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/30 bg-black/30 overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-border/20">
|
||||
<span className="text-[11px] font-medium text-foreground/40">Process Logs</span>
|
||||
<span className="text-[11px] font-medium text-foreground/40">{t.control.processLogs}</span>
|
||||
<span className="text-[10px] text-foreground/15 ml-2">{lines.length} lines</span>
|
||||
</div>
|
||||
<ScrollArea className="h-[400px]">
|
||||
<div className="p-3 font-mono text-[10px] leading-relaxed">
|
||||
{lines.length === 0 ? (
|
||||
<p className="text-foreground/15 text-center py-8">No logs yet</p>
|
||||
<p className="text-foreground/15 text-center py-8">{t.control.noLogs}</p>
|
||||
) : (
|
||||
lines.map((line, i) => {
|
||||
const isErr = line.includes("level=error");
|
||||
@@ -132,6 +135,7 @@ function LogViewer() {
|
||||
/* ── Main Control Tab ───────────────────────────────────── */
|
||||
|
||||
export function ControlTab() {
|
||||
const t = useUiMessages();
|
||||
const [processes, setProcesses] = useState<ProcessInfo[]>([]);
|
||||
const [selfMetrics, setSelfMetrics] = useState({ rss_mb: 0, user_cpu: 0, sys_cpu: 0 });
|
||||
const [selectedPid, setSelectedPid] = useState<number | null>(null);
|
||||
@@ -156,7 +160,7 @@ export function ControlTab() {
|
||||
}, [fetchProcesses]);
|
||||
|
||||
const killProcess = useCallback(async (pid: number) => {
|
||||
if (!confirm(`Kill process ${pid}?`)) return;
|
||||
if (!confirm(t.control.killConfirm(pid))) return;
|
||||
try {
|
||||
await fetch("/api/process-kill", {
|
||||
method: "POST",
|
||||
@@ -165,7 +169,7 @@ export function ControlTab() {
|
||||
});
|
||||
setTimeout(fetchProcesses, 1000);
|
||||
} catch { /* ignore */ }
|
||||
}, [fetchProcesses]);
|
||||
}, [fetchProcesses, t.control]);
|
||||
|
||||
/* Aggregates */
|
||||
const totalCpu = processes.reduce((s, p) => s + p.cpu, 0);
|
||||
@@ -174,32 +178,32 @@ export function ControlTab() {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<h2 className="text-[15px] font-semibold text-foreground/80 mb-6">Control Panel</h2>
|
||||
<h2 className="text-[15px] font-semibold text-foreground/80 mb-6">{t.control.panel}</h2>
|
||||
|
||||
{/* Aggregate gauges */}
|
||||
<div className="flex gap-4 mb-8">
|
||||
<Gauge label="Total CPU" value={totalCpu} max={100 * processes.length || 100} unit="%" color="text-foreground/80" />
|
||||
<Gauge label="Total RAM" value={totalRam} max={4096} unit="MB" color="text-foreground/80" />
|
||||
<Gauge label="Processes" value={processes.length} max={10} unit="" color="text-primary" />
|
||||
<Gauge label="Self RAM" value={selfMetrics.rss_mb} max={2048} unit="MB" color="text-primary" />
|
||||
<Gauge label={t.control.totalCpu} value={totalCpu} max={100 * processes.length || 100} unit="%" color="text-foreground/80" />
|
||||
<Gauge label={t.control.totalRam} value={totalRam} max={4096} unit="MB" color="text-foreground/80" />
|
||||
<Gauge label={t.control.processes} value={processes.length} max={10} unit="" color="text-primary" />
|
||||
<Gauge label={t.control.selfRam} value={selfMetrics.rss_mb} max={2048} unit="MB" color="text-primary" />
|
||||
</div>
|
||||
|
||||
{/* Process grid */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-[13px] font-medium text-foreground/50">
|
||||
Active Processes
|
||||
{t.control.activeProcesses}
|
||||
</h3>
|
||||
<button
|
||||
onClick={fetchProcesses}
|
||||
className="text-[11px] text-primary/60 hover:text-primary transition-colors"
|
||||
>
|
||||
Refresh
|
||||
{t.common.refresh}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{processes.length === 0 ? (
|
||||
<p className="text-foreground/20 text-[12px] text-center py-8">No processes found</p>
|
||||
<p className="text-foreground/20 text-[12px] text-center py-8">{t.control.noProcesses}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{processes.map((p) => (
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GRAPH_CANVAS_DPR } from "./GraphScene";
|
||||
|
||||
describe("GraphScene render limits", () => {
|
||||
it("caps the high-DPI WebGL backing store below the MSAA failure range", () => {
|
||||
expect(GRAPH_CANVAS_DPR[0]).toBe(1);
|
||||
expect(GRAPH_CANVAS_DPR[1]).toBeLessThanOrEqual(1.5);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,8 @@ function CameraAnimator({ target }: { target: CameraTarget | null }) {
|
||||
/* ── Idle auto-rotation ──────────────────────────────────── */
|
||||
|
||||
const IDLE_TIMEOUT_MS = 60_000;
|
||||
export const GRAPH_CANVAS_DPR: [number, number] = [1, 1.5];
|
||||
export const GRAPH_COMPOSER_MULTISAMPLING = 0;
|
||||
|
||||
function IdleAutoRotate({
|
||||
controlsRef,
|
||||
@@ -108,8 +110,12 @@ export function GraphScene({
|
||||
<Canvas
|
||||
camera={{ position: [0, 0, 800], fov: 50, near: 0.1, far: 100000 }}
|
||||
style={{ background: "#06090f" }}
|
||||
dpr={[1, 2]}
|
||||
gl={{ antialias: true, alpha: false }}
|
||||
dpr={GRAPH_CANVAS_DPR}
|
||||
gl={{
|
||||
antialias: false,
|
||||
alpha: false,
|
||||
powerPreference: "high-performance",
|
||||
}}
|
||||
>
|
||||
<color attach="background" args={["#06090f"]} />
|
||||
<ambientLight intensity={0.5} />
|
||||
@@ -176,7 +182,7 @@ export function GraphScene({
|
||||
<CameraAnimator target={cameraTarget} />
|
||||
<IdleAutoRotate controlsRef={controlsRef} />
|
||||
|
||||
<EffectComposer>
|
||||
<EffectComposer multisampling={GRAPH_COMPOSER_MULTISAMPLING}>
|
||||
<Bloom
|
||||
luminanceThreshold={0.3}
|
||||
luminanceSmoothing={0.7}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatGraphLimitNotice } from "./GraphTab";
|
||||
import type { GraphData } from "../lib/types";
|
||||
|
||||
describe("formatGraphLimitNotice", () => {
|
||||
it("reports when the graph response is truncated for render safety", () => {
|
||||
const data = {
|
||||
nodes: Array.from({ length: 2000 }, (_, id) => ({
|
||||
id,
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
label: "Function",
|
||||
name: `fn${id}`,
|
||||
size: 1,
|
||||
color: "#ffffff",
|
||||
})),
|
||||
edges: [],
|
||||
total_nodes: 43729,
|
||||
} satisfies GraphData;
|
||||
|
||||
expect(formatGraphLimitNotice(data)).toBe(
|
||||
"Showing 2,000 of 43,729 nodes. Use filters to narrow.",
|
||||
);
|
||||
});
|
||||
|
||||
it("stays quiet when the full graph is rendered", () => {
|
||||
const data = {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
total_nodes: 0,
|
||||
} satisfies GraphData;
|
||||
|
||||
expect(formatGraphLimitNotice(data)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,11 @@ interface GraphTabProps {
|
||||
project: string | null;
|
||||
}
|
||||
|
||||
export function formatGraphLimitNotice(data: GraphData | null): string | null {
|
||||
if (!data || data.total_nodes <= data.nodes.length) return null;
|
||||
return `Showing ${data.nodes.length.toLocaleString()} of ${data.total_nodes.toLocaleString()} nodes. Use filters to narrow.`;
|
||||
}
|
||||
|
||||
export function GraphTab({ project }: GraphTabProps) {
|
||||
const { data, loading, error, fetchOverview } = useGraphData();
|
||||
const [highlightedIds, setHighlightedIds] = useState<Set<number> | null>(null);
|
||||
@@ -38,6 +43,7 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
const [showLabels, setShowLabels] = useState(true);
|
||||
const [leftWidth, setLeftWidth] = useState(() => loadWidth("cbm-left-w", 260));
|
||||
const [rightWidth, setRightWidth] = useState(() => loadWidth("cbm-right-w", 280));
|
||||
const limitNotice = formatGraphLimitNotice(data);
|
||||
|
||||
/* Filter state — all enabled by default */
|
||||
const [enabledLabels, setEnabledLabels] = useState<Set<string>>(new Set());
|
||||
@@ -282,6 +288,9 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
filtered from {data.nodes.length.toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
{limitNotice && (
|
||||
<p className="text-amber-300/80 mt-0.5">{limitNotice}</p>
|
||||
)}
|
||||
{highlightedIds && highlightedIds.size > 0 && (
|
||||
<p className="text-cyan-400/50 mt-0.5">
|
||||
{highlightedIds.size} selected
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import type { GraphNode } from "../lib/types";
|
||||
import { useUiMessages } from "../lib/i18n";
|
||||
|
||||
interface SidebarProps {
|
||||
nodes: GraphNode[];
|
||||
@@ -105,6 +106,7 @@ function TreeItem({ dir, depth, onSelect, selectedPath }: {
|
||||
}
|
||||
|
||||
export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
|
||||
const t = useUiMessages();
|
||||
const [search, setSearch] = useState("");
|
||||
const tree = useMemo(() => flattenSingleChild(buildFileTree(nodes)), [nodes]);
|
||||
|
||||
@@ -122,7 +124,7 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
placeholder={t.graph.search}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-1.5 text-[12px] text-foreground placeholder-foreground/25 outline-none focus:border-primary/40 focus:bg-white/[0.06] transition-all"
|
||||
@@ -134,7 +136,9 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
|
||||
<div className="py-1">
|
||||
{filtered ? (
|
||||
filtered.length === 0 ? (
|
||||
<p className="text-foreground/20 text-[12px] px-4 py-6 text-center">No matches</p>
|
||||
<p className="text-foreground/20 text-[12px] px-4 py-6 text-center">
|
||||
{t.common.noMatches}
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((n) => (
|
||||
<button
|
||||
@@ -160,7 +164,7 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
|
||||
onClick={() => onSelectPath("", new Set())}
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-white/[0.04] hover:bg-white/[0.07] text-[11px] text-foreground/40 font-medium transition-all"
|
||||
>
|
||||
Clear selection
|
||||
{t.graph.clearSelection}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { StatsTab } from "./StatsTab";
|
||||
|
||||
function mockProjectsFetch(extra?: (url: string, init?: RequestInit) => Response | undefined) {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const overridden = extra?.(url, init);
|
||||
if (overridden) return overridden;
|
||||
if (url === "/rpc") {
|
||||
return new Response(JSON.stringify({
|
||||
result: { content: [{ text: JSON.stringify({ projects: [] }) }] },
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
if (url.startsWith("/api/ui-config")) {
|
||||
return new Response(JSON.stringify({ lang: "en" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url.startsWith("/api/browse")) {
|
||||
return new Response(JSON.stringify({
|
||||
path: "/home/dev",
|
||||
parent: "/home",
|
||||
dirs: ["alpha", "beta"],
|
||||
roots: ["/", "D:/"],
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
if (url === "/api/index") {
|
||||
return new Response(JSON.stringify({ status: "indexing", slot: 0 }), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
describe("StatsTab index modal", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("submits a custom path and project name", async () => {
|
||||
let submitted: unknown = null;
|
||||
mockProjectsFetch((url, init) => {
|
||||
if (url === "/api/index") {
|
||||
submitted = JSON.parse(String(init?.body));
|
||||
return new Response(JSON.stringify({ status: "indexing", slot: 0 }), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
render(<StatsTab onSelectProject={() => {}} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" }));
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("Repository path"), {
|
||||
target: { value: "D:\\work\\信租风控通后端" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Project name"), {
|
||||
target: { value: "信租风控通后端" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Index This Folder" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitted).toEqual({
|
||||
root_path: "D:\\work\\信租风控通后端",
|
||||
project_name: "信租风控通后端",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("filters picker rows and exposes quick row indexing", async () => {
|
||||
mockProjectsFetch();
|
||||
|
||||
render(<StatsTab onSelectProject={() => {}} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" }));
|
||||
|
||||
fireEvent.change(await screen.findByPlaceholderText("Filter folders"), {
|
||||
target: { value: "bet" },
|
||||
});
|
||||
|
||||
expect(screen.queryByText("alpha")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("beta")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Index beta" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Browse D:/" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo, useState, useCallback, useEffect } from "react";
|
||||
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useProjects } from "../hooks/useProjects";
|
||||
import { colorForLabel } from "../lib/colors";
|
||||
import { useUiMessages } from "../lib/i18n";
|
||||
|
||||
interface StatsTabProps {
|
||||
onSelectProject: (project: string) => void;
|
||||
@@ -10,6 +11,7 @@ interface StatsTabProps {
|
||||
/* ── Glowy health dot ───────────────────────────────────── */
|
||||
|
||||
function HealthDot({ name }: { name: string }) {
|
||||
const t = useUiMessages();
|
||||
const [status, setStatus] = useState<"loading" | "healthy" | "corrupt" | "missing">("loading");
|
||||
const [info, setInfo] = useState("");
|
||||
|
||||
@@ -34,9 +36,9 @@ function HealthDot({ name }: { name: string }) {
|
||||
status === "corrupt" ? "#f87171" : "#555";
|
||||
|
||||
const label =
|
||||
status === "healthy" ? "Database healthy" :
|
||||
status === "missing" ? "Database missing" :
|
||||
status === "corrupt" ? "Database unhealthy" : "Checking...";
|
||||
status === "healthy" ? t.projects.healthHealthy :
|
||||
status === "missing" ? t.projects.healthMissing :
|
||||
status === "corrupt" ? t.projects.healthCorrupt : t.projects.healthChecking;
|
||||
|
||||
return (
|
||||
<div className="group relative inline-flex items-center">
|
||||
@@ -64,6 +66,7 @@ function HealthDot({ name }: { name: string }) {
|
||||
/* ── ADR button + modal ─────────────────────────────────── */
|
||||
|
||||
function AdrButton({ project }: { project: string }) {
|
||||
const t = useUiMessages();
|
||||
const [hasAdr, setHasAdr] = useState<boolean | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [content, setContent] = useState("");
|
||||
@@ -117,13 +120,13 @@ function AdrButton({ project }: { project: string }) {
|
||||
<div className="relative bg-[#0e2028] border border-border/40 rounded-2xl p-6 w-full max-w-2xl shadow-2xl max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-[15px] font-semibold text-foreground/90">Architecture Decision Record</h3>
|
||||
<h3 className="text-[15px] font-semibold text-foreground/90">{t.adr.title}</h3>
|
||||
<p className="text-[11px] text-foreground/30 font-mono mt-0.5">{project}</p>
|
||||
</div>
|
||||
<button onClick={() => setOpen(false)} className="text-foreground/20 hover:text-foreground/50 text-[16px] p-1">×</button>
|
||||
</div>
|
||||
{updatedAt && (
|
||||
<p className="text-[10px] text-foreground/20 mb-3">Last updated: {updatedAt}</p>
|
||||
<p className="text-[10px] text-foreground/20 mb-3">{t.adr.lastUpdated}: {updatedAt}</p>
|
||||
)}
|
||||
<textarea
|
||||
value={content}
|
||||
@@ -139,12 +142,12 @@ function AdrButton({ project }: { project: string }) {
|
||||
}}
|
||||
className="px-3 py-2 rounded-lg text-[12px] text-destructive/60 hover:text-destructive hover:bg-destructive/10 font-medium transition-all"
|
||||
>
|
||||
Delete
|
||||
{t.common.delete}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setOpen(false)} className="px-4 py-2 rounded-lg text-[12px] text-foreground/40 hover:bg-white/[0.04] font-medium transition-all">Cancel</button>
|
||||
<button onClick={() => setOpen(false)} className="px-4 py-2 rounded-lg text-[12px] text-foreground/40 hover:bg-white/[0.04] font-medium transition-all">{t.common.cancel}</button>
|
||||
<button onClick={save} disabled={saving} className="px-4 py-2 rounded-lg bg-primary/20 hover:bg-primary/30 text-primary text-[12px] font-medium transition-all disabled:opacity-30">
|
||||
{saving ? "Saving..." : "Save"}
|
||||
{saving ? t.common.saving : t.common.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,16 +159,30 @@ function AdrButton({ project }: { project: string }) {
|
||||
|
||||
/* ── Create Index Modal ─────────────────────────────────── */
|
||||
|
||||
function joinPath(base: string, dir: string): string {
|
||||
if (!base || base === "/") return `/${dir}`;
|
||||
if (/^[A-Za-z]:[\\/]?$/.test(base)) return `${base[0]}:/${dir}`;
|
||||
const slash = base.includes("\\") && !base.includes("/") ? "\\" : "/";
|
||||
return `${base.replace(/[\\/]+$/, "")}${slash}${dir}`;
|
||||
}
|
||||
|
||||
function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||
const t = useUiMessages();
|
||||
const [currentPath, setCurrentPath] = useState("");
|
||||
const [dirs, setDirs] = useState<string[]>([]);
|
||||
const [roots, setRoots] = useState<string[]>(["/"]);
|
||||
const [parentPath, setParentPath] = useState("");
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const filterRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const browse = useCallback(async (path?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const q = path ? `?path=${encodeURIComponent(path)}` : "";
|
||||
const res = await fetch(`/api/browse${q}`);
|
||||
@@ -173,18 +190,30 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
|
||||
if (data.error) throw new Error(data.error);
|
||||
setCurrentPath(data.path ?? "");
|
||||
setDirs((data.dirs ?? []).sort());
|
||||
setRoots(data.roots ?? ["/"]);
|
||||
setParentPath(data.parent ?? "/");
|
||||
} catch (e) { setError(e instanceof Error ? e.message : "Browse failed"); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { browse(); }, [browse]);
|
||||
useEffect(() => { filterRef.current?.focus(); }, []);
|
||||
|
||||
const submit = async () => {
|
||||
if (!currentPath) return;
|
||||
const filteredDirs = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
if (!q) return dirs;
|
||||
return dirs.filter((d) => d.toLowerCase().includes(q));
|
||||
}, [dirs, filter]);
|
||||
|
||||
useEffect(() => { setActiveIndex(0); }, [filter, currentPath]);
|
||||
|
||||
const submit = async (path = currentPath) => {
|
||||
if (!path) return;
|
||||
setSubmitting(true); setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/index", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ root_path: currentPath }) });
|
||||
const body: { root_path: string; project_name?: string } = { root_path: path };
|
||||
if (projectName.trim()) body.project_name = projectName.trim();
|
||||
const res = await fetch("/api/index", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "Failed");
|
||||
onCreated(); onClose();
|
||||
@@ -192,17 +221,78 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
|
||||
finally { setSubmitting(false); }
|
||||
};
|
||||
|
||||
const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, Math.max(filteredDirs.length - 1, 0)));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter" && filteredDirs.length > 0) {
|
||||
e.preventDefault();
|
||||
const dir = filteredDirs.length === 1 ? filteredDirs[0] : filteredDirs[activeIndex];
|
||||
if (filteredDirs.length === 1) void submit(joinPath(currentPath, dir));
|
||||
else void browse(joinPath(currentPath, dir));
|
||||
}
|
||||
};
|
||||
|
||||
/* Breadcrumb segments */
|
||||
const segments = currentPath.split("/").filter(Boolean);
|
||||
const displayPath = currentPath.replace(/\\/g, "/");
|
||||
const segments = displayPath.split("/").filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<div className="relative bg-[#0e2028] border border-border/40 rounded-2xl w-full max-w-lg shadow-2xl flex flex-col overflow-hidden" style={{ height: "min(70vh, 550px)" }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="relative bg-[#0e2028] border border-border/40 rounded-2xl w-full max-w-2xl shadow-2xl flex flex-col overflow-hidden" style={{ height: "min(82vh, 680px)" }} onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="px-5 pt-5 pb-3 shrink-0">
|
||||
<h3 className="text-[15px] font-semibold text-foreground/90 mb-1">Select Repository Folder</h3>
|
||||
<p className="text-[12px] text-foreground/30">Navigate to the project root and click "Index This Folder".</p>
|
||||
<h3 className="text-[15px] font-semibold text-foreground/90 mb-1">{t.index.selectRepositoryFolder}</h3>
|
||||
<p className="text-[12px] text-foreground/30">{t.index.instructions}</p>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-3 grid grid-cols-[1fr_220px] gap-3 shrink-0">
|
||||
<label className="block">
|
||||
<span className="block text-[10px] uppercase tracking-widest text-foreground/25 mb-1">{t.index.repositoryPath}</span>
|
||||
<input
|
||||
aria-label={t.index.repositoryPath}
|
||||
value={currentPath}
|
||||
onChange={(e) => setCurrentPath(e.target.value)}
|
||||
className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-2 text-[12px] text-foreground font-mono outline-none focus:border-primary/40"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="block text-[10px] uppercase tracking-widest text-foreground/25 mb-1">{t.index.projectName}</span>
|
||||
<input
|
||||
aria-label={t.index.projectName}
|
||||
value={projectName}
|
||||
placeholder={t.index.projectNamePlaceholder}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-2 text-[12px] text-foreground outline-none focus:border-primary/40 placeholder:text-foreground/20"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-3 flex items-center gap-2 shrink-0">
|
||||
<input
|
||||
ref={filterRef}
|
||||
value={filter}
|
||||
placeholder={t.index.filterFolders}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
onKeyDown={onFilterKeyDown}
|
||||
className="flex-1 bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-2 text-[12px] text-foreground outline-none focus:border-primary/40 placeholder:text-foreground/20"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{roots.map((root) => (
|
||||
<button
|
||||
key={root}
|
||||
aria-label={t.index.browseRoot(root)}
|
||||
onClick={() => browse(root)}
|
||||
className="px-2.5 py-2 rounded-lg bg-white/[0.04] hover:bg-white/[0.07] text-[11px] text-foreground/45 font-mono transition-all"
|
||||
>
|
||||
{root}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
@@ -235,19 +325,34 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
|
||||
</button>
|
||||
)}
|
||||
{loading ? (
|
||||
<p className="text-foreground/20 text-[12px] text-center py-8">Loading...</p>
|
||||
) : dirs.length === 0 ? (
|
||||
<p className="text-foreground/15 text-[12px] text-center py-8">No subdirectories</p>
|
||||
<p className="text-foreground/20 text-[12px] text-center py-8">{t.common.loading}</p>
|
||||
) : filteredDirs.length === 0 ? (
|
||||
<p className="text-foreground/15 text-[12px] text-center py-8">{t.index.noSubdirectories}</p>
|
||||
) : (
|
||||
dirs.map((d) => (
|
||||
<button
|
||||
filteredDirs.map((d, i) => (
|
||||
<div
|
||||
key={d}
|
||||
onClick={() => browse(`${currentPath}/${d}`)}
|
||||
className="flex items-center gap-2 w-full text-left px-3 py-1.5 rounded-lg hover:bg-white/[0.04] text-[12px] text-foreground/60 transition-colors group"
|
||||
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-[12px] transition-colors group ${
|
||||
i === activeIndex ? "bg-white/[0.05]" : "hover:bg-white/[0.04]"
|
||||
}`}
|
||||
>
|
||||
<span className="text-foreground/20 group-hover:text-foreground/40">📁</span>
|
||||
<span className="truncate">{d}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label={t.index.browseRoot(d)}
|
||||
onClick={() => browse(joinPath(currentPath, d))}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left text-foreground/60"
|
||||
>
|
||||
<span className="text-foreground/20 group-hover:text-foreground/40">/</span>
|
||||
<span className="truncate">{d}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label={t.index.indexDirectory(d)}
|
||||
onClick={() => submit(joinPath(currentPath, d))}
|
||||
disabled={submitting}
|
||||
className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 px-2 py-1 rounded-md bg-primary/15 hover:bg-primary/25 text-primary text-[10px] font-medium transition-all disabled:opacity-30"
|
||||
>
|
||||
{t.index.indexThisFolder}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -259,9 +364,9 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] text-foreground/25 font-mono truncate max-w-[250px]">{currentPath}</p>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button onClick={onClose} className="px-3 py-2 rounded-lg text-[12px] text-foreground/40 hover:bg-white/[0.04] font-medium transition-all">Cancel</button>
|
||||
<button onClick={submit} disabled={submitting || !currentPath} className="px-4 py-2 rounded-lg bg-primary/20 hover:bg-primary/30 text-primary text-[12px] font-medium transition-all disabled:opacity-30">
|
||||
{submitting ? "Starting..." : "Index This Folder"}
|
||||
<button onClick={onClose} className="px-3 py-2 rounded-lg text-[12px] text-foreground/40 hover:bg-white/[0.04] font-medium transition-all">{t.common.cancel}</button>
|
||||
<button onClick={() => submit()} disabled={submitting || !currentPath} className="px-4 py-2 rounded-lg bg-primary/20 hover:bg-primary/30 text-primary text-[12px] font-medium transition-all disabled:opacity-30">
|
||||
{submitting ? t.index.starting : t.index.indexThisFolder}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,6 +379,7 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
|
||||
/* ── Index Progress ─────────────────────────────────────── */
|
||||
|
||||
function IndexProgress({ onDone }: { onDone: () => void }) {
|
||||
const t = useUiMessages();
|
||||
const [jobs, setJobs] = useState<{ slot: number; status: string; path: string }[]>([]);
|
||||
useEffect(() => {
|
||||
const poll = setInterval(async () => {
|
||||
@@ -293,7 +399,7 @@ function IndexProgress({ onDone }: { onDone: () => void }) {
|
||||
<div key={j.slot} className="flex items-center gap-3">
|
||||
<div className="w-4 h-4 border-2 border-primary/30 border-t-primary rounded-full animate-spin shrink-0" />
|
||||
<div>
|
||||
<p className="text-[12px] text-primary font-medium">Indexing in progress</p>
|
||||
<p className="text-[12px] text-primary font-medium">{t.projects.indexingInProgress}</p>
|
||||
<p className="text-[11px] text-foreground/30 font-mono">{j.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,6 +411,7 @@ function IndexProgress({ onDone }: { onDone: () => void }) {
|
||||
/* ── Main Stats Tab ─────────────────────────────────────── */
|
||||
|
||||
export function StatsTab({ onSelectProject }: StatsTabProps) {
|
||||
const t = useUiMessages();
|
||||
const { projects, loading, error, refresh } = useProjects();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [indexing, setIndexing] = useState(false);
|
||||
@@ -319,9 +426,9 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
|
||||
}, [projects]);
|
||||
|
||||
const deleteProject = useCallback(async (name: string) => {
|
||||
if (!confirm(`Delete index for "${name}"?`)) return;
|
||||
if (!confirm(t.projects.deleteConfirm(name))) return;
|
||||
try { await fetch(`/api/project?name=${encodeURIComponent(name)}`, { method: "DELETE" }); refresh(); } catch { /* */ }
|
||||
}, [refresh]);
|
||||
}, [refresh, t.projects]);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
@@ -329,9 +436,9 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
|
||||
{projects.length > 0 && (
|
||||
<div className="flex gap-4 mb-8">
|
||||
{[
|
||||
{ label: "Projects", value: aggregate.projects, color: "text-primary" },
|
||||
{ label: "Nodes", value: aggregate.nodes, color: "text-foreground/80" },
|
||||
{ label: "Edges", value: aggregate.edges, color: "text-foreground/80" },
|
||||
{ label: t.tabs.projects, value: aggregate.projects, color: "text-primary" },
|
||||
{ label: t.projects.nodes, value: aggregate.nodes, color: "text-foreground/80" },
|
||||
{ label: t.projects.edges, value: aggregate.edges, color: "text-foreground/80" },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="flex-1 rounded-xl border border-border/30 bg-white/[0.02] p-4">
|
||||
<p className="text-[10px] text-foreground/25 uppercase tracking-widest mb-1">{s.label}</p>
|
||||
@@ -344,10 +451,10 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
|
||||
{indexing && <IndexProgress onDone={() => { setIndexing(false); refresh(); }} />}
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-[15px] font-semibold text-foreground/80">Indexed Projects</h2>
|
||||
<h2 className="text-[15px] font-semibold text-foreground/80">{t.projects.indexedProjects}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setShowModal(true)} className="px-3 py-1.5 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">+ New Index</button>
|
||||
<button onClick={refresh} disabled={loading} className="px-3 py-1.5 rounded-lg bg-white/[0.04] hover:bg-white/[0.07] text-[12px] text-foreground/40 font-medium transition-all disabled:opacity-30">{loading ? "..." : "Refresh"}</button>
|
||||
<button onClick={() => setShowModal(true)} className="px-3 py-1.5 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">+ {t.index.newIndex}</button>
|
||||
<button onClick={refresh} disabled={loading} className="px-3 py-1.5 rounded-lg bg-white/[0.04] hover:bg-white/[0.07] text-[12px] text-foreground/40 font-medium transition-all disabled:opacity-30">{loading ? "..." : t.common.refresh}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -355,8 +462,8 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
|
||||
|
||||
{!loading && projects.length === 0 && !error && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-foreground/25 text-[13px] mb-2">No indexed projects</p>
|
||||
<button onClick={() => setShowModal(true)} className="px-4 py-2 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">Index your first repository</button>
|
||||
<p className="text-foreground/25 text-[13px] mb-2">{t.projects.noIndexedProjects}</p>
|
||||
<button onClick={() => setShowModal(true)} className="px-4 py-2 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">{t.projects.indexFirstRepository}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -376,15 +483,15 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<AdrButton project={p.project.name} />
|
||||
<button onClick={() => onSelectProject(p.project.name)} className="px-3 py-1.5 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">View Graph</button>
|
||||
<button onClick={() => deleteProject(p.project.name)} className="px-2 py-1.5 rounded-lg hover:bg-destructive/10 text-foreground/20 hover:text-destructive text-[12px] transition-all" title="Delete index">✕</button>
|
||||
<button onClick={() => onSelectProject(p.project.name)} className="px-3 py-1.5 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">{t.projects.viewGraph}</button>
|
||||
<button onClick={() => deleteProject(p.project.name)} className="px-2 py-1.5 rounded-lg hover:bg-destructive/10 text-foreground/20 hover:text-destructive text-[12px] transition-all" title={t.projects.deleteTitle}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
{p.schema && (
|
||||
<>
|
||||
<div className="flex gap-6 text-[12px] text-foreground/30 mb-3">
|
||||
<span><strong className="text-foreground/55 tabular-nums">{totalNodes.toLocaleString()}</strong> nodes</span>
|
||||
<span><strong className="text-foreground/55 tabular-nums">{totalEdges.toLocaleString()}</strong> edges</span>
|
||||
<span><strong className="text-foreground/55 tabular-nums">{totalNodes.toLocaleString()}</strong> {t.projects.nodes}</span>
|
||||
<span><strong className="text-foreground/55 tabular-nums">{totalEdges.toLocaleString()}</strong> {t.projects.edges}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{p.schema.node_labels?.map((l) => (
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchLayout, GRAPH_RENDER_NODE_LIMIT } from "./useGraphData";
|
||||
|
||||
describe("fetchLayout", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("uses the safe graph render cap by default", async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ nodes: [], edges: [], total_nodes: 0 }),
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await fetchLayout("large-project");
|
||||
|
||||
expect(GRAPH_RENDER_NODE_LIMIT).toBe(2000);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const calls = fetchMock.mock.calls as unknown as Array<[string]>;
|
||||
const [url] = calls[0];
|
||||
expect(url).toBe(
|
||||
"/api/layout?project=large-project&max_nodes=2000",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,9 +9,11 @@ interface UseGraphDataResult {
|
||||
fetchDetail: (project: string, centerNode: string) => void;
|
||||
}
|
||||
|
||||
async function fetchLayout(
|
||||
export const GRAPH_RENDER_NODE_LIMIT = 2000;
|
||||
|
||||
export async function fetchLayout(
|
||||
project: string,
|
||||
maxNodes = 50000,
|
||||
maxNodes = GRAPH_RENDER_NODE_LIMIT,
|
||||
): Promise<GraphData> {
|
||||
const params = new URLSearchParams({ project, max_nodes: String(maxNodes) });
|
||||
const res = await fetch(`/api/layout?${params}`);
|
||||
@@ -33,7 +35,7 @@ export function useGraphData(): UseGraphDataResult {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchLayout(project, 50000);
|
||||
const result = await fetchLayout(project);
|
||||
setData(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to fetch layout");
|
||||
@@ -48,7 +50,7 @@ export function useGraphData(): UseGraphDataResult {
|
||||
setError(null);
|
||||
try {
|
||||
/* TODO: detail level with center_node filtering */
|
||||
const result = await fetchLayout(project, 50000);
|
||||
const result = await fetchLayout(project);
|
||||
setData(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to fetch layout");
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detectLanguage, messages } from "./i18n";
|
||||
|
||||
describe("i18n", () => {
|
||||
it("detects Chinese from Accept-Language and falls back to English", () => {
|
||||
expect(detectLanguage("zh-CN,zh;q=0.9,en;q=0.8")).toBe("zh");
|
||||
expect(detectLanguage("de-DE,de;q=0.9")).toBe("en");
|
||||
});
|
||||
|
||||
it("keeps UI chrome messages in the catalog", () => {
|
||||
expect(messages.zh.tabs.projects).toBe("项目");
|
||||
expect(messages.zh.index.newIndex).toBe("新建索引");
|
||||
expect(messages.en.index.repositoryPath).toBe("Repository path");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type UiLanguage = "en" | "zh";
|
||||
|
||||
export const messages = {
|
||||
en: {
|
||||
tabs: {
|
||||
graph: "Graph",
|
||||
projects: "Projects",
|
||||
control: "Control",
|
||||
},
|
||||
common: {
|
||||
cancel: "Cancel",
|
||||
refresh: "Refresh",
|
||||
loading: "Loading...",
|
||||
save: "Save",
|
||||
saving: "Saving...",
|
||||
delete: "Delete",
|
||||
noMatches: "No matches",
|
||||
},
|
||||
graph: {
|
||||
selectedLabel: "Graph",
|
||||
search: "Search...",
|
||||
clearSelection: "Clear selection",
|
||||
},
|
||||
projects: {
|
||||
indexedProjects: "Indexed Projects",
|
||||
noIndexedProjects: "No indexed projects",
|
||||
indexFirstRepository: "Index your first repository",
|
||||
viewGraph: "View Graph",
|
||||
nodes: "nodes",
|
||||
edges: "edges",
|
||||
deleteTitle: "Delete index",
|
||||
deleteConfirm: (name: string) => `Delete index for "${name}"?`,
|
||||
healthHealthy: "Database healthy",
|
||||
healthMissing: "Database missing",
|
||||
healthCorrupt: "Database unhealthy",
|
||||
healthChecking: "Checking...",
|
||||
indexingInProgress: "Indexing in progress",
|
||||
},
|
||||
index: {
|
||||
newIndex: "New Index",
|
||||
selectRepositoryFolder: "Select Repository Folder",
|
||||
instructions: "Navigate to the project root and click \"Index This Folder\".",
|
||||
repositoryPath: "Repository path",
|
||||
projectName: "Project name",
|
||||
projectNamePlaceholder: "Optional display name",
|
||||
filterFolders: "Filter folders",
|
||||
noSubdirectories: "No subdirectories",
|
||||
indexThisFolder: "Index This Folder",
|
||||
starting: "Starting...",
|
||||
browseRoot: (path: string) => `Browse ${path}`,
|
||||
indexDirectory: (name: string) => `Index ${name}`,
|
||||
},
|
||||
adr: {
|
||||
title: "Architecture Decision Record",
|
||||
lastUpdated: "Last updated",
|
||||
},
|
||||
control: {
|
||||
panel: "Control Panel",
|
||||
totalCpu: "Total CPU",
|
||||
totalRam: "Total RAM",
|
||||
processes: "Processes",
|
||||
selfRam: "Self RAM",
|
||||
activeProcesses: "Active Processes",
|
||||
processLogs: "Process Logs",
|
||||
noProcesses: "No processes found",
|
||||
noLogs: "No logs yet",
|
||||
kill: "Kill",
|
||||
thisProcess: "THIS",
|
||||
uptime: "Uptime",
|
||||
killConfirm: (pid: number) => `Kill process ${pid}?`,
|
||||
},
|
||||
},
|
||||
zh: {
|
||||
tabs: {
|
||||
graph: "图谱",
|
||||
projects: "项目",
|
||||
control: "控制",
|
||||
},
|
||||
common: {
|
||||
cancel: "取消",
|
||||
refresh: "刷新",
|
||||
loading: "加载中...",
|
||||
save: "保存",
|
||||
saving: "保存中...",
|
||||
delete: "删除",
|
||||
noMatches: "无匹配结果",
|
||||
},
|
||||
graph: {
|
||||
selectedLabel: "图谱",
|
||||
search: "搜索...",
|
||||
clearSelection: "清除选择",
|
||||
},
|
||||
projects: {
|
||||
indexedProjects: "已索引项目",
|
||||
noIndexedProjects: "暂无已索引项目",
|
||||
indexFirstRepository: "索引第一个仓库",
|
||||
viewGraph: "查看图谱",
|
||||
nodes: "节点",
|
||||
edges: "边",
|
||||
deleteTitle: "删除索引",
|
||||
deleteConfirm: (name: string) => `删除 "${name}" 的索引?`,
|
||||
healthHealthy: "数据库正常",
|
||||
healthMissing: "数据库缺失",
|
||||
healthCorrupt: "数据库异常",
|
||||
healthChecking: "检查中...",
|
||||
indexingInProgress: "正在索引",
|
||||
},
|
||||
index: {
|
||||
newIndex: "新建索引",
|
||||
selectRepositoryFolder: "选择仓库目录",
|
||||
instructions: "导航到项目根目录,然后点击“索引此目录”。",
|
||||
repositoryPath: "仓库路径",
|
||||
projectName: "项目名称",
|
||||
projectNamePlaceholder: "可选显示名称",
|
||||
filterFolders: "筛选目录",
|
||||
noSubdirectories: "没有子目录",
|
||||
indexThisFolder: "索引此目录",
|
||||
starting: "启动中...",
|
||||
browseRoot: (path: string) => `浏览 ${path}`,
|
||||
indexDirectory: (name: string) => `索引 ${name}`,
|
||||
},
|
||||
adr: {
|
||||
title: "架构决策记录",
|
||||
lastUpdated: "最后更新",
|
||||
},
|
||||
control: {
|
||||
panel: "控制面板",
|
||||
totalCpu: "总 CPU",
|
||||
totalRam: "总内存",
|
||||
processes: "进程",
|
||||
selfRam: "自身内存",
|
||||
activeProcesses: "活动进程",
|
||||
processLogs: "进程日志",
|
||||
noProcesses: "未找到进程",
|
||||
noLogs: "暂无日志",
|
||||
kill: "结束",
|
||||
thisProcess: "本进程",
|
||||
uptime: "运行时间",
|
||||
killConfirm: (pid: number) => `结束进程 ${pid}?`,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type UiMessages = (typeof messages)[UiLanguage];
|
||||
|
||||
export function detectLanguage(acceptLanguage?: string | null, override?: string | null): UiLanguage {
|
||||
if (override === "zh" || override === "en") return override;
|
||||
if (!acceptLanguage) return "en";
|
||||
const normalized = acceptLanguage.toLowerCase();
|
||||
return normalized.includes("zh-cn") || normalized.includes("zh") ? "zh" : "en";
|
||||
}
|
||||
|
||||
let cachedLanguage: UiLanguage = "en";
|
||||
let languageLoaded = false;
|
||||
let languageRequest: Promise<UiLanguage> | null = null;
|
||||
const languageListeners = new Set<(lang: UiLanguage) => void>();
|
||||
|
||||
function loadUiLanguage(): Promise<UiLanguage> {
|
||||
if (languageLoaded) return Promise.resolve(cachedLanguage);
|
||||
if (languageRequest) return languageRequest;
|
||||
|
||||
languageRequest = fetch("/api/ui-config")
|
||||
.then((r) => r.json())
|
||||
.then((data) => detectLanguage(null, data?.lang))
|
||||
.catch(() => detectLanguage(navigator.language))
|
||||
.then((lang) => {
|
||||
cachedLanguage = lang;
|
||||
languageLoaded = true;
|
||||
for (const listener of languageListeners) listener(lang);
|
||||
return lang;
|
||||
})
|
||||
.finally(() => {
|
||||
languageRequest = null;
|
||||
});
|
||||
|
||||
return languageRequest;
|
||||
}
|
||||
|
||||
export function useUiMessages(): UiMessages {
|
||||
const [lang, setLang] = useState<UiLanguage>(cachedLanguage);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
languageListeners.add(setLang);
|
||||
void loadUiLanguage().then((nextLang) => {
|
||||
if (!cancelled) setLang(nextLang);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
languageListeners.delete(setLang);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return messages[lang];
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/controltab.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphscene.tsx","./src/components/graphtab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.tsx","./src/components/tabbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/controltab.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/tabbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"}
|
||||
@@ -2715,6 +2715,8 @@ int cbm_cmd_config(int argc, char **argv) {
|
||||
"Enable auto-indexing on MCP session start");
|
||||
printf(" %-25s default=%-10s %s\n", CBM_CONFIG_AUTO_INDEX_LIMIT, "50000",
|
||||
"Max files for auto-indexing new projects");
|
||||
printf(" %-25s default=%-10s %s\n", CBM_CONFIG_UI_LANG, "auto",
|
||||
"Pin graph UI language: en, zh, or auto");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -2740,6 +2742,8 @@ int cbm_cmd_config(int argc, char **argv) {
|
||||
cbm_config_get(cfg, CBM_CONFIG_AUTO_INDEX, "false"));
|
||||
printf(" %-25s = %-10s\n", CBM_CONFIG_AUTO_INDEX_LIMIT,
|
||||
cbm_config_get(cfg, CBM_CONFIG_AUTO_INDEX_LIMIT, "50000"));
|
||||
printf(" %-25s = %-10s\n", CBM_CONFIG_UI_LANG,
|
||||
cbm_config_get(cfg, CBM_CONFIG_UI_LANG, "auto"));
|
||||
} else if (strcmp(argv[0], "get") == 0) {
|
||||
if (argc < MIN_ARGC_GET) {
|
||||
(void)fprintf(stderr, "Usage: config get <key>\n");
|
||||
|
||||
@@ -274,6 +274,7 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key);
|
||||
/* Well-known config keys */
|
||||
#define CBM_CONFIG_AUTO_INDEX "auto_index"
|
||||
#define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit"
|
||||
#define CBM_CONFIG_UI_LANG "ui-lang"
|
||||
|
||||
/* ── Subcommands (wired from main.c) ─────────────────────────── */
|
||||
|
||||
|
||||
+240
-53
@@ -12,47 +12,72 @@
|
||||
#include <string.h>
|
||||
|
||||
static CBMLogLevel g_log_level = CBM_LOG_INFO;
|
||||
static CBMLogFormat g_log_format = CBM_LOG_FORMAT_TEXT;
|
||||
static cbm_log_sink_fn g_log_sink = NULL;
|
||||
static CBMLogSinkMode g_log_sink_mode = CBM_LOG_SINK_REPLACE;
|
||||
|
||||
/* CBM_LOG_LEVEL support — distilled from #414 (closes #413, thanks @santanusinha). */
|
||||
void cbm_log_init_from_env(void) {
|
||||
/* getenv() is safe here: this runs at startup before any thread is created,
|
||||
* so there is no concurrent setenv() to race against. */
|
||||
const char *raw = getenv("CBM_LOG_LEVEL");
|
||||
if (!raw || raw[0] == '\0') {
|
||||
return; /* unset/empty: keep the current (default) level — fail-open */
|
||||
}
|
||||
|
||||
/* Textual form, case-insensitive. Index of each name == its enum value. */
|
||||
static const char *const names[] = {"debug", "info", "warn", "error", "none"};
|
||||
char lower[8];
|
||||
size_t i = 0;
|
||||
for (; i < sizeof(lower) - 1 && raw[i] != '\0'; i++) {
|
||||
lower[i] = (char)tolower((unsigned char)raw[i]);
|
||||
}
|
||||
lower[i] = '\0';
|
||||
if (raw[i] == '\0') { /* fully consumed — candidate textual match */
|
||||
for (size_t lvl = 0; lvl < sizeof(names) / sizeof(names[0]); lvl++) {
|
||||
if (strcmp(lower, names[lvl]) == 0) {
|
||||
cbm_log_set_level((CBMLogLevel)lvl);
|
||||
return;
|
||||
if (raw && raw[0] != '\0') {
|
||||
/* Textual form, case-insensitive. Index of each name == its enum value. */
|
||||
static const char *const names[] = {"debug", "info", "warn", "error", "none"};
|
||||
char lower[8];
|
||||
size_t i = 0;
|
||||
for (; i < sizeof(lower) - 1 && raw[i] != '\0'; i++) {
|
||||
lower[i] = (char)tolower((unsigned char)raw[i]);
|
||||
}
|
||||
lower[i] = '\0';
|
||||
if (raw[i] == '\0') { /* fully consumed — candidate textual match */
|
||||
for (size_t lvl = 0; lvl < sizeof(names) / sizeof(names[0]); lvl++) {
|
||||
if (strcmp(lower, names[lvl]) == 0) {
|
||||
cbm_log_set_level((CBMLogLevel)lvl);
|
||||
goto parse_format;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Numeric form: 0=debug .. 4=none, matching CBMLogLevel. */
|
||||
char *end = NULL;
|
||||
long n = strtol(raw, &end, CBM_DECIMAL_BASE);
|
||||
if (end != raw && *end == '\0' && n >= CBM_LOG_DEBUG && n <= CBM_LOG_NONE) {
|
||||
cbm_log_set_level((CBMLogLevel)n);
|
||||
}
|
||||
}
|
||||
|
||||
/* Numeric form: 0=debug .. 4=none, matching CBMLogLevel. */
|
||||
char *end = NULL;
|
||||
long n = strtol(raw, &end, CBM_DECIMAL_BASE);
|
||||
if (end != raw && *end == '\0' && n >= CBM_LOG_DEBUG && n <= CBM_LOG_NONE) {
|
||||
cbm_log_set_level((CBMLogLevel)n);
|
||||
/* Unrecognised value: leave the level unchanged (fail-open). */
|
||||
|
||||
parse_format:;
|
||||
const char *fmt = getenv("CBM_LOG_FORMAT");
|
||||
if (fmt && fmt[0] != '\0') {
|
||||
char lower_fmt[8];
|
||||
size_t i = 0;
|
||||
for (; i < sizeof(lower_fmt) - 1 && fmt[i] != '\0'; i++) {
|
||||
lower_fmt[i] = (char)tolower((unsigned char)fmt[i]);
|
||||
}
|
||||
lower_fmt[i] = '\0';
|
||||
if (fmt[i] == '\0' && strcmp(lower_fmt, "json") == 0) {
|
||||
cbm_log_set_format(CBM_LOG_FORMAT_JSON);
|
||||
} else if (fmt[i] == '\0' && strcmp(lower_fmt, "text") == 0) {
|
||||
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* Unrecognised value: leave the level unchanged (fail-open). */
|
||||
/* Format is intentionally explicit-only. Logs stay local to stderr and the
|
||||
* optional in-process sink; deployment environment variables must not
|
||||
* silently change the operator-selected output shape. */
|
||||
}
|
||||
|
||||
void cbm_log_set_sink(cbm_log_sink_fn fn) {
|
||||
cbm_log_set_sink_ex(fn, CBM_LOG_SINK_REPLACE);
|
||||
}
|
||||
|
||||
void cbm_log_set_sink_ex(cbm_log_sink_fn fn, CBMLogSinkMode mode) {
|
||||
g_log_sink = fn;
|
||||
g_log_sink_mode = mode;
|
||||
}
|
||||
|
||||
void cbm_log_set_level(CBMLogLevel level) {
|
||||
@@ -63,6 +88,14 @@ CBMLogLevel cbm_log_get_level(void) {
|
||||
return g_log_level;
|
||||
}
|
||||
|
||||
void cbm_log_set_format(CBMLogFormat format) {
|
||||
g_log_format = format;
|
||||
}
|
||||
|
||||
CBMLogFormat cbm_log_get_format(void) {
|
||||
return g_log_format;
|
||||
}
|
||||
|
||||
static const char *level_str(CBMLogLevel level) {
|
||||
switch (level) {
|
||||
case CBM_LOG_DEBUG:
|
||||
@@ -78,54 +111,208 @@ static const char *level_str(CBMLogLevel level) {
|
||||
}
|
||||
}
|
||||
|
||||
static void append_char(char *buf, size_t bufsz, size_t *pos, char ch) {
|
||||
if (*pos < bufsz - 1) {
|
||||
buf[*pos] = ch;
|
||||
}
|
||||
(*pos)++;
|
||||
}
|
||||
|
||||
static void append_raw(char *buf, size_t bufsz, size_t *pos, const char *s) {
|
||||
if (!s) {
|
||||
return;
|
||||
}
|
||||
while (*s) {
|
||||
append_char(buf, bufsz, pos, *s++);
|
||||
}
|
||||
}
|
||||
|
||||
static void append_text_atom(char *buf, size_t bufsz, size_t *pos, const char *s) {
|
||||
if (!s) {
|
||||
return;
|
||||
}
|
||||
while (*s) {
|
||||
unsigned char ch = (unsigned char)*s++;
|
||||
if (ch <= ' ' || ch == 0x7f) {
|
||||
append_char(buf, bufsz, pos, '_');
|
||||
} else {
|
||||
append_char(buf, bufsz, pos, (char)ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void append_json_string(char *buf, size_t bufsz, size_t *pos, const char *s) {
|
||||
append_char(buf, bufsz, pos, '"');
|
||||
if (s) {
|
||||
while (*s) {
|
||||
unsigned char ch = (unsigned char)*s++;
|
||||
switch (ch) {
|
||||
case '"':
|
||||
append_raw(buf, bufsz, pos, "\\\"");
|
||||
break;
|
||||
case '\\':
|
||||
append_raw(buf, bufsz, pos, "\\\\");
|
||||
break;
|
||||
case '\b':
|
||||
append_raw(buf, bufsz, pos, "\\b");
|
||||
break;
|
||||
case '\f':
|
||||
append_raw(buf, bufsz, pos, "\\f");
|
||||
break;
|
||||
case '\n':
|
||||
append_raw(buf, bufsz, pos, "\\n");
|
||||
break;
|
||||
case '\r':
|
||||
append_raw(buf, bufsz, pos, "\\r");
|
||||
break;
|
||||
case '\t':
|
||||
append_raw(buf, bufsz, pos, "\\t");
|
||||
break;
|
||||
default:
|
||||
if (ch < 0x20) {
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
append_raw(buf, bufsz, pos, "\\u00");
|
||||
append_char(buf, bufsz, pos, hex[ch >> 4]);
|
||||
append_char(buf, bufsz, pos, hex[ch & 0xf]);
|
||||
} else {
|
||||
append_char(buf, bufsz, pos, (char)ch);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
append_char(buf, bufsz, pos, '"');
|
||||
}
|
||||
|
||||
static void finish_line(char *buf, size_t bufsz, size_t pos) {
|
||||
if (bufsz == 0) {
|
||||
return;
|
||||
}
|
||||
if (pos >= bufsz) {
|
||||
buf[bufsz - 1] = '\0';
|
||||
} else {
|
||||
buf[pos] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
static void emit_line(const char *line) {
|
||||
if (g_log_sink) {
|
||||
g_log_sink(line);
|
||||
if (g_log_sink_mode == CBM_LOG_SINK_REPLACE) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
(void)fprintf(stderr, "%s\n", line);
|
||||
}
|
||||
|
||||
void cbm_log(CBMLogLevel level, const char *msg, ...) {
|
||||
if (level < g_log_level) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Build the log line into a buffer ONCE — no double va_list iteration */
|
||||
char line_buf[CBM_SZ_512];
|
||||
int pos =
|
||||
snprintf(line_buf, sizeof(line_buf), "level=%s msg=%s", level_str(level), msg ? msg : "");
|
||||
|
||||
char line_buf[CBM_SZ_4K];
|
||||
size_t pos = 0;
|
||||
va_list args;
|
||||
va_start(args, msg);
|
||||
for (;;) {
|
||||
const char *key = va_arg(args, const char *);
|
||||
if (!key) {
|
||||
break;
|
||||
|
||||
if (g_log_format == CBM_LOG_FORMAT_JSON) {
|
||||
append_raw(line_buf, sizeof(line_buf), &pos, "{\"level\":");
|
||||
append_json_string(line_buf, sizeof(line_buf), &pos, level_str(level));
|
||||
append_raw(line_buf, sizeof(line_buf), &pos, ",\"event\":");
|
||||
append_json_string(line_buf, sizeof(line_buf), &pos, msg ? msg : "");
|
||||
for (;;) {
|
||||
const char *key = va_arg(args, const char *);
|
||||
if (!key) {
|
||||
break;
|
||||
}
|
||||
const char *val = va_arg(args, const char *);
|
||||
append_char(line_buf, sizeof(line_buf), &pos, ',');
|
||||
append_json_string(line_buf, sizeof(line_buf), &pos, key);
|
||||
append_char(line_buf, sizeof(line_buf), &pos, ':');
|
||||
append_json_string(line_buf, sizeof(line_buf), &pos, val ? val : "");
|
||||
}
|
||||
const char *val = va_arg(args, const char *);
|
||||
if (!val) {
|
||||
val = "";
|
||||
}
|
||||
if ((size_t)pos < sizeof(line_buf) - SKIP_ONE) {
|
||||
pos += snprintf(line_buf + pos, sizeof(line_buf) - (size_t)pos, " %s=%s", key, val);
|
||||
append_char(line_buf, sizeof(line_buf), &pos, '}');
|
||||
} else {
|
||||
append_raw(line_buf, sizeof(line_buf), &pos, "level=");
|
||||
append_text_atom(line_buf, sizeof(line_buf), &pos, level_str(level));
|
||||
append_raw(line_buf, sizeof(line_buf), &pos, " msg=");
|
||||
append_text_atom(line_buf, sizeof(line_buf), &pos, msg ? msg : "");
|
||||
for (;;) {
|
||||
const char *key = va_arg(args, const char *);
|
||||
if (!key) {
|
||||
break;
|
||||
}
|
||||
const char *val = va_arg(args, const char *);
|
||||
append_char(line_buf, sizeof(line_buf), &pos, ' ');
|
||||
append_text_atom(line_buf, sizeof(line_buf), &pos, key);
|
||||
append_char(line_buf, sizeof(line_buf), &pos, '=');
|
||||
append_text_atom(line_buf, sizeof(line_buf), &pos, val ? val : "");
|
||||
}
|
||||
}
|
||||
va_end(args);
|
||||
|
||||
/* When a sink is registered it takes over all output (exclusive).
|
||||
* Otherwise write structured log to stderr. */
|
||||
if (g_log_sink) {
|
||||
g_log_sink(line_buf);
|
||||
} else {
|
||||
(void)fprintf(stderr, "%s\n", line_buf);
|
||||
}
|
||||
finish_line(line_buf, sizeof(line_buf), pos);
|
||||
emit_line(line_buf);
|
||||
}
|
||||
|
||||
void cbm_log_int(CBMLogLevel level, const char *msg, const char *key, int64_t value) {
|
||||
if (level < g_log_level) {
|
||||
char value_buf[CBM_SZ_32];
|
||||
snprintf(value_buf, sizeof(value_buf), "%" PRId64, value);
|
||||
cbm_log(level, msg, key ? key : "?", value_buf, NULL);
|
||||
}
|
||||
|
||||
static void copy_path_without_query(const char *path, char *out, size_t outsz) {
|
||||
if (!out || outsz == 0) {
|
||||
return;
|
||||
}
|
||||
out[0] = '\0';
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
size_t n = 0;
|
||||
while (path[n] && path[n] != '?' && path[n] != '#' && n < outsz - 1) {
|
||||
out[n] = path[n];
|
||||
n++;
|
||||
}
|
||||
out[n] = '\0';
|
||||
}
|
||||
|
||||
char line_buf[CBM_SZ_256];
|
||||
snprintf(line_buf, sizeof(line_buf), "level=%s msg=%s %s=%" PRId64, level_str(level),
|
||||
msg ? msg : "", key ? key : "?", value);
|
||||
|
||||
if (g_log_sink) {
|
||||
g_log_sink(line_buf);
|
||||
void cbm_log_mcp_request(const char *method, const char *tool_name, bool is_error,
|
||||
int64_t duration_us) {
|
||||
char duration_ms[CBM_SZ_32];
|
||||
snprintf(duration_ms, sizeof(duration_ms), "%" PRId64, duration_us / 1000);
|
||||
if (tool_name && tool_name[0] != '\0') {
|
||||
cbm_log(is_error ? CBM_LOG_WARN : CBM_LOG_INFO, "mcp.request", "protocol", "jsonrpc",
|
||||
"method", method ? method : "", "tool", tool_name, "status",
|
||||
is_error ? "error" : "ok", "duration_ms", duration_ms, NULL);
|
||||
} else {
|
||||
(void)fprintf(stderr, "%s\n", line_buf);
|
||||
cbm_log(is_error ? CBM_LOG_WARN : CBM_LOG_INFO, "mcp.request", "protocol", "jsonrpc",
|
||||
"method", method ? method : "", "status", is_error ? "error" : "ok", "duration_ms",
|
||||
duration_ms, NULL);
|
||||
}
|
||||
}
|
||||
|
||||
void cbm_log_http_request(const char *component, const char *method, const char *path, int status,
|
||||
int64_t duration_ms, size_t request_bytes, size_t response_bytes) {
|
||||
char safe_path[CBM_SZ_1K];
|
||||
char status_buf[CBM_SZ_16];
|
||||
char duration_buf[CBM_SZ_32];
|
||||
char request_buf[CBM_SZ_32];
|
||||
char response_buf[CBM_SZ_32];
|
||||
copy_path_without_query(path, safe_path, sizeof(safe_path));
|
||||
snprintf(status_buf, sizeof(status_buf), "%d", status);
|
||||
snprintf(duration_buf, sizeof(duration_buf), "%" PRId64, duration_ms);
|
||||
snprintf(request_buf, sizeof(request_buf), "%zu", request_bytes);
|
||||
snprintf(response_buf, sizeof(response_buf), "%zu", response_bytes);
|
||||
|
||||
CBMLogLevel level = CBM_LOG_INFO;
|
||||
if (status >= 500) {
|
||||
level = CBM_LOG_ERROR;
|
||||
} else if (status >= 400) {
|
||||
level = CBM_LOG_WARN;
|
||||
}
|
||||
|
||||
cbm_log(level, "http.request", "component", component ? component : "", "method",
|
||||
method ? method : "", "path", safe_path, "status", status_buf, "duration_ms",
|
||||
duration_buf, "request_bytes", request_buf, "response_bytes", response_buf, NULL);
|
||||
}
|
||||
+30
-3
@@ -3,7 +3,8 @@
|
||||
*
|
||||
* Design:
|
||||
* - All output goes to stderr (stdout is reserved for MCP JSON-RPC)
|
||||
* - Structured format: "level=info msg=pass.timing pass=defs elapsed_ms=42"
|
||||
* - Structured text format: "level=info msg=pass.timing pass=defs elapsed_ms=42"
|
||||
* - Optional JSON format for local structured parsing
|
||||
* - Levels: DEBUG, INFO, WARN, ERROR
|
||||
* - Level filtering at runtime via cbm_log_set_level() or the
|
||||
* CBM_LOG_LEVEL env var (see cbm_log_init_from_env)
|
||||
@@ -12,7 +13,9 @@
|
||||
#ifndef CBM_LOG_H
|
||||
#define CBM_LOG_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
typedef enum {
|
||||
CBM_LOG_DEBUG = 0,
|
||||
@@ -22,11 +25,23 @@ typedef enum {
|
||||
CBM_LOG_NONE = 4 /* disable all logging */
|
||||
} CBMLogLevel;
|
||||
|
||||
typedef enum {
|
||||
CBM_LOG_FORMAT_TEXT = 0,
|
||||
CBM_LOG_FORMAT_JSON = 1,
|
||||
} CBMLogFormat;
|
||||
|
||||
typedef enum {
|
||||
CBM_LOG_SINK_REPLACE = 0,
|
||||
CBM_LOG_SINK_TEE = 1,
|
||||
} CBMLogSinkMode;
|
||||
|
||||
/* Apply the CBM_LOG_LEVEL environment variable to the runtime log level.
|
||||
* Accepts (case-insensitive) "debug", "info", "warn", "error", "none", or
|
||||
* the numeric equivalents 0..4 matching CBMLogLevel. Unknown, empty, or
|
||||
* unset values leave the level unchanged (fail-open). Call once at startup,
|
||||
* before any threads or log statements. Distilled from #414 (closes #413). */
|
||||
* unset values leave the level unchanged (fail-open).
|
||||
*
|
||||
* Also applies CBM_LOG_FORMAT=text|json. If unset, the current format is left
|
||||
* unchanged. Call once at startup before any threads or log lines. */
|
||||
void cbm_log_init_from_env(void);
|
||||
|
||||
/* Set minimum log level (default: INFO). */
|
||||
@@ -35,6 +50,10 @@ void cbm_log_set_level(CBMLogLevel level);
|
||||
/* Get current log level. */
|
||||
CBMLogLevel cbm_log_get_level(void);
|
||||
|
||||
/* Set/get output format. Default is text. */
|
||||
void cbm_log_set_format(CBMLogFormat format);
|
||||
CBMLogFormat cbm_log_get_format(void);
|
||||
|
||||
/* Core logging function. msg is a short semantic tag.
|
||||
* Variadic args are key-value pairs: (const char *key, const char *value)...
|
||||
* Terminated by NULL key.
|
||||
@@ -57,8 +76,16 @@ void cbm_log(CBMLogLevel level, const char *msg, ...);
|
||||
/* Log with integer value (avoids sprintf for common case). */
|
||||
void cbm_log_int(CBMLogLevel level, const char *msg, const char *key, int64_t value);
|
||||
|
||||
/* Operational event helpers. They deliberately avoid request bodies, headers,
|
||||
* arguments, and query strings. */
|
||||
void cbm_log_mcp_request(const char *method, const char *tool_name, bool is_error,
|
||||
int64_t duration_us);
|
||||
void cbm_log_http_request(const char *component, const char *method, const char *path, int status,
|
||||
int64_t duration_ms, size_t request_bytes, size_t response_bytes);
|
||||
|
||||
/* Optional log sink callback — called with the formatted log line. */
|
||||
typedef void (*cbm_log_sink_fn)(const char *line);
|
||||
void cbm_log_set_sink(cbm_log_sink_fn fn);
|
||||
void cbm_log_set_sink_ex(cbm_log_sink_fn fn, CBMLogSinkMode mode);
|
||||
|
||||
#endif /* CBM_LOG_H */
|
||||
+1
-1
@@ -429,7 +429,7 @@ int main(int argc, char **argv) {
|
||||
* No more untracked heap blind spots. */
|
||||
/* Store binary path for subprocess spawning + hook log sink */
|
||||
cbm_http_server_set_binary_path(argv[0]);
|
||||
cbm_log_set_sink(cbm_ui_log_append);
|
||||
cbm_log_set_sink_ex(cbm_ui_log_append, CBM_LOG_SINK_TEE);
|
||||
cbm_log_info("server.start", "version", CBM_VERSION);
|
||||
cbm_diag_start(); /* starts if CBM_DIAGNOSTICS=1 */
|
||||
|
||||
|
||||
+193
-37
@@ -32,6 +32,7 @@ enum {
|
||||
MCP_URI_PREFIX = 7, /* strlen("file://") */
|
||||
MCP_CONTENT_PREFIX = 15, /* strlen("Content-Length:") */
|
||||
MCP_RETURN_2 = 2,
|
||||
MCP_TOOLS_PAGE_SIZE = 8,
|
||||
};
|
||||
#define MCP_MS_TO_US 1000LL
|
||||
#define MCP_S_TO_US 1000000LL
|
||||
@@ -249,29 +250,66 @@ char *cbm_mcp_text_result(const char *text, bool is_error) {
|
||||
yyjson_mut_val *content = yyjson_mut_arr(doc);
|
||||
yyjson_mut_val *item = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_str(doc, item, "type", "text");
|
||||
yyjson_mut_obj_add_str(doc, item, "text", text);
|
||||
yyjson_mut_obj_add_str(doc, item, "text", text ? text : "");
|
||||
yyjson_mut_arr_add_val(content, item);
|
||||
yyjson_mut_obj_add_val(doc, root, "content", content);
|
||||
|
||||
if (is_error) {
|
||||
yyjson_mut_obj_add_bool(doc, root, "isError", true);
|
||||
if (!is_error && text) {
|
||||
yyjson_doc *structured_doc = yyjson_read(text, strlen(text), 0);
|
||||
if (structured_doc) {
|
||||
yyjson_val *structured_root = yyjson_doc_get_root(structured_doc);
|
||||
if (yyjson_is_obj(structured_root)) {
|
||||
yyjson_mut_val *structured = yyjson_val_mut_copy(doc, structured_root);
|
||||
yyjson_mut_obj_add_val(doc, root, "structuredContent", structured);
|
||||
}
|
||||
yyjson_doc_free(structured_doc);
|
||||
}
|
||||
}
|
||||
yyjson_mut_obj_add_bool(doc, root, "isError", is_error);
|
||||
|
||||
char *out = yy_doc_to_str(doc);
|
||||
yyjson_mut_doc_free(doc);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool cbm_mcp_cancel_request_matches(const char *params_json, int64_t active_id,
|
||||
const char *active_id_str) {
|
||||
if (!params_json) {
|
||||
return false;
|
||||
}
|
||||
|
||||
yyjson_doc *doc = yyjson_read(params_json, strlen(params_json), 0);
|
||||
if (!doc) {
|
||||
return false;
|
||||
}
|
||||
|
||||
yyjson_val *root = yyjson_doc_get_root(doc);
|
||||
yyjson_val *request_id = yyjson_obj_get(root, "requestId");
|
||||
bool matches = false;
|
||||
if (request_id) {
|
||||
if (active_id_str) {
|
||||
matches =
|
||||
yyjson_is_str(request_id) && strcmp(yyjson_get_str(request_id), active_id_str) == 0;
|
||||
} else {
|
||||
matches = yyjson_is_int(request_id) && yyjson_get_int(request_id) == active_id;
|
||||
}
|
||||
}
|
||||
|
||||
yyjson_doc_free(doc);
|
||||
return matches;
|
||||
}
|
||||
|
||||
/* ── Tool definitions ─────────────────────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
const char *name;
|
||||
const char *title;
|
||||
const char *description;
|
||||
const char *input_schema; /* JSON string */
|
||||
} tool_def_t;
|
||||
|
||||
static const tool_def_t TOOLS[] = {
|
||||
{"index_repository",
|
||||
{"index_repository", "Index repository",
|
||||
"Index a repository into the knowledge graph. "
|
||||
"Special mode 'cross-repo-intelligence': skip extraction, only match Routes/Channels "
|
||||
"across projects to create CROSS_HTTP_CALLS/CROSS_ASYNC_CALLS/CROSS_CHANNEL edges. "
|
||||
@@ -287,12 +325,15 @@ static const tool_def_t TOOLS[] = {
|
||||
"\"target_projects\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},"
|
||||
"\"description\":\"Projects to search for cross-repo links (cross-repo-intelligence mode). "
|
||||
"Use [\\\"*\\\"] for all indexed projects. Run list_projects to see available projects.\"},"
|
||||
"\"name\":{\"type\":\"string\",\"description\":"
|
||||
"\"Override the derived project name. Non-ASCII bytes are encoded and unsafe path characters "
|
||||
"are normalized.\"},"
|
||||
"\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":"
|
||||
"\"Write compressed artifact to .codebase-memory/graph.db.zst for team sharing. "
|
||||
"Teammates can bootstrap from the artifact instead of full re-indexing.\"}"
|
||||
"},\"required\":[\"repo_path\"]}"},
|
||||
|
||||
{"search_graph",
|
||||
{"search_graph", "Search graph",
|
||||
"Search the code knowledge graph for functions, classes, routes, and variables. Use INSTEAD "
|
||||
"OF grep/glob when finding code definitions, implementations, or relationships. Three search "
|
||||
"modes: (1) query='update settings' for BM25 ranked full-text search with camelCase "
|
||||
@@ -329,7 +370,7 @@ static const tool_def_t TOOLS[] = {
|
||||
"increment offset by limit and re-call while has_more is true.\"}},"
|
||||
"\"required\":[\"project\"]}"},
|
||||
|
||||
{"query_graph",
|
||||
{"query_graph", "Query graph",
|
||||
"Execute a Cypher query against the knowledge graph for complex multi-hop patterns, "
|
||||
"aggregations, and cross-service analysis. The response includes 'total' (returned "
|
||||
"row count). There is a hard 100k row ceiling — for broad queries add LIMIT in the "
|
||||
@@ -352,7 +393,7 @@ static const tool_def_t TOOLS[] = {
|
||||
"ceiling. No offset support — use search_graph for paginated browsing.\"}},"
|
||||
"\"required\":[\"query\",\"project\"]}"},
|
||||
|
||||
{"trace_path",
|
||||
{"trace_path", "Trace path",
|
||||
"Trace paths through the code graph. Modes: calls (callers/callees), data_flow (value "
|
||||
"propagation with args at each hop), cross_service (through HTTP/async Route nodes). "
|
||||
"Use INSTEAD OF grep for callers, dependencies, impact analysis, or data flow tracing.",
|
||||
@@ -373,7 +414,7 @@ static const tool_def_t TOOLS[] = {
|
||||
"filtered out. When true, test nodes are included with is_test=true marker."
|
||||
"\"}},\"required\":[\"function_name\",\"project\"]}"},
|
||||
|
||||
{"get_code_snippet",
|
||||
{"get_code_snippet", "Get code snippet",
|
||||
"Read source code for a function/class/symbol. IMPORTANT: First call search_graph to find the "
|
||||
"exact qualified_name, then pass it here. This is a read tool, not a search tool. Accepts "
|
||||
"full qualified_name (exact match) or short function name (returns suggestions if ambiguous).",
|
||||
@@ -382,11 +423,12 @@ static const tool_def_t TOOLS[] = {
|
||||
"\"type\":\"string\"},\"include_neighbors\":{"
|
||||
"\"type\":\"boolean\",\"default\":false}},\"required\":[\"qualified_name\",\"project\"]}"},
|
||||
|
||||
{"get_graph_schema", "Get the schema of the knowledge graph (node labels, edge types)",
|
||||
{"get_graph_schema", "Get graph schema",
|
||||
"Get the schema of the knowledge graph (node labels, edge types)",
|
||||
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":["
|
||||
"\"project\"]}"},
|
||||
|
||||
{"get_architecture",
|
||||
{"get_architecture", "Get architecture",
|
||||
"Get high-level architecture overview — packages, services, dependencies, and project "
|
||||
"structure at a glance. Includes 'clusters': Leiden community detection over the call/import "
|
||||
"graph, surfacing the de-facto modules (each with a label, member count, cohesion score, "
|
||||
@@ -399,7 +441,7 @@ static const tool_def_t TOOLS[] = {
|
||||
"\"aspects\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"project\"]"
|
||||
"}"},
|
||||
|
||||
{"search_code",
|
||||
{"search_code", "Search code",
|
||||
"Graph-augmented code search. Finds text patterns via grep, then enriches results with "
|
||||
"the knowledge graph: deduplicates matches into containing functions, ranks by structural "
|
||||
"importance (definitions first, popular functions next, tests last). "
|
||||
@@ -423,17 +465,17 @@ static const tool_def_t TOOLS[] = {
|
||||
"offset parameter — raise limit or narrow with file_pattern / path_filter to see more."
|
||||
"\",\"default\":10}},\"required\":[\"pattern\",\"project\"]}"},
|
||||
|
||||
{"list_projects", "List all indexed projects", "{\"type\":\"object\",\"properties\":{}}"},
|
||||
|
||||
{"delete_project", "Delete a project from the index",
|
||||
{"list_projects", "List projects", "List all indexed projects",
|
||||
"{\"type\":\"object\",\"properties\":{}}"},
|
||||
{"delete_project", "Delete project", "Delete a project from the index",
|
||||
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":["
|
||||
"\"project\"]}"},
|
||||
|
||||
{"index_status", "Get the indexing status of a project",
|
||||
{"index_status", "Index status", "Get the indexing status of a project",
|
||||
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":["
|
||||
"\"project\"]}"},
|
||||
|
||||
{"detect_changes", "Detect code changes and their impact",
|
||||
{"detect_changes", "Detect changes", "Detect code changes and their impact",
|
||||
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"scope\":{\"type\":"
|
||||
"\"string\"},\"depth\":{\"type\":\"integer\",\"default\":2},\"base_branch\":{\"type\":"
|
||||
"\"string\",\"default\":\"main\"},\"since\":{\"type\":\"string\",\"description\":"
|
||||
@@ -441,13 +483,13 @@ static const tool_def_t TOOLS[] = {
|
||||
"\"required\":"
|
||||
"[\"project\"]}"},
|
||||
|
||||
{"manage_adr", "Create or update Architecture Decision Records",
|
||||
{"manage_adr", "Manage ADR", "Create or update Architecture Decision Records",
|
||||
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"mode\":{\"type\":"
|
||||
"\"string\",\"enum\":[\"get\",\"update\",\"sections\"]},\"content\":{\"type\":\"string\"},"
|
||||
"\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"project\"]"
|
||||
"}"},
|
||||
|
||||
{"ingest_traces", "Ingest runtime traces to enhance the knowledge graph",
|
||||
{"ingest_traces", "Ingest traces", "Ingest runtime traces to enhance the knowledge graph",
|
||||
"{\"type\":\"object\",\"properties\":{\"traces\":{\"type\":\"array\",\"items\":{\"type\":"
|
||||
"\"object\"}},\"project\":{\"type\":"
|
||||
"\"string\"}},\"required\":[\"traces\",\"project\"]}"},
|
||||
@@ -455,37 +497,111 @@ static const tool_def_t TOOLS[] = {
|
||||
|
||||
static const int TOOL_COUNT = sizeof(TOOLS) / sizeof(TOOLS[0]);
|
||||
|
||||
char *cbm_mcp_tools_list(void) {
|
||||
static const char MCP_TOOL_OUTPUT_SCHEMA[] = "{\"type\":\"object\",\"additionalProperties\":true}";
|
||||
|
||||
static void mcp_add_json_schema(yyjson_mut_doc *doc, yyjson_mut_val *obj, const char *key,
|
||||
const char *schema_json) {
|
||||
yyjson_doc *schema_doc = yyjson_read(schema_json, strlen(schema_json), 0);
|
||||
if (schema_doc) {
|
||||
yyjson_mut_val *schema = yyjson_val_mut_copy(doc, yyjson_doc_get_root(schema_doc));
|
||||
if (schema) {
|
||||
yyjson_mut_obj_add_val(doc, obj, key, schema);
|
||||
}
|
||||
yyjson_doc_free(schema_doc);
|
||||
}
|
||||
}
|
||||
|
||||
static void mcp_add_tool_def(yyjson_mut_doc *doc, yyjson_mut_val *tools, int i) {
|
||||
yyjson_mut_val *tool = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_str(doc, tool, "name", TOOLS[i].name);
|
||||
yyjson_mut_obj_add_str(doc, tool, "title", TOOLS[i].title);
|
||||
yyjson_mut_obj_add_str(doc, tool, "description", TOOLS[i].description);
|
||||
|
||||
mcp_add_json_schema(doc, tool, "inputSchema", TOOLS[i].input_schema);
|
||||
mcp_add_json_schema(doc, tool, "outputSchema", MCP_TOOL_OUTPUT_SCHEMA);
|
||||
|
||||
yyjson_mut_arr_add_val(tools, tool);
|
||||
}
|
||||
|
||||
static char *cbm_mcp_tools_list_range(int offset, int limit, bool include_next_cursor) {
|
||||
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
|
||||
yyjson_mut_val *root = yyjson_mut_obj(doc);
|
||||
yyjson_mut_doc_set_root(doc, root);
|
||||
|
||||
yyjson_mut_val *tools = yyjson_mut_arr(doc);
|
||||
|
||||
for (int i = 0; i < TOOL_COUNT; i++) {
|
||||
yyjson_mut_val *tool = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_str(doc, tool, "name", TOOLS[i].name);
|
||||
yyjson_mut_obj_add_str(doc, tool, "description", TOOLS[i].description);
|
||||
if (offset < 0) {
|
||||
offset = 0;
|
||||
}
|
||||
if (offset > TOOL_COUNT) {
|
||||
offset = TOOL_COUNT;
|
||||
}
|
||||
if (limit < 0 || limit > TOOL_COUNT) {
|
||||
limit = TOOL_COUNT;
|
||||
}
|
||||
|
||||
/* Parse input schema JSON and embed */
|
||||
yyjson_doc *schema_doc =
|
||||
yyjson_read(TOOLS[i].input_schema, strlen(TOOLS[i].input_schema), 0);
|
||||
if (schema_doc) {
|
||||
yyjson_mut_val *schema = yyjson_val_mut_copy(doc, yyjson_doc_get_root(schema_doc));
|
||||
yyjson_mut_obj_add_val(doc, tool, "inputSchema", schema);
|
||||
yyjson_doc_free(schema_doc);
|
||||
}
|
||||
int end = offset + limit;
|
||||
if (end > TOOL_COUNT) {
|
||||
end = TOOL_COUNT;
|
||||
}
|
||||
|
||||
yyjson_mut_arr_add_val(tools, tool);
|
||||
for (int i = offset; i < end; i++) {
|
||||
mcp_add_tool_def(doc, tools, i);
|
||||
}
|
||||
|
||||
yyjson_mut_obj_add_val(doc, root, "tools", tools);
|
||||
if (include_next_cursor && end < TOOL_COUNT) {
|
||||
char cursor[32];
|
||||
snprintf(cursor, sizeof(cursor), "%d", end);
|
||||
yyjson_mut_obj_add_strcpy(doc, root, "nextCursor", cursor);
|
||||
}
|
||||
|
||||
char *out = yy_doc_to_str(doc);
|
||||
yyjson_mut_doc_free(doc);
|
||||
return out;
|
||||
}
|
||||
|
||||
char *cbm_mcp_tools_list(void) {
|
||||
return cbm_mcp_tools_list_range(0, TOOL_COUNT, false);
|
||||
}
|
||||
|
||||
static int mcp_tools_cursor_offset(const char *params_json) {
|
||||
if (!params_json) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
yyjson_doc *doc = yyjson_read(params_json, strlen(params_json), 0);
|
||||
if (!doc) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int offset = 0;
|
||||
yyjson_val *root = yyjson_doc_get_root(doc);
|
||||
yyjson_val *cursor = root ? yyjson_obj_get(root, "cursor") : NULL;
|
||||
if (cursor) {
|
||||
offset = TOOL_COUNT;
|
||||
if (yyjson_is_str(cursor)) {
|
||||
const char *cursor_str = yyjson_get_str(cursor);
|
||||
if (cursor_str && *cursor_str != '\0') {
|
||||
char *endptr = NULL;
|
||||
errno = 0;
|
||||
long parsed = strtol(cursor_str, &endptr, 10);
|
||||
if (endptr && *endptr == '\0' && errno == 0 && parsed >= 0) {
|
||||
offset = parsed > TOOL_COUNT ? TOOL_COUNT : (int)parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yyjson_doc_free(doc);
|
||||
return offset;
|
||||
}
|
||||
|
||||
static char *cbm_mcp_tools_list_page(const char *params_json) {
|
||||
return cbm_mcp_tools_list_range(mcp_tools_cursor_offset(params_json), MCP_TOOLS_PAGE_SIZE,
|
||||
true);
|
||||
}
|
||||
|
||||
/* Supported protocol versions, newest first. The server picks the newest
|
||||
* version that it shares with the client (per MCP spec version negotiation). */
|
||||
static const char *SUPPORTED_PROTOCOL_VERSIONS[] = {
|
||||
@@ -531,6 +647,7 @@ char *cbm_mcp_initialize_response(const char *params_json) {
|
||||
|
||||
yyjson_mut_val *caps = yyjson_mut_obj(doc);
|
||||
yyjson_mut_val *tools_cap = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_bool(doc, tools_cap, "listChanged", false);
|
||||
yyjson_mut_obj_add_val(doc, caps, "tools", tools_cap);
|
||||
yyjson_mut_obj_add_val(doc, root, "capabilities", caps);
|
||||
|
||||
@@ -644,6 +761,7 @@ struct cbm_mcp_server {
|
||||
/* Active pipeline tracking for cancellation support */
|
||||
cbm_pipeline_t *active_pipeline; /* non-NULL while index_repository runs */
|
||||
int64_t active_request_id; /* JSON-RPC id of the in-progress tool call */
|
||||
char *active_request_id_str; /* string JSON-RPC id of the in-progress tool call */
|
||||
};
|
||||
|
||||
cbm_mcp_server_t *cbm_mcp_server_new(const char *store_path) {
|
||||
@@ -703,6 +821,7 @@ void cbm_mcp_server_free(cbm_mcp_server_t *srv) {
|
||||
cbm_store_close(srv->store);
|
||||
}
|
||||
free(srv->current_project);
|
||||
free(srv->active_request_id_str);
|
||||
free(srv);
|
||||
}
|
||||
|
||||
@@ -2918,15 +3037,18 @@ static bool build_index_success_response(cbm_mcp_server_t *srv, yyjson_mut_doc *
|
||||
static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
|
||||
char *repo_path = cbm_mcp_get_string_arg(args, "repo_path");
|
||||
char *mode_str = cbm_mcp_get_string_arg(args, "mode");
|
||||
char *name_override = cbm_mcp_get_string_arg(args, "name");
|
||||
cbm_normalize_path_sep(repo_path);
|
||||
|
||||
if (!repo_path) {
|
||||
free(mode_str);
|
||||
free(name_override);
|
||||
return cbm_mcp_text_result("repo_path is required", true);
|
||||
}
|
||||
|
||||
if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) {
|
||||
free(mode_str);
|
||||
free(name_override);
|
||||
char *result = handle_cross_repo_mode(repo_path, args);
|
||||
free(repo_path);
|
||||
return result;
|
||||
@@ -2944,9 +3066,17 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
|
||||
|
||||
cbm_pipeline_t *p = cbm_pipeline_new(repo_path, NULL, mode);
|
||||
if (!p) {
|
||||
free(name_override);
|
||||
free(repo_path);
|
||||
return cbm_mcp_text_result("failed to create pipeline", true);
|
||||
}
|
||||
if (name_override && name_override[0] && !cbm_pipeline_set_project_name(p, name_override)) {
|
||||
cbm_pipeline_free(p);
|
||||
free(name_override);
|
||||
free(repo_path);
|
||||
return cbm_mcp_text_result("invalid project name", true);
|
||||
}
|
||||
free(name_override);
|
||||
cbm_pipeline_set_persistence(p, persistence);
|
||||
|
||||
char *project_name = heap_strdup(cbm_pipeline_project_name(p));
|
||||
@@ -4962,18 +5092,21 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) {
|
||||
/* Notifications (no id) → handle cancellation, then no response */
|
||||
if (!req.has_id) {
|
||||
if (req.method && strcmp(req.method, "notifications/cancelled") == 0) {
|
||||
/* MCP cancellation: cancel the active pipeline if request ID matches */
|
||||
if (srv->active_pipeline) {
|
||||
if (srv->active_pipeline &&
|
||||
cbm_mcp_cancel_request_matches(req.params_raw, srv->active_request_id,
|
||||
srv->active_request_id_str)) {
|
||||
cbm_pipeline_cancel(srv->active_pipeline);
|
||||
cbm_log_info("mcp.cancelled", "request_id_active",
|
||||
srv->active_request_id > 0 ? "yes" : "none");
|
||||
cbm_log_info("mcp.cancelled", "match", "true");
|
||||
}
|
||||
}
|
||||
cbm_jsonrpc_request_free(&req);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
struct timespec req_t0;
|
||||
cbm_clock_gettime(CLOCK_MONOTONIC, &req_t0);
|
||||
char *result_json = NULL;
|
||||
bool request_logged = false;
|
||||
|
||||
if (strcmp(req.method, "initialize") == 0) {
|
||||
result_json = cbm_mcp_initialize_response(req.params_raw);
|
||||
@@ -4983,21 +5116,31 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) {
|
||||
} else if (strcmp(req.method, "ping") == 0) {
|
||||
result_json = heap_strdup("{}");
|
||||
} else if (strcmp(req.method, "tools/list") == 0) {
|
||||
result_json = cbm_mcp_tools_list();
|
||||
result_json = cbm_mcp_tools_list_page(req.params_raw);
|
||||
} else if (strcmp(req.method, "tools/call") == 0) {
|
||||
char *tool_name = req.params_raw ? cbm_mcp_get_tool_name(req.params_raw) : NULL;
|
||||
char *tool_args =
|
||||
req.params_raw ? cbm_mcp_get_arguments(req.params_raw) : heap_strdup("{}");
|
||||
srv->active_request_id = req.id;
|
||||
free(srv->active_request_id_str);
|
||||
srv->active_request_id_str = req.id_str ? heap_strdup(req.id_str) : NULL;
|
||||
|
||||
struct timespec t0;
|
||||
cbm_clock_gettime(CLOCK_MONOTONIC, &t0);
|
||||
result_json = cbm_mcp_handle_tool(srv, tool_name, tool_args);
|
||||
srv->active_request_id = CBM_NOT_FOUND;
|
||||
free(srv->active_request_id_str);
|
||||
srv->active_request_id_str = NULL;
|
||||
struct timespec t1;
|
||||
cbm_clock_gettime(CLOCK_MONOTONIC, &t1);
|
||||
long long dur_us = ((long long)(t1.tv_sec - t0.tv_sec) * MCP_S_TO_US) +
|
||||
((long long)(t1.tv_nsec - t0.tv_nsec) / MCP_MS_TO_US);
|
||||
bool is_err = (result_json != NULL) && (strstr(result_json, "\"isError\":true") != NULL);
|
||||
cbm_diag_record_query(dur_us, is_err);
|
||||
long long request_dur_us = ((long long)(t1.tv_sec - req_t0.tv_sec) * MCP_S_TO_US) +
|
||||
((long long)(t1.tv_nsec - req_t0.tv_nsec) / MCP_MS_TO_US);
|
||||
cbm_log_mcp_request(req.method, tool_name, is_err, request_dur_us);
|
||||
request_logged = true;
|
||||
|
||||
result_json = inject_update_notice(srv, result_json);
|
||||
free(tool_name);
|
||||
@@ -5013,10 +5156,23 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) {
|
||||
.error_json = err_obj,
|
||||
};
|
||||
char *err = cbm_jsonrpc_format_response(&err_resp);
|
||||
struct timespec t1;
|
||||
cbm_clock_gettime(CLOCK_MONOTONIC, &t1);
|
||||
long long dur_us = ((long long)(t1.tv_sec - req_t0.tv_sec) * MCP_S_TO_US) +
|
||||
((long long)(t1.tv_nsec - req_t0.tv_nsec) / MCP_MS_TO_US);
|
||||
cbm_log_mcp_request(req.method, NULL, true, dur_us);
|
||||
cbm_jsonrpc_request_free(&req);
|
||||
return err;
|
||||
}
|
||||
|
||||
if (!request_logged) {
|
||||
struct timespec t1;
|
||||
cbm_clock_gettime(CLOCK_MONOTONIC, &t1);
|
||||
long long dur_us = ((long long)(t1.tv_sec - req_t0.tv_sec) * MCP_S_TO_US) +
|
||||
((long long)(t1.tv_nsec - req_t0.tv_nsec) / MCP_MS_TO_US);
|
||||
cbm_log_mcp_request(req.method, NULL, false, dur_us);
|
||||
}
|
||||
|
||||
cbm_jsonrpc_response_t resp = {
|
||||
.id = req.id,
|
||||
.id_str = req.id_str,
|
||||
|
||||
@@ -54,6 +54,10 @@ char *cbm_jsonrpc_format_error(int64_t id, int code, const char *message);
|
||||
/* Format an MCP tool result with text content. Returns heap-allocated JSON. */
|
||||
char *cbm_mcp_text_result(const char *text, bool is_error);
|
||||
|
||||
/* Return true when notifications/cancelled params target the active request. */
|
||||
bool cbm_mcp_cancel_request_matches(const char *params_json, int64_t active_id,
|
||||
const char *active_id_str);
|
||||
|
||||
/* Format the tools/list response. Returns heap-allocated JSON. */
|
||||
char *cbm_mcp_tools_list(void);
|
||||
|
||||
|
||||
+4
-4
@@ -363,10 +363,10 @@ char *cbm_project_name_from_path(const char *abs_path) {
|
||||
/* Normalize path separators */
|
||||
cbm_normalize_path_sep(path);
|
||||
|
||||
/* Map every character cbm_validate_project_name would reject. The
|
||||
* validator (used by resolve_store via project_db_path) allows only
|
||||
* [A-Za-z0-9._-], so anything else — path separators, ':', spaces, '@',
|
||||
* '+', … — must be normalized here. Otherwise a repo like
|
||||
/* Map every character that is unsafe for portable project DB names. We
|
||||
* keep derived names in [A-Za-z0-9._-], so anything else — path
|
||||
* separators, ':', spaces, '@', '+', … — must be normalized here.
|
||||
* Otherwise a repo like
|
||||
* "/home/u/my project" yields the name "home-u-my project": indexing
|
||||
* creates the DB and it shows in list_projects, but resolve_store rejects
|
||||
* the space and reports project-not-found (#349).
|
||||
|
||||
@@ -27,6 +27,7 @@ enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6, P
|
||||
#include "foundation/platform.h"
|
||||
#include "foundation/compat_fs.h"
|
||||
#include "foundation/log.h"
|
||||
#include "foundation/str_util.h"
|
||||
#include "foundation/hash_table.h"
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/compat_thread.h"
|
||||
@@ -175,6 +176,27 @@ void cbm_pipeline_set_persistence(cbm_pipeline_t *p, bool enabled) {
|
||||
}
|
||||
}
|
||||
|
||||
bool cbm_pipeline_set_project_name(cbm_pipeline_t *p, const char *name) {
|
||||
if (!p || !name || !name[0]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
char *normalized = cbm_project_name_from_path(name);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (!cbm_validate_project_name(normalized)) {
|
||||
free(normalized);
|
||||
return false;
|
||||
}
|
||||
|
||||
free(p->project_name);
|
||||
p->project_name = normalized;
|
||||
free(p->branch_qn);
|
||||
p->branch_qn = cbm_git_context_branch_qn(p->project_name, &p->git_ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
void cbm_pipeline_free(cbm_pipeline_t *p) {
|
||||
if (!p) {
|
||||
return;
|
||||
|
||||
@@ -63,6 +63,9 @@ void cbm_pipeline_cancel(cbm_pipeline_t *p);
|
||||
* owned by the pipeline. Valid until cbm_pipeline_free(). */
|
||||
const char *cbm_pipeline_project_name(const cbm_pipeline_t *p);
|
||||
|
||||
/* Override the derived project name with a sanitized user-provided label. */
|
||||
bool cbm_pipeline_set_project_name(cbm_pipeline_t *p, const char *name);
|
||||
|
||||
/* Get the index mode (CBM_MODE_FULL, CBM_MODE_MODERATE, CBM_MODE_FAST). */
|
||||
int cbm_pipeline_get_mode(const cbm_pipeline_t *p);
|
||||
|
||||
|
||||
+180
-26
@@ -19,10 +19,12 @@
|
||||
#include "ui/layout3d.h"
|
||||
#include "mcp/mcp.h"
|
||||
#include "store/store.h"
|
||||
#include "cli/cli.h"
|
||||
/* pipeline.h no longer needed — indexing runs as subprocess */
|
||||
#include "foundation/log.h"
|
||||
#include "foundation/platform.h"
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/compat_fs.h"
|
||||
#include "foundation/str_util.h"
|
||||
#include "foundation/compat_thread.h"
|
||||
|
||||
@@ -39,6 +41,7 @@
|
||||
#include <process.h>
|
||||
#include <psapi.h> /* GetProcessMemoryInfo */
|
||||
#else
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/wait.h>
|
||||
#endif
|
||||
@@ -79,6 +82,33 @@ static void update_cors(const cbm_http_req_t *req) {
|
||||
snprintf(g_cors_json, sizeof(g_cors_json), "%sContent-Type: application/json\r\n", g_cors);
|
||||
}
|
||||
|
||||
static const char *detect_ui_lang(const char *accept_language) {
|
||||
if (accept_language && (strstr(accept_language, "zh-CN") || strstr(accept_language, "zh"))) {
|
||||
return "zh";
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
static void handle_ui_config(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
const char *lang = NULL;
|
||||
char cache_dir[1024];
|
||||
snprintf(cache_dir, sizeof(cache_dir), "%s", cbm_resolve_cache_dir());
|
||||
cbm_config_t *cfg = cbm_config_open(cache_dir);
|
||||
if (cfg) {
|
||||
const char *pinned = cbm_config_get(cfg, CBM_CONFIG_UI_LANG, "auto");
|
||||
if (strcmp(pinned, "zh") == 0 || strcmp(pinned, "en") == 0) {
|
||||
lang = pinned;
|
||||
}
|
||||
}
|
||||
|
||||
char lang_buf[8];
|
||||
snprintf(lang_buf, sizeof(lang_buf), "%s", lang ? lang : detect_ui_lang(req->accept_language));
|
||||
if (cfg) {
|
||||
cbm_config_close(cfg);
|
||||
}
|
||||
cbm_http_replyf(c, 200, g_cors_json, "{\"lang\":\"%s\"}", lang_buf);
|
||||
}
|
||||
|
||||
/* ── Server state ─────────────────────────────────────────────── */
|
||||
|
||||
struct cbm_http_server {
|
||||
@@ -396,6 +426,26 @@ static void handle_process_kill(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
|
||||
#include <dirent.h>
|
||||
|
||||
static void append_roots_json(char *buf, size_t bufsz, int *pos) {
|
||||
*pos += snprintf(buf + *pos, bufsz - (size_t)*pos, ",\"roots\":[");
|
||||
#ifdef _WIN32
|
||||
DWORD drives = GetLogicalDrives();
|
||||
int count = 0;
|
||||
for (int i = 0; i < 26; i++) {
|
||||
if (!(drives & (1u << i))) {
|
||||
continue;
|
||||
}
|
||||
if (count++ > 0) {
|
||||
buf[(*pos)++] = ',';
|
||||
}
|
||||
*pos += snprintf(buf + *pos, bufsz - (size_t)*pos, "\"%c:/\"", 'A' + i);
|
||||
}
|
||||
#else
|
||||
*pos += snprintf(buf + *pos, bufsz - (size_t)*pos, "\"/\"");
|
||||
#endif
|
||||
*pos += snprintf(buf + *pos, bufsz - (size_t)*pos, "]");
|
||||
}
|
||||
|
||||
/* GET /api/browse?path=/some/dir — list subdirectories for file picker */
|
||||
static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
char path[1024] = {0};
|
||||
@@ -481,7 +531,9 @@ static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
{
|
||||
char esc_parent[2048];
|
||||
cbm_json_escape(esc_parent, (int)sizeof(esc_parent), parent);
|
||||
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "],\"parent\":\"%s\"}", esc_parent);
|
||||
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "],\"parent\":\"%s\"", esc_parent);
|
||||
append_roots_json(buf, sizeof(buf), &pos);
|
||||
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "}");
|
||||
}
|
||||
cbm_http_replyf(c, 200, g_cors_json, "%s", buf);
|
||||
}
|
||||
@@ -596,9 +648,107 @@ static void handle_adr_save(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
|
||||
static char g_binary_path[1024] = {0};
|
||||
|
||||
static bool copy_path(char *out, size_t outsz, const char *path) {
|
||||
if (!out || outsz == 0 || !path || !path[0]) {
|
||||
return false;
|
||||
}
|
||||
int n = snprintf(out, outsz, "%s", path);
|
||||
return n > 0 && (size_t)n < outsz;
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
static bool is_executable_file(const char *path) {
|
||||
struct stat st;
|
||||
return path && stat(path, &st) == 0 && S_ISREG(st.st_mode) && access(path, X_OK) == 0;
|
||||
}
|
||||
|
||||
static bool resolve_from_path(const char *name, char *out, size_t outsz) {
|
||||
const char *path = getenv("PATH");
|
||||
if (!name || !name[0] || strchr(name, '/') || !path || !path[0]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const char *cur = path;
|
||||
while (*cur) {
|
||||
const char *colon = strchr(cur, ':');
|
||||
size_t dir_len = colon ? (size_t)(colon - cur) : strlen(cur);
|
||||
if (dir_len > 0 && dir_len < 900) {
|
||||
char candidate[1024];
|
||||
int n = snprintf(candidate, sizeof(candidate), "%.*s/%s", (int)dir_len, cur, name);
|
||||
if (n > 0 && (size_t)n < sizeof(candidate) && is_executable_file(candidate)) {
|
||||
return copy_path(out, outsz, candidate);
|
||||
}
|
||||
}
|
||||
if (!colon) {
|
||||
break;
|
||||
}
|
||||
cur = colon + 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool resolve_self_executable(char *out, size_t outsz) {
|
||||
#if defined(__APPLE__)
|
||||
char buf[1024];
|
||||
uint32_t sz = sizeof(buf);
|
||||
if (_NSGetExecutablePath(buf, &sz) == 0 && buf[0]) {
|
||||
return copy_path(out, outsz, buf);
|
||||
}
|
||||
return false;
|
||||
#else
|
||||
char buf[1024];
|
||||
ssize_t len = readlink("/proc/self/exe", buf, sizeof(buf) - 1);
|
||||
if (len > 0) {
|
||||
buf[len] = '\0';
|
||||
return copy_path(out, outsz, buf);
|
||||
}
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
static bool resolve_self_executable(char *out, size_t outsz) {
|
||||
char buf[1024];
|
||||
DWORD n = GetModuleFileNameA(NULL, buf, (DWORD)sizeof(buf));
|
||||
if (n > 0 && n < sizeof(buf)) {
|
||||
return copy_path(out, outsz, buf);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool cbm_http_server_resolve_binary_path(const char *argv0, char *out, size_t outsz) {
|
||||
if (!out || outsz == 0) {
|
||||
return false;
|
||||
}
|
||||
out[0] = '\0';
|
||||
|
||||
#ifndef _WIN32
|
||||
if (argv0 && strchr(argv0, '/') && is_executable_file(argv0)) {
|
||||
return copy_path(out, outsz, argv0);
|
||||
}
|
||||
if (resolve_from_path(argv0, out, outsz)) {
|
||||
return true;
|
||||
}
|
||||
#else
|
||||
if (argv0 && argv0[0]) {
|
||||
DWORD attrs = GetFileAttributesA(argv0);
|
||||
if (attrs != INVALID_FILE_ATTRIBUTES && !(attrs & FILE_ATTRIBUTE_DIRECTORY)) {
|
||||
return copy_path(out, outsz, argv0);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if (resolve_self_executable(out, outsz)) {
|
||||
return true;
|
||||
}
|
||||
return copy_path(out, outsz, argv0);
|
||||
}
|
||||
|
||||
void cbm_http_server_set_binary_path(const char *path) {
|
||||
if (path) {
|
||||
snprintf(g_binary_path, sizeof(g_binary_path), "%s", path);
|
||||
if (!cbm_http_server_resolve_binary_path(path, g_binary_path, sizeof(g_binary_path))) {
|
||||
g_binary_path[0] = '\0';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,36 +761,24 @@ static void *index_thread_fn(void *arg) {
|
||||
const char *bin = g_binary_path;
|
||||
char self_path[1024] = {0};
|
||||
if (!bin[0]) {
|
||||
#ifdef _WIN32
|
||||
GetModuleFileNameA(NULL, self_path, sizeof(self_path));
|
||||
#elif defined(__APPLE__)
|
||||
uint32_t sz = sizeof(self_path);
|
||||
_NSGetExecutablePath(self_path, &sz);
|
||||
#else
|
||||
ssize_t len = readlink("/proc/self/exe", self_path, sizeof(self_path) - 1);
|
||||
if (len > 0)
|
||||
self_path[len] = '\0';
|
||||
#endif
|
||||
cbm_http_server_resolve_binary_path(NULL, self_path, sizeof(self_path));
|
||||
bin = self_path[0] ? self_path : "codebase-memory-mcp";
|
||||
}
|
||||
|
||||
char log_file[256];
|
||||
|
||||
/* JSON-escape root_path to prevent injection via double-quotes or backslashes */
|
||||
/* JSON-escape root_path and optional project name. */
|
||||
char escaped_path[2048];
|
||||
{
|
||||
const char *s = job->root_path;
|
||||
size_t j = 0;
|
||||
for (; *s && j < sizeof(escaped_path) - 2; s++) {
|
||||
if (*s == '"' || *s == '\\') {
|
||||
escaped_path[j++] = '\\';
|
||||
}
|
||||
escaped_path[j++] = *s;
|
||||
}
|
||||
escaped_path[j] = '\0';
|
||||
}
|
||||
cbm_json_escape(escaped_path, (int)sizeof(escaped_path), job->root_path);
|
||||
char escaped_name[512];
|
||||
cbm_json_escape(escaped_name, (int)sizeof(escaped_name), job->project_name);
|
||||
char json_arg[4096];
|
||||
snprintf(json_arg, sizeof(json_arg), "{\"repo_path\":\"%s\"}", escaped_path);
|
||||
if (job->project_name[0]) {
|
||||
snprintf(json_arg, sizeof(json_arg), "{\"repo_path\":\"%s\",\"name\":\"%s\"}", escaped_path,
|
||||
escaped_name);
|
||||
} else {
|
||||
snprintf(json_arg, sizeof(json_arg), "{\"repo_path\":\"%s\"}", escaped_path);
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
snprintf(log_file, sizeof(log_file), "%s\\cbm_index_%d.log",
|
||||
@@ -766,7 +904,7 @@ static void *index_thread_fn(void *arg) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* POST /api/index — body: {"root_path": "/abs/path"} → starts background indexing */
|
||||
/* POST /api/index — body: {"root_path": "/abs/path", "project_name": "..."} */
|
||||
static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
if (req->body_len == 0 || req->body_len > 4096) {
|
||||
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid body\"}");
|
||||
@@ -786,6 +924,8 @@ static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
return;
|
||||
}
|
||||
const char *rpath = yyjson_get_str(v_path);
|
||||
yyjson_val *v_project_name = yyjson_obj_get(root, "project_name");
|
||||
const char *project_name = yyjson_is_str(v_project_name) ? yyjson_get_str(v_project_name) : "";
|
||||
|
||||
/* Check path exists */
|
||||
if (!cbm_is_dir(rpath)) {
|
||||
@@ -811,6 +951,7 @@ static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
|
||||
index_job_t *job = &g_index_jobs[slot];
|
||||
snprintf(job->root_path, sizeof(job->root_path), "%s", rpath);
|
||||
snprintf(job->project_name, sizeof(job->project_name), "%s", project_name);
|
||||
job->error_msg[0] = '\0';
|
||||
atomic_store(&job->status, 1);
|
||||
yyjson_doc_free(doc);
|
||||
@@ -1266,6 +1407,12 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c,
|
||||
return;
|
||||
}
|
||||
|
||||
/* GET /api/ui-config → language and local UI preferences */
|
||||
if (is_get && cbm_http_path_match(req->path, "/api/ui-config")) {
|
||||
handle_ui_config(c, req);
|
||||
return;
|
||||
}
|
||||
|
||||
/* DELETE /api/project → delete a project's .db file */
|
||||
if (is_delete && cbm_http_path_match(req->path, "/api/project*")) {
|
||||
handle_delete_project(c, req);
|
||||
@@ -1400,15 +1547,22 @@ void cbm_http_server_run(cbm_http_server_t *srv) {
|
||||
if (!conn)
|
||||
continue; /* timeout — re-check stop flag */
|
||||
|
||||
uint64_t request_start_ms = cbm_now_ms();
|
||||
cbm_http_req_t req;
|
||||
int rc = cbm_httpd_read_request(conn, &req);
|
||||
if (rc == 0) {
|
||||
dispatch_request(srv, conn, &req);
|
||||
cbm_log_http_request("graph_ui", req.method, req.path, cbm_http_conn_status(conn),
|
||||
(int64_t)(cbm_now_ms() - request_start_ms), req.body_len,
|
||||
cbm_http_conn_response_bytes(conn));
|
||||
cbm_http_req_free(&req);
|
||||
} else if (rc > 0) {
|
||||
/* Parse/transport error with a known HTTP status (400/408/411/413/431).
|
||||
* No CORS reflection here — the request was never parsed. */
|
||||
cbm_http_replyf(conn, rc, "", "bad request");
|
||||
cbm_log_http_request("graph_ui", "", "", cbm_http_conn_status(conn),
|
||||
(int64_t)(cbm_now_ms() - request_start_ms), 0,
|
||||
cbm_http_conn_response_bytes(conn));
|
||||
}
|
||||
cbm_httpd_conn_close(conn);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#define CBM_UI_HTTP_SERVER_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
typedef struct cbm_http_server cbm_http_server_t;
|
||||
|
||||
@@ -47,4 +48,7 @@ void cbm_ui_log_append(const char *line);
|
||||
/* Set the binary path for subprocess spawning (call from main). */
|
||||
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);
|
||||
|
||||
#endif /* CBM_UI_HTTP_SERVER_H */
|
||||
@@ -52,6 +52,8 @@ struct cbm_httpd {
|
||||
struct cbm_http_conn {
|
||||
cbm_sock_t fd;
|
||||
int recv_deadline_ms;
|
||||
int response_status;
|
||||
size_t response_bytes;
|
||||
};
|
||||
|
||||
/* ── Small platform helpers ───────────────────────────────────── */
|
||||
@@ -215,6 +217,14 @@ void cbm_httpd_conn_close(cbm_http_conn_t *c) {
|
||||
free(c);
|
||||
}
|
||||
|
||||
int cbm_http_conn_status(const cbm_http_conn_t *c) {
|
||||
return c ? c->response_status : 0;
|
||||
}
|
||||
|
||||
size_t cbm_http_conn_response_bytes(const cbm_http_conn_t *c) {
|
||||
return c ? c->response_bytes : 0;
|
||||
}
|
||||
|
||||
/* ── Head parsing ─────────────────────────────────────────────── */
|
||||
|
||||
static bool header_name_is(const char *line, size_t name_len, const char *name) {
|
||||
@@ -332,6 +342,8 @@ int cbm_http_parse_head(const char *data, size_t len, cbm_http_req_t *req, size_
|
||||
|
||||
if (header_name_is(p, nlen, "origin")) {
|
||||
copy_header_value(colon + 1, eol, req->origin, sizeof(req->origin));
|
||||
} else if (header_name_is(p, nlen, "accept-language")) {
|
||||
copy_header_value(colon + 1, eol, req->accept_language, sizeof(req->accept_language));
|
||||
} else if (header_name_is(p, nlen, "transfer-encoding")) {
|
||||
/* Chunked (or any transfer coding) is not supported. */
|
||||
return 411;
|
||||
@@ -506,6 +518,8 @@ void cbm_http_reply_buf(cbm_http_conn_t *c, int status, const char *extra_header
|
||||
size_t len) {
|
||||
if (!c)
|
||||
return;
|
||||
c->response_status = status;
|
||||
c->response_bytes = len;
|
||||
char head[1024];
|
||||
int hn = snprintf(head, sizeof(head),
|
||||
"HTTP/1.1 %d %s\r\n"
|
||||
|
||||
+5
-2
@@ -42,13 +42,14 @@ typedef struct cbm_httpd cbm_httpd_t; /* listener */
|
||||
typedef struct cbm_http_conn cbm_http_conn_t; /* accepted connection */
|
||||
|
||||
/* A parsed request. `path` and `query` are raw (NOT percent-decoded).
|
||||
* `origin` is the Origin header value ("" when absent) — the only header
|
||||
* the routing layer consumes. `body` is heap-allocated, NUL-terminated. */
|
||||
* `origin` and `accept_language` are the header values consumed by the
|
||||
* routing layer ("" when absent). `body` is heap-allocated, NUL-terminated. */
|
||||
typedef struct {
|
||||
char method[16];
|
||||
char path[2048];
|
||||
char query[2048];
|
||||
char origin[256];
|
||||
char accept_language[256];
|
||||
char *body;
|
||||
size_t body_len;
|
||||
} cbm_http_req_t;
|
||||
@@ -95,6 +96,8 @@ void cbm_http_replyf(cbm_http_conn_t *c, int status, const char *extra_headers,
|
||||
void cbm_http_reply_buf(cbm_http_conn_t *c, int status, const char *extra_headers, const void *data,
|
||||
size_t len);
|
||||
|
||||
int cbm_http_conn_status(const cbm_http_conn_t *c);
|
||||
size_t cbm_http_conn_response_bytes(const cbm_http_conn_t *c);
|
||||
void cbm_httpd_conn_close(cbm_http_conn_t *c);
|
||||
|
||||
/* ── Pure helpers (unit-tested without sockets) ───────────────── */
|
||||
|
||||
+29
-3
@@ -17,13 +17,15 @@
|
||||
#include <yyjson/yyjson.h>
|
||||
|
||||
#include <math.h>
|
||||
#include <errno.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/* ── Constants ────────────────────────────────────────────────── */
|
||||
|
||||
#define DEFAULT_MAX_NODES 50000
|
||||
#define DEFAULT_MAX_NODES 2000
|
||||
#define HARD_MAX_NODES 10000
|
||||
#define BH_THETA 1.2f
|
||||
|
||||
/* Local optimization: gentle, preserves structure */
|
||||
@@ -112,6 +114,31 @@ static float rand_float(uint32_t *seed) {
|
||||
return (float)((*seed >> 16) & 0x7FFF) / 32768.0f - 0.5f;
|
||||
}
|
||||
|
||||
static int render_node_limit(void) {
|
||||
const char *raw = getenv("CBM_UI_MAX_RENDER_NODES");
|
||||
if (!raw || !raw[0]) {
|
||||
return DEFAULT_MAX_NODES;
|
||||
}
|
||||
errno = 0;
|
||||
char *end = NULL;
|
||||
long v = strtol(raw, &end, 10);
|
||||
if (errno != 0 || end == raw || *end != '\0' || v <= 0) {
|
||||
return DEFAULT_MAX_NODES;
|
||||
}
|
||||
if (v > HARD_MAX_NODES) {
|
||||
return HARD_MAX_NODES;
|
||||
}
|
||||
return (int)v;
|
||||
}
|
||||
|
||||
static int clamp_max_nodes(int requested) {
|
||||
int cap = render_node_limit();
|
||||
if (requested <= 0 || requested > cap) {
|
||||
return cap;
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
|
||||
/* ── Barnes-Hut Octree ────────────────────────────────────────── */
|
||||
|
||||
typedef struct octree_node {
|
||||
@@ -389,8 +416,7 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project,
|
||||
int radius, int max_nodes) {
|
||||
if (!store || !project)
|
||||
return NULL;
|
||||
if (max_nodes <= 0)
|
||||
max_nodes = DEFAULT_MAX_NODES;
|
||||
max_nodes = clamp_max_nodes(max_nodes);
|
||||
(void)center_node;
|
||||
(void)radius;
|
||||
(void)level;
|
||||
|
||||
@@ -478,6 +478,19 @@ TEST(project_name_always_validator_safe_issue349) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(project_name_encodes_unicode_segments_issue571) {
|
||||
char *got = cbm_project_name_from_path(
|
||||
"/Users/yunxin/Desktop/\xe5\xbc\x80\xe5\x8f\x91/"
|
||||
"\xe5\x90\x8e\xe7\xab\xaf/"
|
||||
"\xe4\xbf\xa1\xe7\xa7\x9f\xe9\xa3\x8e\xe6\x8e\xa7\xe9\x80\x9a\xe5\x90\x8e\xe7\xab\xaf");
|
||||
ASSERT_NOT_NULL(got);
|
||||
ASSERT_STR_EQ(got, "Users-yunxin-Desktop-e5bc80e58f91-e5908ee7abaf-"
|
||||
"e4bfa1e7a79fe9a38ee68ea7e9809ae5908ee7abaf");
|
||||
ASSERT_TRUE(cbm_validate_project_name(got));
|
||||
free(got);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Suite
|
||||
* ================================================================ */
|
||||
@@ -579,6 +592,7 @@ SUITE(fqn) {
|
||||
RUN_TEST(project_name_already_dashed);
|
||||
RUN_TEST(project_name_deep_path);
|
||||
RUN_TEST(project_name_always_validator_safe_issue349);
|
||||
RUN_TEST(project_name_encodes_unicode_segments_issue571);
|
||||
RUN_TEST(project_name_colon_only);
|
||||
RUN_TEST(project_name_backslash_only);
|
||||
RUN_TEST(project_name_consecutive_colons);
|
||||
|
||||
@@ -11,7 +11,12 @@
|
||||
* RPC dispatch, transport limits, receive deadline, clean shutdown.
|
||||
*/
|
||||
#include "../src/foundation/compat.h"
|
||||
#include "../src/foundation/compat_fs.h"
|
||||
#include "../src/foundation/compat_thread.h"
|
||||
#include "../src/foundation/log.h"
|
||||
#include "../src/foundation/platform.h"
|
||||
#include "../src/cli/cli.h"
|
||||
#include "../src/ui/http_server.h"
|
||||
#include "test_framework.h"
|
||||
#include "test_helpers.h"
|
||||
#include "ui/httpd.h"
|
||||
@@ -20,6 +25,9 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#ifndef _WIN32
|
||||
#include <sys/stat.h>
|
||||
#endif
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <winsock2.h>
|
||||
@@ -37,6 +45,18 @@ typedef int th_sock_t;
|
||||
#define TH_SOCK_BAD (-1)
|
||||
#endif
|
||||
|
||||
static char httpd_log_buf[8192];
|
||||
|
||||
static void httpd_capture_log(const char *line) {
|
||||
size_t used = strlen(httpd_log_buf);
|
||||
size_t avail = sizeof(httpd_log_buf) - used;
|
||||
if (avail <= 1)
|
||||
return;
|
||||
int n = snprintf(httpd_log_buf + used, avail, "%s\n", line ? line : "");
|
||||
if (n < 0 || (size_t)n >= avail)
|
||||
httpd_log_buf[sizeof(httpd_log_buf) - 1] = '\0';
|
||||
}
|
||||
|
||||
/* ── Raw-socket test client ───────────────────────────────────── */
|
||||
|
||||
static th_sock_t th_connect(int port) {
|
||||
@@ -312,6 +332,41 @@ TEST(httpd_path_match_matrix) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(httpd_resolves_bare_binary_path_from_path) {
|
||||
#ifdef _WIN32
|
||||
PASS();
|
||||
#else
|
||||
char tmpdir[256];
|
||||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_httpd_bin_XXXXXX");
|
||||
char *td = cbm_mkdtemp(tmpdir);
|
||||
ASSERT_NOT_NULL(td);
|
||||
|
||||
char exe[512];
|
||||
snprintf(exe, sizeof(exe), "%s/codebase-memory-mcp", td);
|
||||
FILE *f = fopen(exe, "w");
|
||||
ASSERT_NOT_NULL(f);
|
||||
fputs("#!/bin/sh\nexit 0\n", f);
|
||||
fclose(f);
|
||||
ASSERT_EQ(chmod(exe, 0755), 0);
|
||||
|
||||
char *old_path = getenv("PATH") ? strdup(getenv("PATH")) : NULL;
|
||||
cbm_setenv("PATH", td, 1);
|
||||
|
||||
char resolved[1024];
|
||||
ASSERT_TRUE(
|
||||
cbm_http_server_resolve_binary_path("codebase-memory-mcp", resolved, sizeof(resolved)));
|
||||
ASSERT_STR_EQ(resolved, exe);
|
||||
|
||||
if (old_path) {
|
||||
cbm_setenv("PATH", old_path, 1);
|
||||
free(old_path);
|
||||
} else {
|
||||
cbm_unsetenv("PATH");
|
||||
}
|
||||
PASS();
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ── Transport integration (listener only) ────────────────────── */
|
||||
|
||||
TEST(httpd_listen_ephemeral_port) {
|
||||
@@ -507,6 +562,61 @@ TEST(ui_server_browse_traversal_probe) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(ui_server_ui_config_detects_zh_accept_language) {
|
||||
th_server_t ts;
|
||||
ASSERT_EQ(th_server_start(&ts), 0);
|
||||
|
||||
char resp[4096];
|
||||
int n = th_http(cbm_http_server_port(ts.srv),
|
||||
"GET /api/ui-config HTTP/1.1\r\n"
|
||||
"Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\r\n"
|
||||
"\r\n",
|
||||
resp, sizeof(resp));
|
||||
ASSERT_TRUE(n > 0);
|
||||
ASSERT_EQ(th_status(resp), 200);
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"lang\":\"zh\""));
|
||||
|
||||
th_server_stop(&ts);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(ui_server_ui_config_prefers_config_lang) {
|
||||
char tmpdir[256];
|
||||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_httpd_cfg_XXXXXX");
|
||||
char *td = cbm_mkdtemp(tmpdir);
|
||||
ASSERT_NOT_NULL(td);
|
||||
|
||||
char *old_home = getenv("HOME") ? strdup(getenv("HOME")) : NULL;
|
||||
cbm_setenv("HOME", td, 1);
|
||||
|
||||
char cache_dir[1024];
|
||||
snprintf(cache_dir, sizeof(cache_dir), "%s", cbm_resolve_cache_dir());
|
||||
cbm_config_t *cfg = cbm_config_open(cache_dir);
|
||||
ASSERT_NOT_NULL(cfg);
|
||||
ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_UI_LANG, "zh"), 0);
|
||||
cbm_config_close(cfg);
|
||||
|
||||
th_server_t ts;
|
||||
ASSERT_EQ(th_server_start(&ts), 0);
|
||||
|
||||
char resp[4096];
|
||||
int n = th_http(cbm_http_server_port(ts.srv),
|
||||
"GET /api/ui-config HTTP/1.1\r\n"
|
||||
"Accept-Language: en-US,en;q=0.9\r\n"
|
||||
"\r\n",
|
||||
resp, sizeof(resp));
|
||||
ASSERT_TRUE(n > 0);
|
||||
ASSERT_EQ(th_status(resp), 200);
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"lang\":\"zh\""));
|
||||
|
||||
th_server_stop(&ts);
|
||||
if (old_home) {
|
||||
cbm_setenv("HOME", old_home, 1);
|
||||
free(old_home);
|
||||
}
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(ui_server_slow_request_hits_deadline) {
|
||||
th_server_t ts;
|
||||
ASSERT_EQ(th_server_start(&ts), 0);
|
||||
@@ -535,6 +645,35 @@ TEST(ui_server_slow_request_hits_deadline) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(ui_server_access_log_redacts_query) {
|
||||
httpd_log_buf[0] = '\0';
|
||||
CBMLogLevel prev_level = cbm_log_get_level();
|
||||
cbm_log_set_level(CBM_LOG_DEBUG);
|
||||
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
|
||||
cbm_log_set_sink_ex(httpd_capture_log, CBM_LOG_SINK_REPLACE);
|
||||
|
||||
th_server_t ts;
|
||||
ASSERT_EQ(th_server_start(&ts), 0);
|
||||
char resp[4096];
|
||||
int n = th_http(cbm_http_server_port(ts.srv),
|
||||
"GET /definitely/not/here?token=secret HTTP/1.1\r\n\r\n", resp, sizeof(resp));
|
||||
ASSERT_GT(n, 0);
|
||||
ASSERT_EQ(th_status(resp), 404);
|
||||
th_server_stop(&ts);
|
||||
|
||||
cbm_log_set_sink(NULL);
|
||||
cbm_log_set_level(prev_level);
|
||||
|
||||
ASSERT_NOT_NULL(strstr(httpd_log_buf, "msg=http.request"));
|
||||
ASSERT_NOT_NULL(strstr(httpd_log_buf, "component=graph_ui"));
|
||||
ASSERT_NOT_NULL(strstr(httpd_log_buf, "method=GET"));
|
||||
ASSERT_NOT_NULL(strstr(httpd_log_buf, "path=/definitely/not/here"));
|
||||
ASSERT_NOT_NULL(strstr(httpd_log_buf, "status=404"));
|
||||
ASSERT_NULL(strstr(httpd_log_buf, "token"));
|
||||
ASSERT_NULL(strstr(httpd_log_buf, "secret"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(ui_server_stop_joins_cleanly) {
|
||||
th_server_t ts;
|
||||
ASSERT_EQ(th_server_start(&ts), 0);
|
||||
@@ -562,6 +701,7 @@ SUITE(httpd) {
|
||||
RUN_TEST(httpd_query_param_decode);
|
||||
RUN_TEST(httpd_query_param_edge_cases);
|
||||
RUN_TEST(httpd_path_match_matrix);
|
||||
RUN_TEST(httpd_resolves_bare_binary_path_from_path);
|
||||
|
||||
/* Transport */
|
||||
RUN_TEST(httpd_listen_ephemeral_port);
|
||||
@@ -577,6 +717,9 @@ SUITE(httpd) {
|
||||
RUN_TEST(ui_server_encoded_slash_not_routed);
|
||||
RUN_TEST(ui_server_nul_in_target_rejected);
|
||||
RUN_TEST(ui_server_browse_traversal_probe);
|
||||
RUN_TEST(ui_server_ui_config_detects_zh_accept_language);
|
||||
RUN_TEST(ui_server_ui_config_prefers_config_lang);
|
||||
RUN_TEST(ui_server_slow_request_hits_deadline);
|
||||
RUN_TEST(ui_server_access_log_redacts_query);
|
||||
RUN_TEST(ui_server_stop_joins_cleanly);
|
||||
}
|
||||
@@ -22,9 +22,14 @@ static inline bool cbm_str_contains_raw(const char *s, const char *sub) {
|
||||
}
|
||||
|
||||
static char log_buf[4096];
|
||||
static char sink_buf[4096];
|
||||
static int saved_stderr;
|
||||
static int pipe_fds[2];
|
||||
|
||||
static void test_log_sink(const char *line) {
|
||||
snprintf(sink_buf, sizeof(sink_buf), "%s", line ? line : "");
|
||||
}
|
||||
|
||||
static void capture_start(void) {
|
||||
fflush(stderr);
|
||||
saved_stderr = dup(STDERR_FILENO);
|
||||
@@ -112,6 +117,100 @@ TEST(log_int_helper) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(log_json_output) {
|
||||
cbm_log_set_level(CBM_LOG_DEBUG);
|
||||
cbm_log_set_format(CBM_LOG_FORMAT_JSON);
|
||||
capture_start();
|
||||
cbm_log_info("test.msg", "key1", "val1", "key2", "line\nbreak");
|
||||
const char *output = capture_end();
|
||||
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
|
||||
cbm_log_set_level(CBM_LOG_INFO);
|
||||
|
||||
ASSERT(cbm_str_contains_raw(output, "\"level\":\"info\""));
|
||||
ASSERT(cbm_str_contains_raw(output, "\"event\":\"test.msg\""));
|
||||
ASSERT(cbm_str_contains_raw(output, "\"key1\":\"val1\""));
|
||||
ASSERT(cbm_str_contains_raw(output, "\"key2\":\"line\\nbreak\""));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(log_text_sanitizes_control_chars) {
|
||||
cbm_log_set_level(CBM_LOG_DEBUG);
|
||||
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
|
||||
capture_start();
|
||||
cbm_log_info("test\nmsg", "key", "line\r\nbreak\tvalue");
|
||||
const char *output = capture_end();
|
||||
cbm_log_set_level(CBM_LOG_INFO);
|
||||
|
||||
ASSERT(cbm_str_contains_raw(output, "msg=test_msg"));
|
||||
ASSERT(cbm_str_contains_raw(output, "key=line__break_value"));
|
||||
ASSERT_EQ(output[strlen(output) - 1], '\n');
|
||||
ASSERT_NULL(strchr(output, '\r'));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(log_sink_tee_keeps_stderr) {
|
||||
sink_buf[0] = '\0';
|
||||
cbm_log_set_level(CBM_LOG_DEBUG);
|
||||
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
|
||||
cbm_log_set_sink_ex(test_log_sink, CBM_LOG_SINK_TEE);
|
||||
capture_start();
|
||||
cbm_log_info("tee.msg", "key", "val");
|
||||
const char *output = capture_end();
|
||||
cbm_log_set_sink(NULL);
|
||||
cbm_log_set_level(CBM_LOG_INFO);
|
||||
|
||||
ASSERT(cbm_str_contains_raw(output, "msg=tee.msg"));
|
||||
ASSERT(cbm_str_contains_raw(sink_buf, "msg=tee.msg"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(log_operational_helpers) {
|
||||
cbm_log_set_level(CBM_LOG_DEBUG);
|
||||
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
|
||||
capture_start();
|
||||
cbm_log_mcp_request("tools/call", "search_graph", false, 1250);
|
||||
cbm_log_http_request("graph_ui", "GET", "/api/layout", 200, 7, 0, 42);
|
||||
const char *output = capture_end();
|
||||
cbm_log_set_level(CBM_LOG_INFO);
|
||||
|
||||
ASSERT(cbm_str_contains_raw(output, "msg=mcp.request"));
|
||||
ASSERT(cbm_str_contains_raw(output, "protocol=jsonrpc"));
|
||||
ASSERT(cbm_str_contains_raw(output, "method=tools/call"));
|
||||
ASSERT(cbm_str_contains_raw(output, "tool=search_graph"));
|
||||
ASSERT(cbm_str_contains_raw(output, "msg=http.request"));
|
||||
ASSERT(cbm_str_contains_raw(output, "method=GET"));
|
||||
ASSERT(cbm_str_contains_raw(output, "path=/api/layout"));
|
||||
ASSERT(cbm_str_contains_raw(output, "status=200"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(log_format_from_env) {
|
||||
cbm_setenv("CBM_LOG_FORMAT", "json", 1);
|
||||
cbm_log_init_from_env();
|
||||
ASSERT_EQ(cbm_log_get_format(), CBM_LOG_FORMAT_JSON);
|
||||
|
||||
cbm_setenv("CBM_LOG_FORMAT", "text", 1);
|
||||
cbm_log_init_from_env();
|
||||
ASSERT_EQ(cbm_log_get_format(), CBM_LOG_FORMAT_TEXT);
|
||||
|
||||
cbm_unsetenv("CBM_LOG_FORMAT");
|
||||
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(log_format_unset_keeps_current) {
|
||||
cbm_unsetenv("CBM_LOG_FORMAT");
|
||||
cbm_log_set_format(CBM_LOG_FORMAT_JSON);
|
||||
cbm_log_init_from_env();
|
||||
ASSERT_EQ(cbm_log_get_format(), CBM_LOG_FORMAT_JSON);
|
||||
|
||||
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
|
||||
cbm_log_init_from_env();
|
||||
ASSERT_EQ(cbm_log_get_format(), CBM_LOG_FORMAT_TEXT);
|
||||
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* CBM_LOG_LEVEL parsing — distilled from #414 (closes #413). */
|
||||
TEST(log_level_from_env_textual) {
|
||||
cbm_setenv("CBM_LOG_LEVEL", "error", 1);
|
||||
@@ -191,6 +290,12 @@ SUITE(log) {
|
||||
RUN_TEST(log_filtered_by_level);
|
||||
RUN_TEST(log_error_output);
|
||||
RUN_TEST(log_int_helper);
|
||||
RUN_TEST(log_json_output);
|
||||
RUN_TEST(log_text_sanitizes_control_chars);
|
||||
RUN_TEST(log_sink_tee_keeps_stderr);
|
||||
RUN_TEST(log_operational_helpers);
|
||||
RUN_TEST(log_format_from_env);
|
||||
RUN_TEST(log_format_unset_keeps_current);
|
||||
RUN_TEST(log_level_from_env_textual);
|
||||
RUN_TEST(log_level_from_env_numeric);
|
||||
RUN_TEST(log_level_from_env_invalid_ignored);
|
||||
|
||||
+115
-89
@@ -10,6 +10,30 @@ int tf_skip_count = 0;
|
||||
|
||||
#include "test_framework.h"
|
||||
#include <sqlite3.h>
|
||||
#include <stdbool.h>
|
||||
#include <string.h>
|
||||
|
||||
static int g_suite_argc = 0;
|
||||
static char **g_suite_argv = NULL;
|
||||
|
||||
static bool suite_requested(const char *name) {
|
||||
if (g_suite_argc <= 1) {
|
||||
return true;
|
||||
}
|
||||
for (int i = 1; i < g_suite_argc; i++) {
|
||||
if (strcmp(g_suite_argv[i], name) == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#define RUN_SELECTED_SUITE(name) \
|
||||
do { \
|
||||
if (suite_requested(#name)) { \
|
||||
RUN_SUITE(name); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
/* Forward declarations of suite functions */
|
||||
extern void suite_arena(void);
|
||||
@@ -106,155 +130,157 @@ extern void suite_dump_verify_io(void);
|
||||
* caches at thread teardown (pass_parallel.c). */
|
||||
extern void cbm_kind_in_set_free_cache(void);
|
||||
|
||||
int main(void) {
|
||||
int main(int argc, char **argv) {
|
||||
g_suite_argc = argc;
|
||||
g_suite_argv = argv;
|
||||
printf("\n codebase-memory-mcp C test suite\n");
|
||||
|
||||
/* Foundation */
|
||||
RUN_SUITE(arena);
|
||||
RUN_SUITE(hash_table);
|
||||
RUN_SUITE(dyn_array);
|
||||
RUN_SUITE(str_intern);
|
||||
RUN_SUITE(log);
|
||||
RUN_SUITE(str_util);
|
||||
RUN_SUITE(platform);
|
||||
RUN_SUITE(dump_verify);
|
||||
RUN_SELECTED_SUITE(arena);
|
||||
RUN_SELECTED_SUITE(hash_table);
|
||||
RUN_SELECTED_SUITE(dyn_array);
|
||||
RUN_SELECTED_SUITE(str_intern);
|
||||
RUN_SELECTED_SUITE(log);
|
||||
RUN_SELECTED_SUITE(str_util);
|
||||
RUN_SELECTED_SUITE(platform);
|
||||
RUN_SELECTED_SUITE(dump_verify);
|
||||
|
||||
/* Existing C code regression tests */
|
||||
RUN_SUITE(ac);
|
||||
RUN_SUITE(extraction);
|
||||
RUN_SUITE(extraction_inheritance);
|
||||
RUN_SUITE(extraction_imports);
|
||||
RUN_SUITE(grammar_regression);
|
||||
RUN_SUITE(grammar_labels);
|
||||
RUN_SUITE(grammar_imports);
|
||||
RUN_SELECTED_SUITE(ac);
|
||||
RUN_SELECTED_SUITE(extraction);
|
||||
RUN_SELECTED_SUITE(extraction_inheritance);
|
||||
RUN_SELECTED_SUITE(extraction_imports);
|
||||
RUN_SELECTED_SUITE(grammar_regression);
|
||||
RUN_SELECTED_SUITE(grammar_labels);
|
||||
RUN_SELECTED_SUITE(grammar_imports);
|
||||
|
||||
/* Store (M5) */
|
||||
RUN_SUITE(store_nodes);
|
||||
RUN_SUITE(store_edges);
|
||||
RUN_SUITE(store_search);
|
||||
RUN_SUITE(store_bulk);
|
||||
RUN_SUITE(store_pragmas);
|
||||
RUN_SUITE(store_checkpoint);
|
||||
RUN_SUITE(dump_verify_io);
|
||||
RUN_SELECTED_SUITE(store_nodes);
|
||||
RUN_SELECTED_SUITE(store_edges);
|
||||
RUN_SELECTED_SUITE(store_search);
|
||||
RUN_SELECTED_SUITE(store_bulk);
|
||||
RUN_SELECTED_SUITE(store_pragmas);
|
||||
RUN_SELECTED_SUITE(store_checkpoint);
|
||||
RUN_SELECTED_SUITE(dump_verify_io);
|
||||
|
||||
/* Cypher (M6) */
|
||||
RUN_SUITE(cypher);
|
||||
RUN_SELECTED_SUITE(cypher);
|
||||
|
||||
/* MCP Server (M9) */
|
||||
RUN_SUITE(mcp);
|
||||
RUN_SELECTED_SUITE(mcp);
|
||||
|
||||
/* Discover (M2) */
|
||||
RUN_SUITE(language);
|
||||
RUN_SUITE(userconfig);
|
||||
RUN_SUITE(gitignore);
|
||||
RUN_SUITE(discover);
|
||||
RUN_SELECTED_SUITE(language);
|
||||
RUN_SELECTED_SUITE(userconfig);
|
||||
RUN_SELECTED_SUITE(gitignore);
|
||||
RUN_SELECTED_SUITE(discover);
|
||||
|
||||
/* Graph Buffer (M7) */
|
||||
RUN_SUITE(graph_buffer);
|
||||
RUN_SELECTED_SUITE(graph_buffer);
|
||||
|
||||
/* Pipeline (M8) */
|
||||
RUN_SUITE(registry);
|
||||
RUN_SUITE(pipeline);
|
||||
RUN_SUITE(fqn);
|
||||
RUN_SUITE(route_canon);
|
||||
RUN_SUITE(path_alias);
|
||||
RUN_SELECTED_SUITE(registry);
|
||||
RUN_SELECTED_SUITE(pipeline);
|
||||
RUN_SELECTED_SUITE(fqn);
|
||||
RUN_SELECTED_SUITE(route_canon);
|
||||
RUN_SELECTED_SUITE(path_alias);
|
||||
|
||||
/* Watcher (M10) */
|
||||
RUN_SUITE(watcher);
|
||||
RUN_SELECTED_SUITE(watcher);
|
||||
|
||||
/* LZ4 + zstd + SQLite writer */
|
||||
RUN_SUITE(lz4);
|
||||
RUN_SUITE(zstd);
|
||||
RUN_SUITE(sqlite_writer);
|
||||
RUN_SELECTED_SUITE(lz4);
|
||||
RUN_SELECTED_SUITE(zstd);
|
||||
RUN_SELECTED_SUITE(sqlite_writer);
|
||||
|
||||
/* Persistent artifact export/import */
|
||||
RUN_SUITE(artifact);
|
||||
RUN_SELECTED_SUITE(artifact);
|
||||
|
||||
/* LSP resolvers */
|
||||
RUN_SUITE(scope);
|
||||
RUN_SUITE(type_rep);
|
||||
RUN_SUITE(go_lsp);
|
||||
RUN_SUITE(c_lsp);
|
||||
RUN_SUITE(php_lsp);
|
||||
RUN_SUITE(cs_lsp);
|
||||
RUN_SUITE(cs_lsp_bench);
|
||||
RUN_SUITE(py_lsp);
|
||||
RUN_SUITE(kotlin_lsp);
|
||||
RUN_SUITE(rust_lsp);
|
||||
RUN_SUITE(py_lsp_bench);
|
||||
RUN_SUITE(py_lsp_stress);
|
||||
RUN_SUITE(py_lsp_scale);
|
||||
RUN_SUITE(ts_lsp);
|
||||
RUN_SUITE(java_lsp);
|
||||
RUN_SUITE(java_lsp_coverage);
|
||||
RUN_SELECTED_SUITE(scope);
|
||||
RUN_SELECTED_SUITE(type_rep);
|
||||
RUN_SELECTED_SUITE(go_lsp);
|
||||
RUN_SELECTED_SUITE(c_lsp);
|
||||
RUN_SELECTED_SUITE(php_lsp);
|
||||
RUN_SELECTED_SUITE(cs_lsp);
|
||||
RUN_SELECTED_SUITE(cs_lsp_bench);
|
||||
RUN_SELECTED_SUITE(py_lsp);
|
||||
RUN_SELECTED_SUITE(kotlin_lsp);
|
||||
RUN_SELECTED_SUITE(rust_lsp);
|
||||
RUN_SELECTED_SUITE(py_lsp_bench);
|
||||
RUN_SELECTED_SUITE(py_lsp_stress);
|
||||
RUN_SELECTED_SUITE(py_lsp_scale);
|
||||
RUN_SELECTED_SUITE(ts_lsp);
|
||||
RUN_SELECTED_SUITE(java_lsp);
|
||||
RUN_SELECTED_SUITE(java_lsp_coverage);
|
||||
|
||||
/* Architecture + ADR + Louvain */
|
||||
RUN_SUITE(store_arch);
|
||||
RUN_SELECTED_SUITE(store_arch);
|
||||
|
||||
/* HTTP link */
|
||||
|
||||
/* Traces helpers */
|
||||
RUN_SUITE(traces);
|
||||
RUN_SELECTED_SUITE(traces);
|
||||
|
||||
/* Config link */
|
||||
RUN_SUITE(configlink);
|
||||
RUN_SELECTED_SUITE(configlink);
|
||||
|
||||
/* Infrastructure scanning */
|
||||
RUN_SUITE(infrascan);
|
||||
RUN_SELECTED_SUITE(infrascan);
|
||||
|
||||
/* CLI (install, update, config) */
|
||||
RUN_SUITE(cli);
|
||||
RUN_SELECTED_SUITE(cli);
|
||||
|
||||
/* System info + worker pool (parallelism) */
|
||||
RUN_SUITE(system_info);
|
||||
RUN_SUITE(worker_pool);
|
||||
RUN_SELECTED_SUITE(system_info);
|
||||
RUN_SELECTED_SUITE(worker_pool);
|
||||
|
||||
/* Parallel pipeline */
|
||||
RUN_SUITE(parallel);
|
||||
RUN_SELECTED_SUITE(parallel);
|
||||
|
||||
/* mem + arena + slab integration */
|
||||
RUN_SUITE(mem);
|
||||
RUN_SELECTED_SUITE(mem);
|
||||
|
||||
/* UI (config, embedded assets, layout) */
|
||||
RUN_SUITE(ui);
|
||||
RUN_SELECTED_SUITE(ui);
|
||||
|
||||
/* UI HTTP server (transport + routing) */
|
||||
RUN_SUITE(httpd);
|
||||
RUN_SELECTED_SUITE(httpd);
|
||||
|
||||
/* Security defenses */
|
||||
RUN_SUITE(security);
|
||||
RUN_SELECTED_SUITE(security);
|
||||
|
||||
/* YAML parser */
|
||||
RUN_SUITE(yaml);
|
||||
RUN_SELECTED_SUITE(yaml);
|
||||
|
||||
/* SimHash / SIMILAR_TO */
|
||||
RUN_SUITE(simhash);
|
||||
RUN_SELECTED_SUITE(simhash);
|
||||
|
||||
/* Stack overflow regression (GitHub #199) */
|
||||
RUN_SUITE(stack_overflow);
|
||||
RUN_SELECTED_SUITE(stack_overflow);
|
||||
|
||||
/* Integration (end-to-end) */
|
||||
RUN_SUITE(integration);
|
||||
RUN_SELECTED_SUITE(integration);
|
||||
|
||||
/* Per-language graph contracts (node/edge types, attribution, no-crash) */
|
||||
RUN_SUITE(lang_contract);
|
||||
RUN_SUITE(edge_imports);
|
||||
RUN_SUITE(edge_structural);
|
||||
RUN_SUITE(lsp_resolution_probe);
|
||||
RUN_SUITE(node_creation_probe);
|
||||
RUN_SUITE(edge_types_probe);
|
||||
RUN_SUITE(convergence_probe);
|
||||
RUN_SUITE(matrix_known_classes);
|
||||
RUN_SUITE(matrix_new_constructs);
|
||||
RUN_SUITE(grammar_probe_a);
|
||||
RUN_SUITE(grammar_probe_b);
|
||||
RUN_SUITE(grammar_probe_c);
|
||||
RUN_SUITE(grammar_probe_d);
|
||||
RUN_SUITE(grammar_probe_e);
|
||||
RUN_SUITE(grammar_probe_f);
|
||||
RUN_SUITE(grammar_probe_g);
|
||||
RUN_SELECTED_SUITE(lang_contract);
|
||||
RUN_SELECTED_SUITE(edge_imports);
|
||||
RUN_SELECTED_SUITE(edge_structural);
|
||||
RUN_SELECTED_SUITE(lsp_resolution_probe);
|
||||
RUN_SELECTED_SUITE(node_creation_probe);
|
||||
RUN_SELECTED_SUITE(edge_types_probe);
|
||||
RUN_SELECTED_SUITE(convergence_probe);
|
||||
RUN_SELECTED_SUITE(matrix_known_classes);
|
||||
RUN_SELECTED_SUITE(matrix_new_constructs);
|
||||
RUN_SELECTED_SUITE(grammar_probe_a);
|
||||
RUN_SELECTED_SUITE(grammar_probe_b);
|
||||
RUN_SELECTED_SUITE(grammar_probe_c);
|
||||
RUN_SELECTED_SUITE(grammar_probe_d);
|
||||
RUN_SELECTED_SUITE(grammar_probe_e);
|
||||
RUN_SELECTED_SUITE(grammar_probe_f);
|
||||
RUN_SELECTED_SUITE(grammar_probe_g);
|
||||
|
||||
RUN_SUITE(incremental);
|
||||
RUN_SELECTED_SUITE(incremental);
|
||||
|
||||
/* Release process-lifetime caches so LeakSanitizer reports no leaks. */
|
||||
cbm_kind_in_set_free_cache();
|
||||
|
||||
+120
-12
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
#include "../src/foundation/compat.h"
|
||||
#include "../src/foundation/compat_fs.h" /* cbm_unlink / cbm_rmdir */
|
||||
#include "../src/foundation/log.h"
|
||||
#include "test_framework.h"
|
||||
#include <mcp/mcp.h>
|
||||
#include <store/store.h>
|
||||
@@ -14,6 +15,12 @@
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
|
||||
static char mcp_log_buf[4096];
|
||||
|
||||
static void mcp_capture_log(const char *line) {
|
||||
snprintf(mcp_log_buf, sizeof(mcp_log_buf), "%s", line ? line : "");
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
* JSON-RPC PARSING
|
||||
* ══════════════════════════════════════════════════════════════════ */
|
||||
@@ -144,6 +151,7 @@ TEST(mcp_initialize_response) {
|
||||
ASSERT_NOT_NULL(strstr(json, "codebase-memory-mcp"));
|
||||
ASSERT_NOT_NULL(strstr(json, "capabilities"));
|
||||
ASSERT_NOT_NULL(strstr(json, "tools"));
|
||||
ASSERT_NOT_NULL(strstr(json, "\"listChanged\":false"));
|
||||
ASSERT_NOT_NULL(strstr(json, "2025-11-25"));
|
||||
free(json);
|
||||
|
||||
@@ -188,6 +196,27 @@ TEST(mcp_tools_list) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(mcp_tools_list_latest_metadata) {
|
||||
char *json = cbm_mcp_tools_list();
|
||||
ASSERT_NOT_NULL(json);
|
||||
ASSERT_NOT_NULL(strstr(json, "\"title\":\"Search graph\""));
|
||||
ASSERT_NOT_NULL(strstr(json, "\"title\":\"Index repository\""));
|
||||
ASSERT_NOT_NULL(strstr(json, "\"outputSchema\":{\"type\":\"object\""));
|
||||
ASSERT_NOT_NULL(strstr(json, "\"additionalProperties\":true"));
|
||||
free(json);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(mcp_index_repository_declares_name_override_issue571) {
|
||||
char *json = cbm_mcp_tools_list();
|
||||
ASSERT_NOT_NULL(json);
|
||||
ASSERT_NOT_NULL(strstr(json, "\"index_repository\""));
|
||||
ASSERT_NOT_NULL(strstr(json, "\"name\":{\"type\":\"string\""));
|
||||
ASSERT_NOT_NULL(strstr(json, "Non-ASCII bytes are encoded"));
|
||||
free(json);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(mcp_tools_array_schemas_have_items) {
|
||||
/* VS Code 1.112+ rejects array schemas without "items" (see
|
||||
* https://github.com/microsoft/vscode/issues/248810).
|
||||
@@ -223,11 +252,32 @@ TEST(mcp_text_result) {
|
||||
ASSERT_NOT_NULL(strstr(json, "\"type\":\"text\""));
|
||||
/* The text value is JSON-escaped inside the "text" field */
|
||||
ASSERT_NOT_NULL(strstr(json, "total"));
|
||||
ASSERT_NOT_NULL(strstr(json, "\"structuredContent\":{\"total\":5}"));
|
||||
ASSERT_NOT_NULL(strstr(json, "\"isError\":false"));
|
||||
ASSERT_NULL(strstr(json, "\"isError\":true"));
|
||||
free(json);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(mcp_text_result_skips_structured_content_for_plain_text) {
|
||||
char *json = cbm_mcp_text_result("plain text", false);
|
||||
ASSERT_NOT_NULL(json);
|
||||
ASSERT_NULL(strstr(json, "\"structuredContent\""));
|
||||
ASSERT_NOT_NULL(strstr(json, "\"isError\":false"));
|
||||
free(json);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(mcp_cancel_matches_request_id) {
|
||||
ASSERT_TRUE(cbm_mcp_cancel_request_matches("{\"requestId\":7}", 7, NULL));
|
||||
ASSERT_FALSE(cbm_mcp_cancel_request_matches("{\"requestId\":8}", 7, NULL));
|
||||
ASSERT_TRUE(cbm_mcp_cancel_request_matches("{\"requestId\":\"call-1\"}", -1, "call-1"));
|
||||
ASSERT_FALSE(cbm_mcp_cancel_request_matches("{\"requestId\":\"call-2\"}", -1, "call-1"));
|
||||
ASSERT_FALSE(cbm_mcp_cancel_request_matches("{\"requestId\":7}", -1, "7"));
|
||||
ASSERT_FALSE(cbm_mcp_cancel_request_matches("{}", 7, NULL));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(mcp_text_result_error) {
|
||||
char *json = cbm_mcp_text_result("something failed", true);
|
||||
ASSERT_NOT_NULL(json);
|
||||
@@ -348,6 +398,58 @@ TEST(server_handle_tools_list) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(server_handle_tools_list_paginates) {
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
|
||||
char *resp =
|
||||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":200,\"method\":\"tools/list\"}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"id\":200"));
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"nextCursor\":\"8\""));
|
||||
ASSERT_NOT_NULL(strstr(resp, "index_repository"));
|
||||
ASSERT_NULL(strstr(resp, "manage_adr"));
|
||||
free(resp);
|
||||
|
||||
resp = cbm_mcp_server_handle(
|
||||
srv,
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":201,\"method\":\"tools/list\",\"params\":{\"cursor\":\"8\"}}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"id\":201"));
|
||||
ASSERT_NULL(strstr(resp, "\"nextCursor\""));
|
||||
ASSERT_NOT_NULL(strstr(resp, "manage_adr"));
|
||||
free(resp);
|
||||
|
||||
cbm_mcp_server_free(srv);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(server_handle_logs_request_without_params) {
|
||||
mcp_log_buf[0] = '\0';
|
||||
CBMLogLevel prev_level = cbm_log_get_level();
|
||||
cbm_log_set_level(CBM_LOG_DEBUG);
|
||||
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
|
||||
cbm_log_set_sink_ex(mcp_capture_log, CBM_LOG_SINK_REPLACE);
|
||||
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
char *resp =
|
||||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":210,\"method\":\"tools/list\","
|
||||
"\"params\":{\"token\":\"secret\"}}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
free(resp);
|
||||
cbm_mcp_server_free(srv);
|
||||
|
||||
cbm_log_set_sink(NULL);
|
||||
cbm_log_set_level(prev_level);
|
||||
|
||||
ASSERT_NOT_NULL(strstr(mcp_log_buf, "msg=mcp.request"));
|
||||
ASSERT_NOT_NULL(strstr(mcp_log_buf, "protocol=jsonrpc"));
|
||||
ASSERT_NOT_NULL(strstr(mcp_log_buf, "method=tools/list"));
|
||||
ASSERT_NOT_NULL(strstr(mcp_log_buf, "status=ok"));
|
||||
ASSERT_NULL(strstr(mcp_log_buf, "token"));
|
||||
ASSERT_NULL(strstr(mcp_log_buf, "secret"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(server_handle_unknown_method) {
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
|
||||
@@ -506,11 +608,11 @@ TEST(tool_search_graph_query_honors_file_pattern_issue552) {
|
||||
"FROM nodes;"),
|
||||
CBM_STORE_OK);
|
||||
|
||||
char *resp = cbm_mcp_server_handle(
|
||||
srv, "{\"jsonrpc\":\"2.0\",\"id\":552,\"method\":\"tools/call\","
|
||||
"\"params\":{\"name\":\"search_graph\","
|
||||
"\"arguments\":{\"project\":\"issue-552\",\"query\":\"status\","
|
||||
"\"file_pattern\":\"src/lib/*\",\"limit\":10}}}");
|
||||
char *resp =
|
||||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":552,\"method\":\"tools/call\","
|
||||
"\"params\":{\"name\":\"search_graph\","
|
||||
"\"arguments\":{\"project\":\"issue-552\",\"query\":\"status\","
|
||||
"\"file_pattern\":\"src/lib/*\",\"limit\":10}}}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
char *inner = extract_text_content(resp);
|
||||
ASSERT_NOT_NULL(inner);
|
||||
@@ -841,11 +943,11 @@ TEST(tool_get_architecture_path_scoping) {
|
||||
ASSERT_NOT_NULL(inner_root);
|
||||
ASSERT_NOT_NULL(strstr(inner_root, "Django"));
|
||||
|
||||
char *resp_scoped = cbm_mcp_server_handle(
|
||||
srv, "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\","
|
||||
"\"params\":{\"name\":\"get_architecture\","
|
||||
"\"arguments\":{\"project\":\"arch-path\",\"path\":\"apps/hoa\","
|
||||
"\"aspects\":[\"packages\"]}}}");
|
||||
char *resp_scoped =
|
||||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\","
|
||||
"\"params\":{\"name\":\"get_architecture\","
|
||||
"\"arguments\":{\"project\":\"arch-path\",\"path\":\"apps/hoa\","
|
||||
"\"aspects\":[\"packages\"]}}}");
|
||||
ASSERT_NOT_NULL(resp_scoped);
|
||||
char *inner_scoped = extract_text_content(resp_scoped);
|
||||
ASSERT_NOT_NULL(inner_scoped);
|
||||
@@ -1156,7 +1258,7 @@ TEST(tool_manage_adr_get_with_existing_adr) {
|
||||
/* ADR content must appear in response */
|
||||
ASSERT_NOT_NULL(strstr(resp, "PURPOSE"));
|
||||
/* Must not be an error */
|
||||
ASSERT_NULL(strstr(resp, "isError"));
|
||||
ASSERT_NULL(strstr(resp, "\"isError\":true"));
|
||||
free(resp);
|
||||
|
||||
/* Clean up */
|
||||
@@ -1202,7 +1304,7 @@ TEST(tool_manage_adr_unified_backend_issue256) {
|
||||
"\"mode\":\"get\"}}}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
ASSERT_NOT_NULL(strstr(resp, "Unified ADR backend."));
|
||||
ASSERT_NULL(strstr(resp, "isError"));
|
||||
ASSERT_NULL(strstr(resp, "\"isError\":true"));
|
||||
free(resp);
|
||||
|
||||
cbm_mcp_server_free(srv);
|
||||
@@ -2324,8 +2426,12 @@ SUITE(mcp) {
|
||||
/* MCP protocol helpers */
|
||||
RUN_TEST(mcp_initialize_response);
|
||||
RUN_TEST(mcp_tools_list);
|
||||
RUN_TEST(mcp_tools_list_latest_metadata);
|
||||
RUN_TEST(mcp_index_repository_declares_name_override_issue571);
|
||||
RUN_TEST(mcp_tools_array_schemas_have_items);
|
||||
RUN_TEST(mcp_text_result);
|
||||
RUN_TEST(mcp_text_result_skips_structured_content_for_plain_text);
|
||||
RUN_TEST(mcp_cancel_matches_request_id);
|
||||
RUN_TEST(mcp_text_result_error);
|
||||
|
||||
/* Argument extraction */
|
||||
@@ -2354,6 +2460,8 @@ SUITE(mcp) {
|
||||
RUN_TEST(server_handle_initialize);
|
||||
RUN_TEST(server_handle_initialized_notification);
|
||||
RUN_TEST(server_handle_tools_list);
|
||||
RUN_TEST(server_handle_tools_list_paginates);
|
||||
RUN_TEST(server_handle_logs_request_without_params);
|
||||
RUN_TEST(server_handle_unknown_method);
|
||||
|
||||
/* Server handle — edge cases */
|
||||
|
||||
@@ -317,6 +317,46 @@ TEST(layout_respects_max_nodes) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(layout_clamps_render_cap_from_env) {
|
||||
cbm_store_t *store = cbm_store_open_memory();
|
||||
ASSERT_NOT_NULL(store);
|
||||
|
||||
const char *old_raw = getenv("CBM_UI_MAX_RENDER_NODES");
|
||||
char *old_cap = old_raw ? strdup(old_raw) : NULL;
|
||||
cbm_setenv("CBM_UI_MAX_RENDER_NODES", "25", 1);
|
||||
|
||||
cbm_store_upsert_project(store, "test", "/tmp/test");
|
||||
|
||||
for (int i = 0; i < 40; i++) {
|
||||
char name[32], qn[64];
|
||||
snprintf(name, sizeof(name), "fn%d", i);
|
||||
snprintf(qn, sizeof(qn), "test::fn%d", i);
|
||||
cbm_node_t n = {.project = "test",
|
||||
.label = "Function",
|
||||
.name = name,
|
||||
.qualified_name = qn,
|
||||
.file_path = "a.c",
|
||||
.start_line = i,
|
||||
.end_line = i + 1};
|
||||
cbm_store_upsert_node(store, &n);
|
||||
}
|
||||
|
||||
cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 50000);
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_LTE(r->node_count, 25);
|
||||
ASSERT_EQ(r->total_nodes, 40);
|
||||
|
||||
cbm_layout_free(r);
|
||||
cbm_store_close(store);
|
||||
if (old_cap) {
|
||||
cbm_setenv("CBM_UI_MAX_RENDER_NODES", old_cap, 1);
|
||||
free(old_cap);
|
||||
} else {
|
||||
cbm_unsetenv("CBM_UI_MAX_RENDER_NODES");
|
||||
}
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(layout_deterministic) {
|
||||
cbm_store_t *store = cbm_store_open_memory();
|
||||
ASSERT_NOT_NULL(store);
|
||||
@@ -433,6 +473,7 @@ SUITE(ui) {
|
||||
RUN_TEST(layout_single_node);
|
||||
RUN_TEST(layout_two_connected);
|
||||
RUN_TEST(layout_respects_max_nodes);
|
||||
RUN_TEST(layout_clamps_render_cap_from_env);
|
||||
RUN_TEST(layout_deterministic);
|
||||
RUN_TEST(layout_to_json);
|
||||
RUN_TEST(layout_null_inputs);
|
||||
|
||||
Reference in new issue
Block a user