diff --git a/.gitignore b/.gitignore index 5860fb1b6..d9b8639ad 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,9 @@ build/ node_modules/ graph-ui/dist/ +# Generated by scripts/embed-frontend.sh during the --with-ui build +src/ui/embedded_assets.c + # Generated reports BENCHMARK_REPORT.md TEST_PLAN.md diff --git a/Makefile.cbm b/Makefile.cbm index 3ff50b81f..0b37ac5e5 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -89,9 +89,16 @@ ifeq ($(STATIC),1) STATIC_FLAGS := -static endif -LDFLAGS = -lm -lstdc++ -lpthread -lz $(LIBGIT2_LIBS) $(WIN32_LIBS) $(STATIC_FLAGS) -LDFLAGS_TEST = -lm -lstdc++ -lpthread -lz $(SANITIZE) $(LIBGIT2_LIBS) $(WIN32_LIBS) -LDFLAGS_TSAN = -lm -lstdc++ -lpthread -lz -fsanitize=thread $(LIBGIT2_LIBS) $(WIN32_LIBS) +# libdl: SQLite's dlopen/dlsym live in a separate libdl on glibc < 2.34. +# Linked on non-Windows (harmless stub on glibc >= 2.34 and macOS); MinGW has no libdl. +DL_LIBS := +ifneq ($(IS_MINGW),yes) +DL_LIBS := -ldl +endif + +LDFLAGS = -lm -lstdc++ -lpthread -lz $(DL_LIBS) $(LIBGIT2_LIBS) $(WIN32_LIBS) $(STATIC_FLAGS) +LDFLAGS_TEST = -lm -lstdc++ -lpthread -lz $(DL_LIBS) $(SANITIZE) $(LIBGIT2_LIBS) $(WIN32_LIBS) +LDFLAGS_TSAN = -lm -lstdc++ -lpthread -lz $(DL_LIBS) -fsanitize=thread $(LIBGIT2_LIBS) $(WIN32_LIBS) # ── Source files ───────────────────────────────────────────────── diff --git a/docs/SECURITY_ASSESSMENT.html b/docs/SECURITY_ASSESSMENT.html new file mode 100644 index 000000000..38b00b6fa --- /dev/null +++ b/docs/SECURITY_ASSESSMENT.html @@ -0,0 +1,290 @@ + + + + + +codebase-memory-mcp - Security Assessment + + + +
+ +

Security Assessment: codebase-memory-mcp

+

+ Scope: data exfiltration risk to proprietary source, remote code execution potential, and + project legitimacy. Reviewed at git e599df1 (branch main). + Assessment date: 2026-06-18. +

+ +
+
+
Code leak / exfiltration
+
No leak found
+
All outbound traffic is GET/download-only to GitHub. No source is uploaded.
+
+
+
Remote code execution
+
No RCE; caveat fixed
+
Tool inputs cannot inject shell commands. Self-update now fails closed when it cannot verify the binary.
+
+
+
Project legitimacy
+
Legitimate
+
MIT, 862 commits, real maintainers, arXiv paper, SLSA 3 + Sigstore signing.
+
+
+ +

1. What this tool is

+
+

+ codebase-memory-mcp is a code-intelligence engine written in pure C (zero runtime + dependencies). It parses a repository with tree-sitter (plus a hybrid LSP layer), builds a + persistent knowledge graph in a local SQLite database, and exposes ~14 MCP tools so AI coding + agents can query code structure instead of reading files one by one. By design it reads your + entire codebase, writes to agent config files, and spawns local helper processes + (git, grep). All indexing happens locally; the graph is stored in + local *.db files. +

+
+ +

2. Legitimacy assessment Legitimate

+ + + + + + + + +
SignalFinding
LicenseMIT, Copyright (c) 2025 DeusData. Permissive, auditable.
Repository activity862 commits, ~4 months of active history (2026-02-25 to 2026-06-12).
AuthorshipPrimary author Martin Vogel (696 commits), plus DeusData, Shane McCarron, Dependabot, and several outside contributors. Not a single-commit drop.
ProvenanceREADME advertises SLSA 3 build provenance, Sigstore signatures, SHA-256 checksums, VirusTotal scan per release, and an OpenSSF Scorecard badge.
Research backingLinked arXiv preprint describing the design and benchmarks.
TransparencySECURITY.md explicitly discloses the filesystem access patterns, invites researchers to find RCE, and states "your code never leaves your machine."
+

+ Conclusion: this is a real, actively maintained open-source project, not malware or a typosquat. + The README's "code never leaves your machine" claim is consistent with what the source actually does + (see section 3). +

+ +

3. Outbound network calls No code uploaded

+

The entire C codebase makes exactly four outbound network calls. All target GitHub, and all are + fetch/download only - none send a request body, and none transmit any indexed code, file paths, + symbols, or graph data.

+ + + + + + +
#LocationWhat it doesLeaks code?
1src/mcp/mcp.c:4369curl -sf GET to api.github.com/.../releases/latest to read the latest version tag (background update check on startup).No - GET, no body
2src/cli/cli.c:2804Downloads checksums.txt from GitHub releases (only during update).No - download
3src/cli/cli.c:3801Downloads the release binary archive from GitHub releases (only during update).No - download
4src/cli/cli.c:3874curl -sfI HEAD request to the releases page to detect a newer version.No - HEAD
+ +

Ruled out

+ + +

4. Remote code execution analysis No RCE; caveat fixed

+ +

4.1 MCP tool inputs cannot inject shell commands

+

The highest-risk path is the search_code tool, which feeds user input into a + grep invocation. The design neutralizes injection:

+ +

Net: an attacker who can supply MCP tool arguments or CLI arguments cannot achieve + arbitrary command execution through the reviewed paths.

+ +

4.2 Self-update is the one place a binary is downloaded and executed

+

The update subcommand downloads a release archive and installs it as the running + binary. This is user-initiated only - the automatic startup check merely sets a one-shot + notice string ("run: codebase-memory-mcp update"); it never downloads or + swaps anything on its own.

+

The downloaded archive's SHA-256 is compared against checksums.txt from the same + release. FIXED Previously, the caller logic + (download_verify_install) aborted only on an explicit checksum mismatch: + if verification could not be performed at all - the checksums.txt download failed, the + archive name was missing from it, or no sha256sum/shasum tool was present - + verify_download_checksum returned "could not verify" and the install proceeded anyway + with only a stderr warning.

+

The installer now fails closed: any non-zero verification result aborts the update and deletes + the downloaded archive. An explicit mismatch always aborts. The "could not verify" case also aborts + unless an operator explicitly opts in for an offline/airgapped host by setting + CBM_ALLOW_UNVERIFIED_UPDATE=1 (which prints a loud warning). Current logic in + src/cli/cli.c:

+
int crc = verify_download_checksum(tmp_archive, archive_name);
+if (crc == CLI_TRUE) {        /* mismatch -> always abort */
+    cbm_unlink(tmp_archive);
+    return CLI_TRUE;
+}
+if (crc != 0) {               /* "could not verify" -> fail closed */
+    const char *allow = cbm_safe_getenv("CBM_ALLOW_UNVERIFIED_UPDATE", ...);
+    if (!allow || strcmp(allow, "1") != 0) {
+        cbm_unlink(tmp_archive);
+        return CLI_TRUE;       /* abort: refuse to install unverified binary */
+    }
+    /* else: install with a loud "UNVERIFIED" warning (explicit opt-in) */
+}
+

Residual (recommended, defense in depth): the update path verifies only the + SHA-256 from the same origin; it does not verify the Sigstore signature or SLSA provenance the + project publishes for releases. So integrity now rests on enforced SHA-256 + HTTPS + GitHub release + integrity, but not yet on a cryptographic signature check. Verifying the signature/provenance would + close the remaining same-origin-trust gap.

+

With the fix, the only way to install a tampered binary is a checksum collision against + a same-origin checksums.txt (i.e. a compromised GitHub release or HTTPS break that + rewrites both files consistently), and it still triggers only when the user manually runs + update. The CBM_ALLOW_UNVERIFIED_UPDATE=1 opt-out is the one path that + re-enables unverified installs, by explicit operator choice.

+ +

5. Findings summary

