Skip to content

Commit 4d4d083

Browse files
committed
Merge main PR #19 into sync/upstream-step5 preserving upstream behavior
Complete the existing true merge of b770042 into b632a9c. Retain native Graph UI read endpoints, Codex legacy-block warnings, busy-stop coverage, operation argument allocation tracking, tree defaults, and output budgets. Resolve every conflicted path: - scripts/memory-core-baseline.txt: regenerate at 3318 raw sites; enforce the smaller per-file ceiling from both parents, with no increases. - src/foundation/mem_core.h: retain one OPERATION_ARG class alongside all upstream allocation classes and declarations. - src/operations/adr.c: retain ADR behavior and release parsed arguments through their OPERATION_ARG class on every exit path. - src/operations/changes.c: retain full-width argument parsing and Git default-base discovery; preserve class-owned base/direction cleanup. - src/operations/index.c: retain deepest root-owner admission, ambiguity refusal and session scoping; use JSON for internal project discovery and pair every parsed string with its class-aware release. - src/operations/json_args.c: retain signed/unsigned integer bounds and class-owned string extraction; define the free helper exactly once. - src/operations/mutation.c: retain mutation guards and class-owned project cleanup on every path. - src/operations/project_arg.c: retain path-owner and tail resolution; normalize replacement strings into argument ownership before releasing originals, preserving originals if allocation fails. - src/operations/search.c: retain callable identity and truthful combined filter hints inside the tree/JSON budget loop; remove obsolete duplicate emission, preserve metadata failure handling and class cleanup. - src/operations/source_search.c: retain bounded context and explicit regex-size/path-filter refusals with cleanup, and class-own the whitespace regex replacement buffer. Fix the automatically merged tree renderer to pass callable names to prefix_length. Extend the existing disk-backed callable regression to exercise grouped tree identities. Remove duplicate headers and free-helper prototypes introduced by the merge. Keep all tests from both parents, including platform-conditional bodies and registrations. No vendored sources changed. Validated final production and sanitizer builds, CI lint, the independent min-parent allocator ceiling check, pinned vendored checksums, all 14 requested C suites (1037 passed, no failures, one Windows-only skip), real CLI TOON/JSON budget smoke, and 48 frontend tests. Eleven real outer JSON cases match the pre-merge binary byte-for-byte; outline matches its legacy explicit JSON encoding. The test executable remained untouched throughout the authoritative suite run.
2 parents b632a9c + b770042 commit 4d4d083

39 files changed

Lines changed: 1673 additions & 484 deletions

‎graph-ui/src/api/native.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/* Same-origin, read-only graph API. Responses are operation JSON payloads. */
2+
export async function fetchRead<T>(
3+
endpoint: "projects" | "schema" | "snippet",
4+
args: Record<string, string | number | boolean> = {},
5+
): Promise<T> {
6+
const query = new URLSearchParams();
7+
for (const [key, value] of Object.entries(args)) query.set(key, String(value));
8+
const res = await fetch(`/api/${endpoint}?${query}`);
9+
if (!res.ok) {
10+
// Error bodies are not always JSON (plain-text 404, proxy error pages).
11+
const body = (await res.json().catch(() => null)) as { error?: string } | null;
12+
throw new Error(body?.error ?? `HTTP ${res.status}: ${res.statusText}`);
13+
}
14+
return (await res.json()) as T;
15+
}

‎graph-ui/src/api/rpc.ts‎

Lines changed: 0 additions & 47 deletions
This file was deleted.

‎graph-ui/src/components/NodeDetailPanel.test.tsx‎

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,11 @@
11
/* @vitest-environment jsdom */
22
import "@testing-library/jest-dom/vitest";
33
import { fireEvent, render, screen } from "@testing-library/react";
4-
import { describe, expect, it, vi } from "vitest";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
55
import { NodeDetailPanel } from "./NodeDetailPanel";
66
import type { GraphNode, RepoInfo } from "../lib/types";
77