+ +
+

Informational Proprietary code does not leave the machine

+

No exfiltration path exists. All processing is local; outbound traffic is version checks and + (on demand) binary downloads from GitHub. Matches the project's own claim.

+
+ +
+

Informational MCP / CLI inputs are not an RCE vector

+

Search patterns are passed out-of-band via grep -f; interpolated paths are validated; + process spawns avoid the shell. No command injection found in the reviewed paths.

+
+ +
+

Medium - FIXED Self-update no longer installs an unverified binary

+

Where: download_verify_install in src/cli/cli.c.

+

Previous issue: "could not verify" (missing checksums.txt, missing sha256 tool, name not + listed) was treated as a non-fatal warning, so an unverified binary was still installed and executed.

+

Fix applied: the installer now fails closed - any non-zero result from + verify_download_checksum (mismatch or "could not verify") aborts the update and + deletes the downloaded archive. An explicit checksum mismatch always aborts. For genuinely + offline/airgapped hosts, an operator can opt in to skipping verification by setting + CBM_ALLOW_UNVERIFIED_UPDATE=1, which also prints a loud warning.

+
int crc = verify_download_checksum(tmp_archive, archive_name);
+if (crc == CLI_TRUE) {        /* mismatch -> always abort */
+    cbm_unlink(tmp_archive);
+    return CLI_TRUE;
+}
+if (crc != 0) {               /* "could not verify" -> fail closed */
+    /* abort unless CBM_ALLOW_UNVERIFIED_UPDATE=1 is set */
+}
+

Still recommended (defense in depth): verify the Sigstore signature / SLSA provenance rather + than only a same-origin SHA-256, and prefer installing updates from independently verified release + artifacts (section 6).

+
+ +
+

Low Automatic update check phones GitHub on startup

+

Where: start_update_check (src/mcp/mcp.c:4417), launched at startup + (src/mcp/mcp.c:4496).

+

Issue: The server contacts api.github.com on its own. It discloses only your IP + and that you run the tool - no code - but there is no built-in opt-out, unlike the downloads which + honor the CBM_DOWNLOAD_URL env var.

+

Recommendation: in restricted-egress / air-gapped environments, block egress at the firewall + (section 6). Optionally patch out start_update_check if building from source.

+
+ +

6. How to run this worry-free against a proprietary codebase

+ +

Tier 1 - Network containment (defeats every exfiltration and update concern)

+ + +

Tier 2 - Trust the binary you run

+ + +

Tier 3 - Least privilege

+ + +
+

Bottom line: This is a legitimate tool that does not exfiltrate your code and is not + remotely exploitable through its MCP/CLI inputs. The only meaningful hardening item is the + best-effort self-update; pair "build-from-source or manually-verified binary" with + "run with egress blocked" and you can point it at proprietary code with confidence.

+
+ +
+ Generated from a manual source review of codebase-memory-mcp at commit e599df1. + This assessment covers the reviewed paths and is not a guarantee of absence of all vulnerabilities. + Re-verify after upgrading, since outbound behavior and the update logic can change between versions. +
+ +
+ + diff --git a/graph-ui/index.html b/graph-ui/index.html index 2a78c5eb0..4c2cb6ad9 100644 --- a/graph-ui/index.html +++ b/graph-ui/index.html @@ -3,10 +3,7 @@ - - - - Codebase Memory — Graph + Codebase Memory - Graph
diff --git a/graph-ui/package-lock.json b/graph-ui/package-lock.json index 3cd7a3715..a3b9380a2 100644 --- a/graph-ui/package-lock.json +++ b/graph-ui/package-lock.json @@ -22,6 +22,8 @@ "three": "~0.183.0" }, "devDependencies": { + "@fontsource/inter": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", "@tailwindcss/vite": "^4.2.1", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.1.0", @@ -956,6 +958,26 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@fontsource/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -3225,6 +3247,7 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { "version": "1.8.1", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -3235,6 +3258,7 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { "version": "1.8.1", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -3244,6 +3268,7 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.1.0", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -3253,6 +3278,7 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -3268,6 +3294,7 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { "version": "0.10.1", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -3277,6 +3304,7 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { "version": "2.8.1", + "dev": true, "inBundle": true, "license": "0BSD", "optional": true diff --git a/graph-ui/package.json b/graph-ui/package.json index 496c66f6e..e2228c88f 100644 --- a/graph-ui/package.json +++ b/graph-ui/package.json @@ -26,6 +26,8 @@ "three": "~0.183.0" }, "devDependencies": { + "@fontsource/inter": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", "@tailwindcss/vite": "^4.2.1", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.1.0", diff --git a/graph-ui/public/fonts/DejaVu-LICENSE.txt b/graph-ui/public/fonts/DejaVu-LICENSE.txt new file mode 100644 index 000000000..746d06306 --- /dev/null +++ b/graph-ui/public/fonts/DejaVu-LICENSE.txt @@ -0,0 +1,78 @@ +Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: DejaVu fonts +Upstream-Author: Stepan Roh (original author), + see /usr/share/doc/ttf-dejavu/AUTHORS for full list +Source: http://dejavu-fonts.org/ + +Files: * +Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. + Bitstream Vera is a trademark of Bitstream, Inc. + DejaVu changes are in public domain. +License: bitstream-vera + Permission is hereby granted, free of charge, to any person obtaining a copy + of the fonts accompanying this license ("Fonts") and associated + documentation files (the "Font Software"), to reproduce and distribute the + Font Software, including without limitation the rights to use, copy, merge, + publish, distribute, and/or sell copies of the Font Software, and to permit + persons to whom the Font Software is furnished to do so, subject to the + following conditions: + . + The above copyright and trademark notices and this permission notice shall + be included in all copies of one or more of the Font Software typefaces. + . + The Font Software may be modified, altered, or added to, and in particular + the designs of glyphs or characters in the Fonts may be modified and + additional glyphs or characters may be added to the Fonts, only if the fonts + are renamed to names not containing either the words "Bitstream" or the word + "Vera". + . + This License becomes null and void to the extent applicable to Fonts or Font + Software that has been modified and is distributed under the "Bitstream + Vera" names. + . + The Font Software may be sold as part of a larger software package but no + copy of one or more of the Font Software typefaces may be sold by itself. + . + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, + TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME + FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING + ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF + THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE + FONT SOFTWARE. + . + Except as contained in this notice, the names of Gnome, the Gnome + Foundation, and Bitstream Inc., shall not be used in advertising or + otherwise to promote the sale, use or other dealings in this Font Software + without prior written authorization from the Gnome Foundation or Bitstream + Inc., respectively. For further information, contact: fonts at gnome dot + org. + +Files: debian/* +Copyright: (C) 2005-2006 Peter Cernak + (C) 2006-2011 Davide Viti + (C) 2011-2013 Christian Perrier + (C) 2013 Fabian Greffrath +License: GPL-2+ + This program is free software; you can redistribute it + and/or modify it under the terms of the GNU General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later + version. + . + This program is distributed in the hope that it will be + useful, but WITHOUT ANY WARRANTY; without even the implied + warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + PURPOSE. See the GNU General Public License for more + details. + . + You should have received a copy of the GNU General Public + License along with this package; if not, write to the Free + Software Foundation, Inc., 51 Franklin St, Fifth Floor, + Boston, MA 02110-1301 USA + . + On Debian systems, the full text of the GNU General Public + License version 2 can be found in the file + /usr/share/common-licenses/GPL-2'. diff --git a/graph-ui/public/fonts/DejaVuSans.ttf b/graph-ui/public/fonts/DejaVuSans.ttf new file mode 100644 index 000000000..725d11748 Binary files /dev/null and b/graph-ui/public/fonts/DejaVuSans.ttf differ diff --git a/graph-ui/src/components/GraphTab.tsx b/graph-ui/src/components/GraphTab.tsx index ea9b7bb5b..0f3ff7d0e 100644 --- a/graph-ui/src/components/GraphTab.tsx +++ b/graph-ui/src/components/GraphTab.tsx @@ -6,7 +6,7 @@ import { computeCameraTarget, type CameraTarget, } from "./GraphScene"; -import { Sidebar } from "./Sidebar"; +import { LevelList } from "./LevelList"; import { FilterPanel } from "./FilterPanel"; import { NodeDetailPanel } from "./NodeDetailPanel"; import { ResizeHandle } from "./ResizeHandle"; @@ -29,10 +29,20 @@ interface GraphTabProps { project: string | null; } +/* A breadcrumb in the drill-down path. qn is the container's qualified_name + * (passed to /api/graph as `parent`); name is the segment shown to the user. */ +interface Crumb { + qn: string; + name: string; +} + export function GraphTab({ project }: GraphTabProps) { - const { data, loading, error, fetchOverview } = useGraphData(); + const { data, loading, error, fetchGraph } = useGraphData(); + /* Drill-down path. crumbs[0] is the repo root; the last crumb is the + * currently displayed container. */ + const [crumbs, setCrumbs] = useState([]); const [highlightedIds, setHighlightedIds] = useState | null>(null); - const [selectedPath, setSelectedPath] = useState(null); + const [, setSelectedPath] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const [cameraTarget, setCameraTarget] = useState(null); const [showLabels, setShowLabels] = useState(true); @@ -87,35 +97,36 @@ export function GraphTab({ project }: GraphTabProps) { return { nodes, edges, total_nodes: data.total_nodes, linked_projects }; }, [data, enabledLabels, enabledEdgeTypes]); + /* On project change, reset to the repo root (parent = project name). */ useEffect(() => { if (project) { - fetchOverview(project); + const base = project.split(/[./]/).pop() || project; + setCrumbs([{ qn: project, name: base }]); + fetchGraph(project, project); setHighlightedIds(null); setSelectedPath(null); + setSelectedNode(null); } - }, [project, fetchOverview]); + }, [project, fetchGraph]); + + const handleNodeClick = useCallback( + (node: GraphNode) => { + if (!filteredData || !project) return; - const handleSelectPath = useCallback( - (path: string, nodeIds: Set) => { - if (!filteredData || !path || nodeIds.size === 0) { + /* Expandable container -> drill one level deeper. */ + if (node.expandable && node.qn) { + const qn = node.qn; + setCrumbs((prev) => [...prev, { qn, name: node.name }]); + fetchGraph(project, qn); setHighlightedIds(null); setSelectedPath(null); + setSelectedNode(null); setCameraTarget(null); return; } - setSelectedPath(path); - setHighlightedIds(nodeIds); - setCameraTarget(computeCameraTarget(filteredData.nodes, nodeIds)); - }, - [filteredData], - ); - const handleNodeClick = useCallback( - (node: GraphNode) => { - if (!filteredData) return; + /* Leaf -> select + highlight its direct connections. */ setSelectedNode(node); - - /* Highlight the node and its direct connections */ const connectedIds = new Set([node.id]); for (const edge of filteredData.edges) { if (edge.source === node.id) connectedIds.add(edge.target); @@ -125,7 +136,23 @@ export function GraphTab({ project }: GraphTabProps) { setSelectedPath(node.file_path ?? null); setCameraTarget(computeCameraTarget(filteredData.nodes, connectedIds)); }, - [filteredData], + [filteredData, project, fetchGraph], + ); + + /* Jump to an ancestor in the breadcrumb path. */ + const navigateToCrumb = useCallback( + (index: number) => { + if (!project) return; + const next = crumbs.slice(0, index + 1); + const target = next[next.length - 1]; + setCrumbs(next); + fetchGraph(project, target.qn); + setHighlightedIds(null); + setSelectedPath(null); + setSelectedNode(null); + setCameraTarget(null); + }, + [project, crumbs, fetchGraph], ); const handleNavigateToNode = useCallback( @@ -197,7 +224,11 @@ export function GraphTab({ project }: GraphTabProps) {

{error}

-
@@ -242,10 +273,10 @@ export function GraphTab({ project }: GraphTabProps) { onEnableAll={enableAll} onDisableAll={disableAll} /> -
+ {/* Breadcrumb drill path */} +
+ {crumbs.map((c, i) => ( + + {i > 0 && /} + + + ))} +
+ {/* HUD */} -
+

- {filteredData.nodes.length.toLocaleString()} nodes /{" "} - {filteredData.edges.length.toLocaleString()} edges + {filteredData.nodes.length.toLocaleString()} groups /{" "} + {filteredData.edges.length.toLocaleString()} links +

+

+ click a node to drill in - size = code volume

- {data.nodes.length > filteredData.nodes.length && ( -

- filtered from {data.nodes.length.toLocaleString()} -

- )} {highlightedIds && highlightedIds.size > 0 && ( -

- {highlightedIds.size} selected -

+

{highlightedIds.size} selected

)}
@@ -311,7 +359,7 @@ export function GraphTab({ project }: GraphTabProps) { setSelectedPath(null); setSelectedNode(null); setCameraTarget(null); - fetchOverview(project); + fetchGraph(project, crumbs[crumbs.length - 1]?.qn ?? project); }} > Refresh diff --git a/graph-ui/src/components/LevelList.tsx b/graph-ui/src/components/LevelList.tsx new file mode 100644 index 000000000..f8f2ba055 --- /dev/null +++ b/graph-ui/src/components/LevelList.tsx @@ -0,0 +1,69 @@ +import { useMemo, useState } from "react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import type { GraphNode } from "../lib/types"; + +interface LevelListProps { + nodes: GraphNode[]; + onPick: (node: GraphNode) => void; + selectedId: number | null; +} + +/* Aim-free navigation for the drill-down explorer: a plain clickable list of the + * current level's container nodes. Clicking a row drills in (expandable) or + * selects (leaf) - identical to clicking the 3D node, but without having to aim + * at a small sphere. Sorted by code volume (descending). */ +export function LevelList({ nodes, onPick, selectedId }: LevelListProps) { + const [search, setSearch] = useState(""); + const rows = useMemo(() => { + const q = search.trim().toLowerCase(); + return [...nodes] + .filter((n) => !q || n.name.toLowerCase().includes(q)) + .sort((a, b) => (b.count ?? 0) - (a.count ?? 0)); + }, [nodes, search]); + + return ( +
+
+ 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" + /> +
+ +
+ {rows.length === 0 ? ( +

No matches

+ ) : ( + rows.map((n) => ( + + )) + )} +
+
+
+ ); +} diff --git a/graph-ui/src/components/NodeCloud.tsx b/graph-ui/src/components/NodeCloud.tsx index e5465b8f2..bb428344c 100644 --- a/graph-ui/src/components/NodeCloud.tsx +++ b/graph-ui/src/components/NodeCloud.tsx @@ -55,7 +55,9 @@ export function NodeCloud({ const n = nodes[i]; tempObj.position.set(n.x, n.y, n.z); const isHighlighted = !hasHighlight || highlightedIds.has(n.id); - const s = n.size * (isHighlighted ? 0.5 : 0.2); + /* Larger solid spheres so the clickable geometry roughly matches the + * bloom glow - clicking the visible halo previously missed the tiny core. */ + const s = n.size * (isHighlighted ? 0.9 : 0.35); tempObj.scale.set(s, s, s); tempObj.updateMatrix(); mesh.setMatrixAt(i, tempObj.matrix); diff --git a/graph-ui/src/components/NodeLabels.tsx b/graph-ui/src/components/NodeLabels.tsx index 5bd66bf0a..5884dabc4 100644 --- a/graph-ui/src/components/NodeLabels.tsx +++ b/graph-ui/src/components/NodeLabels.tsx @@ -37,6 +37,11 @@ export function NodeLabels({ follow > void; - fetchDetail: (project: string, centerNode: string) => void; + /* Load one hierarchy level of the drill-down explorer. `parent` is a container + * qualified_name; omit (or pass the project) for the repo root. */ + fetchGraph: (project: string, parent?: string) => void; } -async function fetchLayout( - project: string, - maxNodes = 50000, -): Promise { - const params = new URLSearchParams({ project, max_nodes: String(maxNodes) }); - const res = await fetch(`/api/layout?${params}`); +/* The drill-down explorer fetches aggregated container nodes + weighted + * super-edges from /api/graph. The backend only ever materializes the current + * level (a few hundred nodes), so this is memory-safe for any repo size - + * unlike /api/layout, which tried to lay out the whole graph and OOM'd. */ +async function fetchLevel(project: string, parent?: string): Promise { + const params = new URLSearchParams({ project }); + if (parent) params.set("parent", parent); + const res = await fetch(`/api/graph?${params}`); if (!res.ok) { const body = await res.json().catch(() => ({ error: res.statusText })); @@ -29,35 +32,18 @@ export function useGraphData(): UseGraphDataResult { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const fetchOverview = useCallback(async (project: string) => { + const fetchGraph = useCallback(async (project: string, parent?: string) => { setLoading(true); setError(null); try { - const result = await fetchLayout(project, 50000); + const result = await fetchLevel(project, parent); setData(result); } catch (e) { - setError(e instanceof Error ? e.message : "Failed to fetch layout"); + setError(e instanceof Error ? e.message : "Failed to load graph"); } finally { setLoading(false); } }, []); - const fetchDetail = useCallback( - async (project: string, _centerNode: string) => { - setLoading(true); - setError(null); - try { - /* TODO: detail level with center_node filtering */ - const result = await fetchLayout(project, 50000); - setData(result); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to fetch layout"); - } finally { - setLoading(false); - } - }, - [], - ); - - return { data, loading, error, fetchOverview, fetchDetail }; + return { data, loading, error, fetchGraph }; } diff --git a/graph-ui/src/lib/types.ts b/graph-ui/src/lib/types.ts index 0286a35b5..39a37b275 100644 --- a/graph-ui/src/lib/types.ts +++ b/graph-ui/src/lib/types.ts @@ -10,12 +10,17 @@ export interface GraphNode { file_path?: string; size: number; color: string; + /* Drill-down explorer (aggregated container nodes from /api/graph): */ + count?: number; /* number of code symbols in this subtree */ + expandable?: boolean; /* can be drilled into */ + qn?: string; /* full qualified_name; pass as the next `parent` to drill in */ } export interface GraphEdge { source: number; target: number; type: string; + weight?: number; /* aggregated super-edge weight (drill-down explorer) */ } export interface LinkedProject { @@ -31,6 +36,7 @@ export interface GraphData { edges: GraphEdge[]; total_nodes: number; linked_projects?: LinkedProject[]; + prefix?: string; /* current container QN (drill-down explorer) */ } export interface Project { diff --git a/graph-ui/src/main.tsx b/graph-ui/src/main.tsx index e95ed1bd2..20a12e310 100644 --- a/graph-ui/src/main.tsx +++ b/graph-ui/src/main.tsx @@ -1,6 +1,13 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; +/* Self-hosted fonts (bundled by vite) - replaces the Google Fonts CDN link so + * the UI makes no external requests when viewing a proprietary codebase. */ +import "@fontsource/inter/400.css"; +import "@fontsource/inter/500.css"; +import "@fontsource/inter/600.css"; +import "@fontsource/jetbrains-mono/400.css"; +import "@fontsource/jetbrains-mono/500.css"; import "./styles/globals.css"; createRoot(document.getElementById("root")!).render( diff --git a/graph-ui/tsconfig.tsbuildinfo b/graph-ui/tsconfig.tsbuildinfo index e1401cf36..618452f0c 100644 --- a/graph-ui/tsconfig.tsbuildinfo +++ b/graph-ui/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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/LevelList.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"} \ No newline at end of file diff --git a/scripts/embed-frontend.sh b/scripts/embed-frontend.sh index 908c39790..edef97013 100755 --- a/scripts/embed-frontend.sh +++ b/scripts/embed-frontend.sh @@ -38,6 +38,8 @@ content_type_for() { *.ico) echo "image/x-icon" ;; *.woff2) echo "font/woff2" ;; *.woff) echo "font/woff" ;; + *.ttf) echo "font/ttf" ;; + *.otf) echo "font/otf" ;; *.map) echo "application/json" ;; *) echo "application/octet-stream" ;; esac diff --git a/src/cli/cli.c b/src/cli/cli.c index 4228dbaad..d8edf21dc 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -3812,9 +3812,28 @@ static int download_verify_install(const char *url, const char *ext, const char want_ui ? "ui-" : "", os, arch, portable, ext); int crc = verify_download_checksum(tmp_archive, archive_name); if (crc == CLI_TRUE) { + /* Explicit checksum mismatch: the binary is tampered or corrupt. Never install. */ cbm_unlink(tmp_archive); return CLI_TRUE; } + if (crc != 0) { + /* "Could not verify" (missing checksums.txt, name absent, or no sha256 tool). + * Fail closed: do not install an unverified binary. An operator on an + * isolated/offline host may opt in explicitly via CBM_ALLOW_UNVERIFIED_UPDATE=1. */ + char allow_buf[CLI_BUF_16]; + const char *allow = + cbm_safe_getenv("CBM_ALLOW_UNVERIFIED_UPDATE", allow_buf, sizeof(allow_buf), NULL); + if (!allow || strcmp(allow, "1") != 0) { + (void)fprintf(stderr, + "error: could not verify the downloaded binary - aborting.\n" + " Verification is required by default. To override on an offline host, " + "set CBM_ALLOW_UNVERIFIED_UPDATE=1.\n"); + cbm_unlink(tmp_archive); + return CLI_TRUE; + } + (void)fprintf(stderr, "warning: installing UNVERIFIED binary " + "(CBM_ALLOW_UNVERIFIED_UPDATE=1).\n"); + } int killed = cbm_kill_other_instances(); if (killed > 0) { diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index d3aa3bf3d..7d045725e 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -4418,6 +4418,15 @@ static void start_update_check(cbm_mcp_server_t *srv) { if (srv->update_checked) { return; } + /* Opt-out: skip the GitHub version check entirely for restricted-egress or + * air-gapped environments. Set CBM_NO_UPDATE_CHECK=1 to disable. */ + char optout_buf[8]; + const char *optout = + cbm_safe_getenv("CBM_NO_UPDATE_CHECK", optout_buf, sizeof(optout_buf), NULL); + if (optout && strcmp(optout, "1") == 0) { + srv->update_checked = true; /* mark done so we never launch the thread */ + return; + } srv->update_checked = true; /* prevent double-launch */ if (cbm_thread_create(&srv->update_tid, 0, update_check_thread, srv) == 0) { srv->update_thread_active = true; diff --git a/src/store/store.c b/src/store/store.c index c237332e2..514f6a14a 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -2348,7 +2348,21 @@ static int search_where_basic(const cbm_search_params_t *params, char *where, in *wlen = where_append(where, where_sz, *wlen, nparams, bind_buf); where_bind_text(binds, bind_idx, params->project); } - if (params->label) { + if (params->include_labels && params->include_labels[0]) { + /* n.label IN (?,?,...) — one bind per whitelisted label. */ + char in_buf[CBM_SZ_256]; + int off = snprintf(in_buf, sizeof(in_buf), "n.label IN ("); + int base = *bind_idx; + int k = 0; + for (; params->include_labels[k] && k < ST_SEARCH_MAX_BINDS; k++) { + off += snprintf(in_buf + off, (size_t)(sizeof(in_buf) - off), "%s?%d", k ? "," : "", + base + SKIP_ONE + k); + } + snprintf(in_buf + off, (size_t)(sizeof(in_buf) - off), ")"); + *wlen = where_append(where, where_sz, *wlen, nparams, in_buf); + for (int i = 0; i < k; i++) + where_bind_text(binds, bind_idx, params->include_labels[i]); + } else if (params->label) { snprintf(bind_buf, sizeof(bind_buf), "n.label = ?%d", *bind_idx + SKIP_ONE); *wlen = where_append(where, where_sz, *wlen, nparams, bind_buf); where_bind_text(binds, bind_idx, params->label); @@ -2560,6 +2574,198 @@ void cbm_store_search_free(cbm_search_output_t *out) { memset(out, 0, sizeof(*out)); } +/* ── Hierarchy expansion (semantic-zoom drill-down) ─────────────────── */ + +void cbm_tree_view_free(cbm_tree_view_t *v) { + if (!v) { + return; + } + for (int i = 0; i < v->child_count; i++) { + free(v->children[i].name); + free(v->children[i].full_qn); + free(v->children[i].kind); + } + free(v->children); + free(v->edges); + memset(v, 0, sizeof(*v)); +} + +/* SQL fragment: the QN segment of column `col` immediately after the bound + * prefix (param ?2 = length of "prefix."). Reused in the child-grouping and + * edge-aggregation queries. SUBSTR(col, ?2 + 1) is the remainder after the + * prefix; the first dotted token of that remainder is the child segment. */ +#define CBM_TREE_SEG(col) \ + "CASE WHEN INSTR(SUBSTR(" col ", ?2 + 1), '.') > 0 " \ + " THEN SUBSTR(SUBSTR(" col ", ?2 + 1), 1, INSTR(SUBSTR(" col ", ?2 + 1), '.') - 1) " \ + " ELSE SUBSTR(" col ", ?2 + 1) END" + +static int tree_child_index(const cbm_tree_view_t *v, const char *name) { + for (int i = 0; i < v->child_count; i++) { + if (strcmp(v->children[i].name, name) == 0) { + return i; + } + } + return CBM_NOT_FOUND; +} + +int cbm_store_expand_tree(cbm_store_t *s, const char *project, const char *prefix, int child_limit, + int edge_limit, cbm_tree_view_t *out) { + if (!s || !s->db || !project || !prefix || !out) { + return CBM_STORE_ERR; + } + memset(out, 0, sizeof(*out)); + if (child_limit <= 0) { + child_limit = 500; + } + if (edge_limit <= 0) { + edge_limit = 2000; + } + + size_t plen = strlen(prefix); + /* Range bounds so the (project, qualified_name) index can prefix-scan: + * every child QN is "prefix" + "." + ..., and '.' (0x2E) < '/' (0x2F), so + * [prefix+".", prefix+"/") is exactly the set of descendants. This replaces a + * non-sargable SUBSTR(...) = ? predicate that forced a full table scan. */ + char *lo = malloc(plen + 2); + char *hi = malloc(plen + 2); + if (!lo || !hi) { + free(lo); + free(hi); + return CBM_STORE_ERR; + } + memcpy(lo, prefix, plen); + lo[plen] = '.'; + lo[plen + 1] = '\0'; + memcpy(hi, prefix, plen); + hi[plen] = '/'; + hi[plen + 1] = '\0'; + int substr_len = (int)plen + 1; /* offset of the segment after "prefix." */ + + /* 1. Children: group nodes one level below `prefix` by their next QN segment. */ + const char *child_sql = "SELECT " CBM_TREE_SEG("qualified_name") " AS child, COUNT(*) AS cnt, " + "MAX(CASE WHEN INSTR(SUBSTR(qualified_name, ?2 + 1), '.') > 0 " + " THEN 1 ELSE 0 END) AS expandable, " + "MAX(CASE WHEN INSTR(SUBSTR(qualified_name, ?2 + 1), '.') = 0 " + " THEN label END) AS kind " + "FROM nodes WHERE project = ?1 " + "AND qualified_name >= ?3 AND qualified_name < ?4 " + "GROUP BY child ORDER BY cnt DESC LIMIT ?5"; + + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(s->db, child_sql, CBM_NOT_FOUND, &st, NULL) != SQLITE_OK) { + free(lo); + free(hi); + return CBM_STORE_ERR; + } + sqlite3_bind_text(st, 1, project, CBM_NOT_FOUND, SQLITE_STATIC); + sqlite3_bind_int(st, 2, substr_len); + sqlite3_bind_text(st, 3, lo, CBM_NOT_FOUND, SQLITE_STATIC); + sqlite3_bind_text(st, 4, hi, CBM_NOT_FOUND, SQLITE_STATIC); + sqlite3_bind_int(st, 5, child_limit); + + int cap = 16, n = 0; + cbm_tree_child_t *children = malloc((size_t)cap * sizeof(*children)); + if (!children) { + sqlite3_finalize(st); + free(lo); + free(hi); + return CBM_STORE_ERR; + } + while (sqlite3_step(st) == SQLITE_ROW) { + const char *child = (const char *)sqlite3_column_text(st, 0); + if (!child || child[0] == '\0') { + continue; + } + if (n >= cap) { + cap *= 2; + cbm_tree_child_t *tmp = realloc(children, (size_t)cap * sizeof(*children)); + if (!tmp) { + break; + } + children = tmp; + } + const char *kind = (const char *)sqlite3_column_text(st, 3); + size_t clen = strlen(child); + char *fq = malloc(plen + 1 + clen + 1); + if (fq) { + memcpy(fq, prefix, plen); + fq[plen] = '.'; + memcpy(fq + plen + 1, child, clen + 1); + } + children[n].name = heap_strdup(child); + children[n].full_qn = fq; + children[n].kind = heap_strdup(kind ? kind : "Group"); + children[n].count = sqlite3_column_int(st, 1); + children[n].expandable = sqlite3_column_int(st, 2); + n++; + } + sqlite3_finalize(st); + out->children = children; + out->child_count = n; + if (n == 0) { + free(lo); + free(hi); + return CBM_STORE_OK; + } + + /* 2. Edges: aggregate cross-child relationship edges into weighted super-edges. + * Drive from the (project, qualified_name) index range on each endpoint so the + * planner restricts to the current subtree before the COUNT/GROUP, instead of + * scanning every typed edge in the project. */ + const char *edge_sql = + "SELECT " CBM_TREE_SEG("ns.qualified_name") " AS src, " CBM_TREE_SEG( + "nt.qualified_name") " AS tgt, COUNT(*) AS w " + "FROM edges e JOIN nodes ns ON ns.id = e.source_id JOIN nodes nt ON nt.id = e.target_id " + "WHERE e.project = ?1 AND e.type IN " + "('CALLS','IMPORTS','INHERITS','USAGE','WRITES','CONFIGURES','HTTP_CALLS','THROWS') " + "AND ns.qualified_name >= ?3 AND ns.qualified_name < ?4 " + "AND nt.qualified_name >= ?3 AND nt.qualified_name < ?4 " + "AND " CBM_TREE_SEG("ns.qualified_name") " <> " CBM_TREE_SEG("nt.qualified_name") " " + "GROUP BY src, tgt ORDER BY w DESC LIMIT ?5"; + + if (sqlite3_prepare_v2(s->db, edge_sql, CBM_NOT_FOUND, &st, NULL) != SQLITE_OK) { + free(lo); + free(hi); + return CBM_STORE_OK; /* children are still valid */ + } + sqlite3_bind_text(st, 1, project, CBM_NOT_FOUND, SQLITE_STATIC); + sqlite3_bind_int(st, 2, substr_len); + sqlite3_bind_text(st, 3, lo, CBM_NOT_FOUND, SQLITE_STATIC); + sqlite3_bind_text(st, 4, hi, CBM_NOT_FOUND, SQLITE_STATIC); + sqlite3_bind_int(st, 5, edge_limit); + + int ecap = 32, en = 0; + cbm_tree_edge_t *edges = malloc((size_t)ecap * sizeof(*edges)); + while (edges && sqlite3_step(st) == SQLITE_ROW) { + const char *sc = (const char *)sqlite3_column_text(st, 0); + const char *tc = (const char *)sqlite3_column_text(st, 1); + int si = sc ? tree_child_index(out, sc) : CBM_NOT_FOUND; + int di = tc ? tree_child_index(out, tc) : CBM_NOT_FOUND; + if (si < 0 || di < 0) { + continue; /* endpoint truncated by child_limit */ + } + if (en >= ecap) { + ecap *= 2; + cbm_tree_edge_t *tmp = realloc(edges, (size_t)ecap * sizeof(*edges)); + if (!tmp) { + break; + } + edges = tmp; + } + edges[en].src = si; + edges[en].dst = di; + edges[en].weight = sqlite3_column_int(st, 2); + en++; + } + sqlite3_finalize(st); + out->edges = edges; + out->edge_count = en; + + free(lo); + free(hi); + return CBM_STORE_OK; +} + /* ── BFS Traversal ──────────────────────────────────────────────── */ static int bfs_collect_edges(cbm_store_t *s, int64_t start_id, const cbm_node_hop_t *visited, diff --git a/src/store/store.h b/src/store/store.h index 26b09a5c2..5749c84c1 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -105,6 +105,7 @@ int cbm_store_restore_from(cbm_store_t *dst, cbm_store_t *src); typedef struct { const char *project; const char *label; /* NULL = any label */ + const char **include_labels; /* NULL-terminated whitelist (label IN (...)); overrides `label` */ const char *name_pattern; /* regex on name, NULL = any */ const char *qn_pattern; /* regex on qualified_name, NULL = any */ const char *file_pattern; /* glob on file_path, NULL = any */ @@ -380,6 +381,41 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear /* Free a search output's allocated memory. */ void cbm_store_search_free(cbm_search_output_t *out); +/* ── Hierarchy expansion (semantic-zoom drill-down) ───────────────── + * Groups nodes one level below a qualified_name prefix by their next QN + * segment, and aggregates cross-group relationship edges into weighted + * super-edges. Lets the UI represent a 300k-node graph as a navigable tree + * of container super-nodes without ever materializing the whole graph. */ +typedef struct { + char *name; /* child segment (the next QN token under the prefix) */ + char *full_qn; /* prefix + "." + name (pass back as the next prefix to drill in) */ + char *kind; /* label of the container/leaf node for this segment, or "Group" */ + int count; /* number of nodes in this subtree */ + int expandable; /* 1 if the subtree has deeper nodes (can drill in) */ +} cbm_tree_child_t; + +typedef struct { + int src; /* index into children[] */ + int dst; /* index into children[] */ + int weight; /* number of underlying relationship edges crossing the boundary */ +} cbm_tree_edge_t; + +typedef struct { + cbm_tree_child_t *children; + int child_count; + cbm_tree_edge_t *edges; + int edge_count; +} cbm_tree_view_t; + +/* Expand one hierarchy level under `prefix` (a node qualified_name, e.g. the + * project name for the root). Caps children at child_limit and edges at + * edge_limit. Returns CBM_STORE_OK and fills `out` (free with cbm_tree_view_free). */ +int cbm_store_expand_tree(cbm_store_t *s, const char *project, const char *prefix, int child_limit, + int edge_limit, cbm_tree_view_t *out); + +/* Free a tree view's allocated memory. */ +void cbm_tree_view_free(cbm_tree_view_t *v); + /* ── Traversal ──────────────────────────────────────────────────── */ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const char **edge_types, diff --git a/src/ui/http_server.c b/src/ui/http_server.c index 568b47cc0..fe64caf18 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -105,6 +105,25 @@ typedef struct { static index_job_t g_index_jobs[MAX_INDEX_JOBS]; +/* Content-Security-Policy for the UI document. The loopback server and its + * embedded assets are entirely self-contained, so every fetch directive is + * pinned to 'self'. This makes the browser refuse any external request - + * fonts, scripts, XHR/fetch/WebSocket, images - so viewing a proprietary + * codebase in the graph UI cannot leak data to any third-party host. + * script/style allow 'unsafe-inline'/'unsafe-eval' and worker allows blob: + * because the bundled React + three.js/troika stack needs them; none of those + * widen network egress, which is governed by connect-src/font-src/img-src. */ +/* Note on blob: in script-src - three.js/troika build Web Workers from blob: + * URLs and those workers call importScripts(blob:...), which is governed by + * script-src (script-src-elem falls back to it), not worker-src. blob: is a + * local, same-origin scheme and does NOT permit any network egress; egress is + * still fully pinned by connect-src/font-src/img-src 'self'. */ +#define CBM_UI_CSP \ + "Content-Security-Policy: default-src 'self'; connect-src 'self'; font-src 'self'; " \ + "img-src 'self' data: blob:; media-src 'self' data: blob:; " \ + "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; " \ + "worker-src 'self' blob:; object-src 'none'; base-uri 'self'\r\n" + /* ── Serve embedded asset ─────────────────────────────────────── */ static bool serve_embedded(cbm_http_conn_t *c, const char *path) { @@ -976,6 +995,14 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { max_nodes = v; } + /* Optional label filter. Empty string ("label=") explicitly means "all labels"; + * an absent param keeps the default. */ + char label[64] = {0}; + const char *label_filter = NULL; + if (cbm_http_query_param(req->query, "label", label, (int)sizeof(label))) { + label_filter = label[0] != '\0' ? label : NULL; + } + char db_path[1024]; db_path_for_project(project, db_path, sizeof(db_path)); @@ -991,7 +1018,7 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { } cbm_layout_result_t *layout = - cbm_layout_compute(store, project, CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes); + cbm_layout_compute(store, project, CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes, label_filter); /* Find linked projects from CROSS_* edges. Keep `store` open through the * linked-projects loop below so we can resolve target Route QNs against @@ -1055,7 +1082,8 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { /* Keep lp_store open through cross_edges resolution below. */ cbm_layout_result_t *lp_layout = - cbm_layout_compute(lp_store, linked[li], CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes); + cbm_layout_compute(lp_store, linked[li], CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes, + label_filter); if (!lp_layout) { cbm_store_close(lp_store); @@ -1190,6 +1218,186 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { } } +/* Color for an aggregated container super-node, keyed by its dominant node label. */ +static uint32_t tree_kind_color(const char *kind) { + if (!kind) + return 0x8888aa; + if (strcmp(kind, "Folder") == 0) + return 0xf2c14e; + if (strcmp(kind, "File") == 0) + return 0x4ea8de; + if (strcmp(kind, "Class") == 0) + return 0x9b5de5; + if (strcmp(kind, "Interface") == 0) + return 0xc77dff; + if (strcmp(kind, "Method") == 0) + return 0x00bbf9; + if (strcmp(kind, "Function") == 0) + return 0x00f5d4; + if (strcmp(kind, "Module") == 0) + return 0xff8fab; + if (strcmp(kind, "Variable") == 0) + return 0xfee440; + if (strcmp(kind, "Route") == 0) + return 0xff5d8f; + if (strcmp(kind, "Enum") == 0 || strcmp(kind, "Type") == 0) + return 0x90be6d; + return 0x8888aa; /* Group / unknown */ +} + +/* Memo cache for /api/graph. The aggregation over a large subtree scans many + * edges (~seconds at the root), but the index is static while the server runs, + * so each (project,parent) view is computed once and reused. The HTTP server is + * single-threaded per request (see httpd.c), so no locking is needed. */ +#define GRAPH_CACHE_MAX 64 +static struct { + char *key; + char *json; +} g_graph_cache[GRAPH_CACHE_MAX]; +static int g_graph_cache_n = 0; +static int g_graph_cache_next = 0; /* FIFO eviction cursor when full */ + +static const char *graph_cache_get(const char *key) { + for (int i = 0; i < g_graph_cache_n; i++) { + if (g_graph_cache[i].key && strcmp(g_graph_cache[i].key, key) == 0) { + return g_graph_cache[i].json; + } + } + return NULL; +} + +static void graph_cache_put(const char *key, const char *json) { + char *kd = strdup(key); + char *jd = strdup(json); + if (!kd || !jd) { + free(kd); + free(jd); + return; + } + int slot; + if (g_graph_cache_n < GRAPH_CACHE_MAX) { + slot = g_graph_cache_n++; + } else { + slot = g_graph_cache_next; + g_graph_cache_next = (g_graph_cache_next + 1) % GRAPH_CACHE_MAX; + free(g_graph_cache[slot].key); + free(g_graph_cache[slot].json); + } + g_graph_cache[slot].key = kd; + g_graph_cache[slot].json = jd; +} + +/* GET /api/graph?project=X&parent= — one hierarchy level for the drill-down + * explorer: aggregated container children + weighted cross-container super-edges. + * `parent` defaults to the project root. Memory-safe for any repo size: only the + * (capped) children + super-edges are materialized, never the full graph. */ +static void handle_graph(cbm_http_conn_t *c, const cbm_http_req_t *req) { + char project[256] = {0}; + char parent[1024] = {0}; + if (!cbm_http_query_param(req->query, "project", project, (int)sizeof(project)) || + project[0] == '\0') { + cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"missing project parameter\"}"); + return; + } + /* Root = the project node, whose qualified_name equals the project name. */ + if (!cbm_http_query_param(req->query, "parent", parent, (int)sizeof(parent)) || + parent[0] == '\0') { + snprintf(parent, sizeof(parent), "%s", project); + } + + /* Cache key = project + '\n' + parent. */ + char cache_key[1300]; + snprintf(cache_key, sizeof(cache_key), "%s\n%s", project, parent); + const char *cached = graph_cache_get(cache_key); + if (cached) { + cbm_http_replyf(c, 200, g_cors_json, "%s", cached); + return; + } + + char db_path[1024]; + db_path_for_project(project, db_path, sizeof(db_path)); + if (!cbm_file_exists(db_path)) { + cbm_http_replyf(c, 404, g_cors_json, "{\"error\":\"project not found\"}"); + return; + } + cbm_store_t *store = cbm_store_open_path(db_path); + if (!store) { + cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"cannot open store\"}"); + return; + } + + cbm_tree_view_t view; + if (cbm_store_expand_tree(store, project, parent, 500, 2000, &view) != CBM_STORE_OK) { + cbm_store_close(store); + cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"expand failed\"}"); + return; + } + + int n = view.child_count; + /* Spread nodes wider so they overlap less and present larger click targets. */ + double radius = 90.0 + sqrt((double)(n > 0 ? n : 1)) * 26.0; + double golden = M_PI * (3.0 - sqrt(5.0)); + + 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 *na = yyjson_mut_arr(doc); + for (int i = 0; i < n; i++) { + cbm_tree_child_t *ch = &view.children[i]; + yyjson_mut_val *nd = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, nd, "id", i); + /* Fibonacci sphere placement so the overview reads as a ball of nodes. */ + double yy = n > 1 ? 1.0 - (i / (double)(n - 1)) * 2.0 : 0.0; + double rr = sqrt(1.0 - yy * yy); + double th = golden * i; + yyjson_mut_obj_add_real(doc, nd, "x", cos(th) * rr * radius); + yyjson_mut_obj_add_real(doc, nd, "y", yy * radius); + yyjson_mut_obj_add_real(doc, nd, "z", sin(th) * rr * radius); + yyjson_mut_obj_add_str(doc, nd, "label", ch->kind ? ch->kind : "Group"); + yyjson_mut_obj_add_str(doc, nd, "name", ch->name ? ch->name : ""); + double sz = 4.0 + log((double)(ch->count > 0 ? ch->count : 1) + 1.0) * 2.6; + yyjson_mut_obj_add_real(doc, nd, "size", sz); + char hex[16]; + snprintf(hex, sizeof(hex), "#%06x", tree_kind_color(ch->kind)); + yyjson_mut_obj_add_strcpy(doc, nd, "color", hex); + yyjson_mut_obj_add_int(doc, nd, "count", ch->count); + yyjson_mut_obj_add_bool(doc, nd, "expandable", ch->expandable != 0); + if (ch->full_qn) { + yyjson_mut_obj_add_strcpy(doc, nd, "qn", ch->full_qn); + } + yyjson_mut_arr_append(na, nd); + } + yyjson_mut_obj_add_val(doc, root, "nodes", na); + + yyjson_mut_val *ea = yyjson_mut_arr(doc); + for (int i = 0; i < view.edge_count; i++) { + yyjson_mut_val *ed = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, ed, "source", view.edges[i].src); + yyjson_mut_obj_add_int(doc, ed, "target", view.edges[i].dst); + yyjson_mut_obj_add_str(doc, ed, "type", "AGG"); + yyjson_mut_obj_add_int(doc, ed, "weight", view.edges[i].weight); + yyjson_mut_arr_append(ea, ed); + } + yyjson_mut_obj_add_val(doc, root, "edges", ea); + yyjson_mut_obj_add_int(doc, root, "total_nodes", n); + yyjson_mut_obj_add_strcpy(doc, root, "prefix", parent); + + size_t len = 0; + char *json = yyjson_mut_write_opts(doc, YYJSON_WRITE_ALLOW_INVALID_UNICODE, NULL, &len, NULL); + yyjson_mut_doc_free(doc); + cbm_tree_view_free(&view); + cbm_store_close(store); + + if (json) { + graph_cache_put(cache_key, json); + cbm_http_replyf(c, 200, g_cors_json, "%s", json); + free(json); + } else { + cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"JSON write failed\"}"); + } +} + /* ── Handle JSON-RPC request ──────────────────────────────────── */ static void handle_rpc(cbm_http_conn_t *c, const cbm_http_req_t *req, cbm_mcp_server_t *mcp) { @@ -1240,6 +1448,11 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c, return; } + if (is_get && cbm_http_path_match(req->path, "/api/graph*")) { + handle_graph(c, req); + return; + } + /* POST /api/index → start background indexing */ if (is_post && cbm_http_path_match(req->path, "/api/index")) { handle_index_start(c, req); @@ -1304,9 +1517,9 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c, if (cbm_http_path_match(req->path, "/")) { const cbm_embedded_file_t *f = cbm_embedded_lookup("/index.html"); if (f) { - char html_hdrs[512]; + char html_hdrs[1024]; snprintf(html_hdrs, sizeof(html_hdrs), - "%sContent-Type: text/html\r\nCache-Control: no-cache\r\n", g_cors); + "%sContent-Type: text/html\r\nCache-Control: no-cache\r\n" CBM_UI_CSP, g_cors); cbm_http_reply_buf(c, 200, html_hdrs, f->data, (size_t)f->size); return; } diff --git a/src/ui/layout3d.c b/src/ui/layout3d.c index 5758a3334..e39f395a0 100644 --- a/src/ui/layout3d.c +++ b/src/ui/layout3d.c @@ -384,7 +384,7 @@ static int find_node_index(const node_id_entry_t *map, int count, int64_t id) { cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, cbm_layout_level_t level, const char *center_node, - int radius, int max_nodes) { + int radius, int max_nodes, const char *label) { if (!store || !project) return NULL; if (max_nodes <= 0) @@ -400,11 +400,75 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, params.limit = max_nodes; params.min_degree = -1; params.max_degree = -1; + /* Optional label filter. Accepts a comma-separated whitelist (e.g. + * "Class,Method,Variable"); NULL/empty means all labels. */ + char label_buf[CBM_SZ_256]; + const char *label_ptrs[16]; + int label_n = 0; + if (label && label[0] != '\0') { + snprintf(label_buf, sizeof(label_buf), "%s", label); + char *save = NULL; + for (char *tok = strtok_r(label_buf, ",", &save); + tok && label_n < (int)(sizeof(label_ptrs) / sizeof(label_ptrs[0])) - 1; + tok = strtok_r(NULL, ",", &save)) { + while (*tok == ' ') + tok++; + if (*tok != '\0') + label_ptrs[label_n++] = tok; + } + } + label_ptrs[label_n] = NULL; + params.include_labels = label_n > 0 ? label_ptrs : NULL; cbm_search_output_t search_out; memset(&search_out, 0, sizeof(search_out)); - if (cbm_store_search(store, ¶ms, &search_out) != CBM_STORE_OK) + if (label_n > 1) { + /* Multiple labels: give each its own quota so every requested type is + * represented. A flat "ORDER BY name LIMIT" lets one label (e.g. Variable) + * crowd out the rest. Run one search per label, then merge - transferring + * string ownership to the combined output (free only each sub's container + * array; cbm_store_search_free(&search_out) later frees the strings once). */ + int per = max_nodes / label_n; + if (per < 1) + per = 1; + cbm_search_output_t subs[16]; + int sub_count = 0; + int merged_cap = 0, merged_total = 0; + for (int li = 0; li < label_n; li++) { + const char *one[2] = {label_ptrs[li], NULL}; + cbm_search_params_t p = params; + p.include_labels = one; + p.limit = per; + memset(&subs[sub_count], 0, sizeof(subs[0])); + if (cbm_store_search(store, &p, &subs[sub_count]) == CBM_STORE_OK) { + merged_cap += subs[sub_count].count; + merged_total += subs[sub_count].total; + sub_count++; + } + } + int merged_ok = 0; + if (merged_cap > 0) { + search_out.results = malloc((size_t)merged_cap * sizeof(cbm_search_result_t)); + if (search_out.results) { + int idx = 0; + for (int s = 0; s < sub_count; s++) + for (int i = 0; i < subs[s].count; i++) + search_out.results[idx++] = subs[s].results[i]; + search_out.count = merged_cap; + search_out.total = merged_total; + for (int s = 0; s < sub_count; s++) + free(subs[s].results); /* container only; strings moved to search_out */ + merged_ok = 1; + } + } + if (!merged_ok) { + for (int s = 0; s < sub_count; s++) + cbm_store_search_free(&subs[s]); + return calloc(CBM_ALLOC_ONE, sizeof(cbm_layout_result_t)); + } + } else if (cbm_store_search(store, ¶ms, &search_out) != CBM_STORE_OK) { return calloc(CBM_ALLOC_ONE, sizeof(cbm_layout_result_t)); + } int n = search_out.count, total_count = search_out.total; if (n == 0) { diff --git a/src/ui/layout3d.h b/src/ui/layout3d.h index a939d6996..03fc95e5b 100644 --- a/src/ui/layout3d.h +++ b/src/ui/layout3d.h @@ -54,10 +54,11 @@ typedef enum { /* Compute layout for a project. * center_node: QN of center (for detail level), NULL for overview * radius: hop distance from center (for detail level) - * max_nodes: cap on returned nodes */ + * max_nodes: cap on returned nodes + * label: restrict to a single node label (e.g. "Class"), or NULL for all labels */ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, cbm_layout_level_t level, const char *center_node, - int radius, int max_nodes); + int radius, int max_nodes, const char *label); /* Free a layout result. */ void cbm_layout_free(cbm_layout_result_t *result); diff --git a/tests/test_ui.c b/tests/test_ui.c index 002e8d39a..0c614b660 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -203,7 +203,7 @@ TEST(layout_empty_graph) { /* No nodes in store → empty result */ cbm_layout_result_t *r = - cbm_layout_compute(store, "test-project", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); + cbm_layout_compute(store, "test-project", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL); ASSERT_NOT_NULL(r); ASSERT_EQ(r->node_count, 0); ASSERT_EQ(r->edge_count, 0); @@ -230,7 +230,7 @@ TEST(layout_single_node) { int64_t id = cbm_store_upsert_node(store, &node); ASSERT_GT(id, 0); - cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); + cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL); ASSERT_NOT_NULL(r); ASSERT_EQ(r->node_count, 1); ASSERT_STR_EQ(r->nodes[0].name, "main"); @@ -267,7 +267,7 @@ TEST(layout_two_connected) { cbm_edge_t edge = {.project = "test", .source_id = id1, .target_id = id2, .type = "CALLS"}; cbm_store_insert_edge(store, &edge); - cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); + cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL); ASSERT_NOT_NULL(r); ASSERT_EQ(r->node_count, 2); @@ -307,7 +307,7 @@ TEST(layout_respects_max_nodes) { } /* max_nodes=5 should return at most 5 */ - cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 5); + cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 5, NULL); ASSERT_NOT_NULL(r); ASSERT_LTE(r->node_count, 5); ASSERT_EQ(r->total_nodes, 20); @@ -341,8 +341,8 @@ TEST(layout_deterministic) { cbm_store_upsert_node(store, &n2); /* Run twice, check positions match */ - cbm_layout_result_t *r1 = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); - cbm_layout_result_t *r2 = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); + cbm_layout_result_t *r1 = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL); + cbm_layout_result_t *r2 = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL); ASSERT_NOT_NULL(r1); ASSERT_NOT_NULL(r2); ASSERT_EQ(r1->node_count, r2->node_count); @@ -374,7 +374,7 @@ TEST(layout_to_json) { .end_line = 5}; cbm_store_upsert_node(store, &n); - cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); + cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL); ASSERT_NOT_NULL(r); char *json = cbm_layout_to_json(r); @@ -395,12 +395,12 @@ TEST(layout_to_json) { TEST(layout_null_inputs) { /* NULL store → NULL result */ - cbm_layout_result_t *r = cbm_layout_compute(NULL, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); + cbm_layout_result_t *r = cbm_layout_compute(NULL, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL); ASSERT_NULL(r); /* NULL project → NULL result */ cbm_store_t *store = cbm_store_open_memory(); - r = cbm_layout_compute(store, NULL, CBM_LAYOUT_OVERVIEW, NULL, 0, 100); + r = cbm_layout_compute(store, NULL, CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL); ASSERT_NULL(r); /* cbm_layout_free(NULL) should not crash */ @@ -414,6 +414,73 @@ TEST(layout_null_inputs) { PASS(); } +/* ── Hierarchy expansion (drill-down explorer) ────────────────── */ + +TEST(expand_tree_groups_and_aggregates) { + cbm_store_t *store = cbm_store_open_memory(); + ASSERT_NOT_NULL(store); + cbm_store_upsert_project(store, "test", "/tmp/test"); + + /* Hierarchy under "test": two folders with children + a leaf module. + * QNs use '.' separators, matching the real indexer's scheme. */ + cbm_node_t nodes[] = { + {.project = "test", .label = "Folder", .name = "alpha", .qualified_name = "test.alpha"}, + {.project = "test", .label = "Method", .name = "x", .qualified_name = "test.alpha.x"}, + {.project = "test", .label = "Method", .name = "y", .qualified_name = "test.alpha.y"}, + {.project = "test", .label = "Folder", .name = "beta", .qualified_name = "test.beta"}, + {.project = "test", .label = "Method", .name = "z", .qualified_name = "test.beta.z"}, + {.project = "test", .label = "Module", .name = "gamma", .qualified_name = "test.gamma"}, + }; + int64_t ids[6]; + for (int i = 0; i < 6; i++) { + ids[i] = cbm_store_upsert_node(store, &nodes[i]); + } + /* Cross-folder call alpha.x -> beta.z should aggregate to a super-edge. */ + cbm_edge_t e = { + .project = "test", .source_id = ids[1], .target_id = ids[4], .type = "CALLS"}; + cbm_store_insert_edge(store, &e); + + cbm_tree_view_t v; + ASSERT_EQ(cbm_store_expand_tree(store, "test", "test", 500, 2000, &v), CBM_STORE_OK); + + /* Three top-level groups: alpha (3 nodes), beta (2), gamma (1, leaf). */ + ASSERT_EQ(v.child_count, 3); + int ai = -1, bi = -1, gi = -1; + for (int i = 0; i < v.child_count; i++) { + if (strcmp(v.children[i].name, "alpha") == 0) + ai = i; + else if (strcmp(v.children[i].name, "beta") == 0) + bi = i; + else if (strcmp(v.children[i].name, "gamma") == 0) + gi = i; + } + ASSERT_GTE(ai, 0); + ASSERT_GTE(bi, 0); + ASSERT_GTE(gi, 0); + ASSERT_EQ(v.children[ai].count, 3); + ASSERT_TRUE(v.children[ai].expandable); + ASSERT_EQ(v.children[bi].count, 2); + ASSERT_EQ(v.children[gi].count, 1); + ASSERT_FALSE(v.children[gi].expandable); /* single leaf, nothing to drill into */ + + /* Exactly one aggregated super-edge: alpha -> beta, weight 1. */ + ASSERT_EQ(v.edge_count, 1); + ASSERT_EQ(v.edges[0].src, ai); + ASSERT_EQ(v.edges[0].dst, bi); + ASSERT_EQ(v.edges[0].weight, 1); + + cbm_tree_view_free(&v); + cbm_store_close(store); + PASS(); +} + +TEST(expand_tree_null_inputs) { + cbm_tree_view_t v; + ASSERT_EQ(cbm_store_expand_tree(NULL, "test", "test", 0, 0, &v), CBM_STORE_ERR); + cbm_tree_view_free(NULL); /* must not crash */ + PASS(); +} + /* ── Suite ────────────────────────────────────────────────────── */ SUITE(ui) { @@ -436,4 +503,8 @@ SUITE(ui) { RUN_TEST(layout_deterministic); RUN_TEST(layout_to_json); RUN_TEST(layout_null_inputs); + + /* Hierarchy expansion */ + RUN_TEST(expand_tree_groups_and_aggregates); + RUN_TEST(expand_tree_null_inputs); }