8-
/* Mock the RPC layer so "Show code" resolves without a backend. */
9-
const callToolMock = vi.fn();
10-
vi.mock("../api/rpc", () => ({
11-
callTool: (...args: unknown[]) => callToolMock(...args),
12-
RpcError: class extends Error {},
13-
}));
14-
8+
const fetchMock = vi.fn();
159
const NODE: GraphNode = {
1610
id: 7,
1711
x: 0,
@@ -40,10 +34,12 @@ const REPO: RepoInfo = {
4034
};
4135

4236
describe("NodeDetailPanel code preview + deep-link", () => {
37+
afterEach(() => vi.unstubAllGlobals());
4338
it("renders fetched source as escaped text, never as injected HTML", async () => {
4439
/* A payload that would execute if the code were rendered as raw HTML. */
4540
const payload = "<script>window.__pwned = true;</script>\nconst answer = 42;";
46-
callToolMock.mockResolvedValueOnce({ source: payload });
41+
vi.stubGlobal("fetch", fetchMock);
42+
fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ source: payload }) });
4743

4844
const { container } = render(
4945
<NodeDetailPanel
@@ -68,12 +64,9 @@ describe("NodeDetailPanel code preview + deep-link", () => {
6864
/* …but was NOT parsed into a real <script> element, and did not execute. */
6965
expect(container.querySelector("script")).toBeNull();
7066
expect((window as unknown as { __pwned?: boolean }).__pwned).toBeUndefined();
71-
expect(callToolMock).toHaveBeenCalledWith("get_code_snippet", {
72-
qualified_name: "app::render",
73-
project: "demo",
74-
format: "json",
75-
source_mode: "full",
76-
});
67+
expect(fetchMock).toHaveBeenCalledWith(
68+
"/api/snippet?qualified_name=app%3A%3Arender&project=demo&source_mode=full",
69+
);
7770
});
7871

7972
it("builds an https GitHub deep-link with URL-encoded path segments", () => {

‎graph-ui/src/components/NodeDetailPanel.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useMemo, useState, useEffect } from "react";
22
import { ScrollArea } from "@/components/ui/scroll-area";
33
import { colorForLabel } from "../lib/colors";
4-
import { callTool } from "../api/rpc";
4+
import { fetchRead } from "../api/native";
55
import type { GraphNode, GraphEdge, RepoInfo } from "../lib/types";
66

77
interface Connection {
@@ -73,10 +73,9 @@ export function NodeDetailPanel({
7373
setCodeLoading(true);
7474
setCodeError(null);
7575
try {
76-
const res = await callTool<SnippetResult>("get_code_snippet", {
76+
const res = await fetchRead<SnippetResult>("snippet", {
7777
qualified_name: node.qualified_name,
7878
project,
79-
format: "json",
8079
source_mode: "full",
8180
});
8281
setCode(res.source ?? "(source not available)");

‎graph-ui/src/components/StatsTab.test.tsx‎

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,8 @@ function mockProjectsFetch(extra?: (url: string, init?: RequestInit) => Response
1010
const url = String(input);
1111
const overridden = extra?.(url, init);
1212
if (overridden) return overridden;
13-
if (url === "/rpc") {
14-
return new Response(JSON.stringify({
15-
result: { content: [{ text: JSON.stringify({ projects: [], has_more: false }) }] },
16-
}), { status: 200, headers: { "Content-Type": "application/json" } });
13+
if (url.startsWith("/api/projects")) {
14+
return new Response(JSON.stringify({ projects: [], has_more: false }), { status: 200, headers: { "Content-Type": "application/json" } });
1715
}
1816
if (url.startsWith("/api/ui-config")) {
1917
return new Response(JSON.stringify({ lang: "en" }), {
@@ -211,10 +209,8 @@ describe("StatsTab index modal", () => {
211209
it("posts empty ADR content when deleting an existing ADR", async () => {
212210
let saved: unknown = null;
213211
mockProjectsFetch((url, init) => {
214-
if (url === "/rpc") {
215-
const body = JSON.parse(String(init?.body));
216-
const tool = body.params?.name;
217-
const result = tool === "list_projects"
212+
if (url.startsWith("/api/projects") || url.startsWith("/api/schema")) {
213+
const result = url.startsWith("/api/projects")
218214
? {
219215
projects: [{
220216
name: "demo",
@@ -226,9 +222,7 @@ describe("StatsTab index modal", () => {
226222
: {
227223
node_labels: [], edge_types: [], total_nodes: 0, total_edges: 0, has_more: false,
228224
};
229-
return new Response(JSON.stringify({
230-
result: { content: [{ text: JSON.stringify(result) }] },
231-
}), { status: 200, headers: { "Content-Type": "application/json" } });
225+
return new Response(JSON.stringify(result), { status: 200, headers: { "Content-Type": "application/json" } });
232226
}
233227
if (url.startsWith("/api/adr")) {
234228
if (init?.method === "POST") {
Lines changed: 53 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,57 @@
11
/* @vitest-environment jsdom */
22
import { renderHook, waitFor } from "@testing-library/react";
3-
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
import { useProjects } from "./useProjects";
55

6-
const callToolMock = vi.fn();
7-
vi.mock("../api/rpc", () => ({
8-
callTool: (...args: unknown[]) => callToolMock(...args),
9-
}));
10-
6+
const fetchMock = vi.fn();
117
describe("useProjects machine-readable pagination", () => {
12-
beforeEach(() => callToolMock.mockReset());
8+
beforeEach(() => {
9+
fetchMock.mockReset();
10+
vi.stubGlobal("fetch", fetchMock);
11+
});
12+
13+
afterEach(() => vi.unstubAllGlobals());
1314

1415
it("requests JSON and merges every project and schema page", async () => {
15-
callToolMock.mockImplementation(async (name: string, args: Record<string, unknown>) => {
16-
if (name === "list_projects") {
17-
if (args.offset === 0) {
16+
fetchMock.mockImplementation(async (input: string) => {
17+
const [name, query] = input.slice(5).split("?");
18+
const args = Object.fromEntries(new URLSearchParams(query));
19+
const page = await (async () => {
20+
if (name === "projects") {
21+
if (args.offset === "0") {
22+
return {
23+
projects: [{ name: "alpha", root_path: "/alpha", indexed_at: "now" }],
24+
has_more: true,
25+
next_offset: 1,
26+
};
27+
}
1828
return {
19-
projects: [{ name: "alpha", root_path: "/alpha", indexed_at: "now" }],
20-
has_more: true,
21-
next_offset: 1,
29+
projects: [{ name: "beta", root_path: "/beta", indexed_at: "now" }],
30+
has_more: false,
2231
};
2332
}
24-
return {
25-
projects: [{ name: "beta", root_path: "/beta", indexed_at: "now" }],
26-
has_more: false,
27-
};
28-
}
29-
if (name === "get_graph_schema" && args.project === "alpha") {
30-
if (args.offset === 0) {
33+
if (name === "schema" && args.project === "alpha") {
34+
if (args.offset === "0") {
35+
return {
36+
node_labels: [{ label: "Function", count: 3 }],
37+
edge_types: [],
38+
has_more: true,
39+
next_offset: 1,
40+
};
41+
}
3142
return {
32-
node_labels: [{ label: "Function", count: 3 }],
33-
edge_types: [],
34-
total_nodes: 3,
35-
total_edges: 2,
36-
has_more: true,
37-
next_offset: 1,
43+
node_labels: [],
44+
edge_types: [{ type: "CALLS", count: 2 }],
45+
has_more: false,
3846
};
3947
}
4048
return {
41-
node_labels: [],
42-
edge_types: [{ type: "CALLS", count: 2 }],
43-
total_nodes: 3,
44-
total_edges: 2,
49+
node_labels: [{ label: "Class", count: 1 }],
50+
edge_types: [],
4551
has_more: false,
4652
};
47-
}
48-
return {
49-
node_labels: [{ label: "Class", count: 1 }],
50-
edge_types: [],
51-
total_nodes: 1,
52-
total_edges: 0,
53-
has_more: false,
54-
};
53+
})();
54+
return { ok: true, json: async () => page };
5555
});
5656

5757
const { result } = renderHook(() => useProjects());
@@ -64,23 +64,21 @@ describe("useProjects machine-readable pagination", () => {
6464
expect(result.current.projects[0].schema?.edge_types).toEqual([
6565
{ type: "CALLS", count: 2 },
6666
]);
67-
expect(callToolMock).toHaveBeenCalledWith("list_projects", {
68-
format: "json",
69-
detail: "stats",
70-
limit: 500,
71-
offset: 0,
72-
});
73-
expect(callToolMock).toHaveBeenCalledWith("list_projects", {
74-
format: "json",
75-
detail: "stats",
76-
limit: 500,
77-
offset: 1,
78-
});
79-
expect(callToolMock).toHaveBeenCalledWith("get_graph_schema", {
80-
project: "alpha",
81-
format: "json",
82-
limit: 500,
83-
offset: 1,
67+
expect(result.current.projects[0].schema?.total_nodes).toBe(3);
68+
expect(result.current.projects[0].schema?.total_edges).toBe(2);
69+
expect(fetchMock).toHaveBeenCalledWith("/api/projects?limit=500&offset=0");
70+
expect(fetchMock).toHaveBeenCalledWith("/api/projects?limit=500&offset=1");
71+
expect(fetchMock).toHaveBeenCalledWith("/api/schema?project=alpha&limit=500&offset=1");
72+
});
73+
it("reports native API errors", async () => {
74+
fetchMock.mockResolvedValue({
75+
ok: false,
76+
status: 400,
77+
json: async () => ({ error: "cannot read cache directory" }),
8478
});
79+
const { result } = renderHook(() => useProjects());
80+
await waitFor(() => expect(result.current.loading).toBe(false));
81+
expect(result.current.error).toBe("cannot read cache directory");
82+
expect(result.current.projects).toEqual([]);
8583
});
8684
});

‎graph-ui/src/hooks/useProjects.ts‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useCallback, useEffect, useState } from "react";
2-
import { callTool } from "../api/rpc";
2+
import { fetchRead } from "../api/native";
33
import type { Project, SchemaInfo } from "../lib/types";
44

55
interface ProjectInfo {
@@ -13,7 +13,7 @@ interface ProjectPage {
1313
next_offset?: number;
1414
}
1515

16-
interface SchemaPage extends SchemaInfo {
16+
interface SchemaPage extends Omit<SchemaInfo, "total_nodes" | "total_edges"> {
1717
has_more?: boolean;
1818
next_offset?: number;
1919
}
@@ -35,9 +35,7 @@ async function fetchAllProjects(): Promise<Project[]> {
3535
const projects: Project[] = [];
3636
let offset = 0;
3737
for (;;) {
38-
const page = await callTool<ProjectPage>("list_projects", {
39-
format: "json",
40-
detail: "stats",
38+
const page = await fetchRead<ProjectPage>("projects", {
4139
limit: PAGE_LIMIT,
4240
offset,
4341
});
@@ -54,9 +52,8 @@ async function fetchFullSchema(project: string): Promise<SchemaInfo> {
5452
let firstPage: SchemaPage | null = null;
5553
let offset = 0;
5654
for (;;) {
57-
const page = await callTool<SchemaPage>("get_graph_schema", {
55+
const page = await fetchRead<SchemaPage>("schema", {
5856
project,
59-
format: "json",
6057
limit: PAGE_LIMIT,
6158
offset,
6259
});
@@ -65,7 +62,13 @@ async function fetchFullSchema(project: string): Promise<SchemaInfo> {
6562
edgeTypes.push(...(page.edge_types ?? []));
6663
const next = nextPageOffset(page, offset);
6764
if (next === null) {
68-
return { ...firstPage, node_labels: nodeLabels, edge_types: edgeTypes };
65+
return {
66+
...firstPage,
67+
node_labels: nodeLabels,
68+
edge_types: edgeTypes,
69+
total_nodes: nodeLabels.reduce((total, label) => total + label.count, 0),
70+
total_edges: edgeTypes.reduce((total, edge) => total + edge.count, 0),
71+
};
6972
}
7073
offset = next;
7174
}

‎graph-ui/vite.config.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ export default defineConfig({
5050
port: 5173,
5151
strictPort: true,
5252
proxy: {
53-
"/rpc": uiBackendProxy(),
5453
"/api": uiBackendProxy(),
5554
},
5655
},

0 commit comments

Comments
 (0)