Files
codegraph/__tests__/ui-filecode-api.test.ts
T
Colby McHenryandClaude Opus 5 bd99c5e99a feat(ui): the whole file — full source with gutter ports and intra-file call arcs (CG-52)
The File view gains a Source tab: the file itself, top to bottom, with the
Symbol view's line grid, gutter ports and call-site links, a line-anchored
callee rail, and — in the left margin — an arc for every call that stays inside
the file, drawn from the calling line to the callee's definition line.

The arcs are the point. Source order is already a layout, chosen by whoever
wrote the file, so a file's internal call structure can be drawn with no
algorithm placing anything. Crabviz's idea, in the one place it is legible.

Everything is arithmetic, not measurement. The Symbol view queries the laid-out
DOM to place a callee row beside its line; a 6 820-line file cannot afford that.
Here a line is exactly 20px at `10 + (n - 1) x 20`, so ~90 line elements exist at
a time and the arcs, ports, rail rows and connectors are all functions of a line
number. `src/mcp/tools.ts` scrolls at a 16.6ms median frame.

- `GET /api/filecode/<path>` — outline, one call group per (caller, callee) PAIR
  with its call-site lines, unresolved references, and the file's length. The
  source is NOT in it: it pages through `/api/source` 800 lines at a time with a
  discarded 150-line lead-in, so a page starting inside a block comment does not
  render prose as code, and so the ports and arcs are complete from the first
  frame while the text fills in behind them.
- `intraFileCalls` is counted over the groups actually returned, so the header
  and the picture under it cannot disagree once a cap bites.
- Above 40 arcs the diagram narrows to the symbol under the pointer (or the one
  the scroll position is inside) and the header states the total. Accent is for
  the pointer only, never for the filter.
- Sticky outline rail at >= 1400px, following the reader down the file.
- `QueryBuilder.getUnresolvedReferencesInFile` — one indexed lookup instead of
  one per symbol; `buildOutlineEntries` lifted out of `/api/file` so both
  readings of a file draw the same rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 03:47:42 -05:00

304 lines
12 KiB
TypeScript

/**
* `GET /api/filecode` — everything the whole-file view draws (CG-52).
*
* Against a real indexed fixture over a real loopback server, like the rest of
* the viewer's API suite. The fixture is shaped around the four claims this
* endpoint makes that a hand-written payload could not prove:
*
* - a call group is one (CALLER, CALLEE) pair, not one per callee — the same
* helper reached from two functions has to come back as two rows, because a
* row is anchored to a line and there is no line that is both,
* - `intraFileCalls` counts exactly the arcs the viewer can draw from `calls`,
* so the header and the picture under it cannot disagree,
* - top-level code has an owner (the file node), which is the only way a
* statement outside every definition gets a port at all,
* - a reference that resolves to nothing still comes back, so a line calling a
* runtime builtin shows a hollow port instead of an empty gutter.
*
* The pure geometry is tested without a server in `ui-filecode-model.test.ts`.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as http from 'http';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
import { MAX_FILE_CALL_GROUPS, MAX_FILE_OUTSIDE_REFS } from '../src/ui-server/api/filecode';
let server: UiServerHandle;
let api: GraphApi;
let tempDir: string;
let projectRoot: string;
function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
return new Promise((resolve, reject) => {
const req = http.request(
{
host: '127.0.0.1',
port: server.port,
path: requestPath,
method: 'GET',
headers: { Host: `127.0.0.1:${server.port}` },
setHost: false,
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () =>
resolve({
status: res.statusCode ?? 0,
body: Buffer.concat(chunks).toString('utf-8'),
type: res.headers['content-type'],
})
);
}
);
req.on('error', reject);
req.end();
});
}
async function getCode(file: string, expected = 200): Promise<any> {
const res = await request(`/api/filecode/${file}`);
expect(res.type).toBe('application/json; charset=utf-8');
expect(res.status).toBe(expected);
return JSON.parse(res.body);
}
function write(root: string, rel: string, body: string): void {
const full = path.join(root, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, body);
}
/** Rows as `caller -> callee`, which is how the rail reads. */
function pairs(payload: any): string[] {
const names = new Map<string, string>(
payload.outline.items.map((e: any) => [e.id, e.name] as [string, string])
);
return payload.calls.items.map(
(c: any) => `${names.get(c.ownerId) ?? 'file'} -> ${c.relation.node.name}`
);
}
beforeAll(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-filecode-'));
projectRoot = path.join(tempDir, 'project');
// `format` is called by TWO functions in this file and by one in another, and
// `render` calls it twice from two different lines — every grouping case in
// one file.
write(
projectRoot,
'src/report.ts',
`import { widen } from './widen';
export function format(value: string): string {
return value.trim();
}
export function render(a: string, b: string): string {
const left = format(a);
const right = format(b);
return left + right;
}
export function summarise(rows: string[]): string {
const head = format(rows[0] ?? '');
console.log(head);
return widen(head);
}
render('a', 'b');
`
);
write(
projectRoot,
'src/widen.ts',
`export function widen(text: string): string {
return text + ' ';
}
`
);
// Nothing in it reaches anything: the empty-rail, no-arc case.
write(projectRoot, 'src/quiet.ts', `export const NAME = 'quiet';\n`);
const cg = CodeGraph.initSync(projectRoot, {
config: { include: ['src/**/*.ts'], exclude: [] },
});
await cg.indexAll();
cg.resolveReferences();
cg.close();
const viewerDir = path.join(tempDir, 'viewer');
fs.mkdirSync(viewerDir, { recursive: true });
fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
api = createGraphApi({ projectRoot });
server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
}, 120_000);
afterAll(async () => {
api?.close();
await server?.close();
if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('GET /api/filecode', () => {
it('describes the file and its length, which is the view\'s layout', async () => {
const payload = await getCode('src/report.ts');
expect(payload.file.path).toBe('src/report.ts');
expect(payload.file.language).toBe('typescript');
expect(payload.file.id).toBe('file:src/report.ts');
expect(payload.drift).toBe(false);
// The count comes from disk, not from the index: it is the height of the
// scrolling document, and the source itself is paged in separately.
const onDisk = fs.readFileSync(path.join(projectRoot, 'src/report.ts'), 'utf-8');
expect(payload.file.totalLines).toBe(onDisk.replace(/\n$/, '').split('\n').length);
});
it('returns the same outline rows the File view draws', async () => {
const code = await getCode('src/report.ts');
const file = JSON.parse((await request('/api/file/src/report.ts')).body);
expect(code.outline.total).toBe(file.outline.total);
expect(code.outline.items.map((e: any) => e.name)).toEqual(
file.outline.items.map((e: any) => e.name)
);
// A rail that disagreed with the source beside it would be worse than none.
for (const entry of code.outline.items) {
expect(entry.line).toBeGreaterThan(0);
expect(entry.endLine).toBeGreaterThanOrEqual(entry.line);
}
});
it('groups by the PAIR, so one callee reached from two functions is two rows', async () => {
const payload = await getCode('src/report.ts');
const rows = pairs(payload);
expect(rows).toContain('render -> format');
expect(rows).toContain('summarise -> format');
// …and the two lines `render` calls it from stay ONE row, with both lines.
const renderRow = payload.calls.items.find(
(c: any) =>
c.relation.node.name === 'format' &&
payload.outline.items.find((e: any) => e.id === c.ownerId)?.name === 'render'
);
expect(renderRow.relation.lines.length).toBe(2);
expect(renderRow.relation.lines[0]).toBeLessThan(renderRow.relation.lines[1]);
});
it('rows are in call-site order — the only ordering the screen has', async () => {
const payload = await getCode('src/report.ts');
const firstLines = payload.calls.items.map((c: any) => c.relation.lines[0] ?? Infinity);
const sorted = [...firstLines].sort((a: number, b: number) => a - b);
expect(firstLines).toEqual(sorted);
});
it('gives top-level code an owner, so a statement outside every definition has a port', async () => {
const payload = await getCode('src/report.ts');
const topLevel = payload.calls.items.filter((c: any) => c.ownerId === payload.file.id);
// `render('a', 'b')` at the bottom of the file belongs to no symbol.
expect(topLevel.map((c: any) => c.relation.node.name)).toContain('render');
});
it('counts exactly the arcs the payload can draw', async () => {
const payload = await getCode('src/report.ts');
// Recompute the arc list the way the viewer does, from `calls` alone.
let arcs = 0;
for (const call of payload.calls.items) {
if (call.relation.node.file !== payload.file.path) continue;
for (const line of call.relation.lines) {
if (line !== call.relation.node.line) arcs++;
}
}
expect(payload.intraFileCalls).toBe(arcs);
// render x2, summarise x1, top-level render x1 — every call that stays home.
expect(payload.intraFileCalls).toBeGreaterThanOrEqual(4);
});
it('does not count a cross-file call as an arc', async () => {
const payload = await getCode('src/report.ts');
const widen = payload.calls.items.find((c: any) => c.relation.node.name === 'widen');
expect(widen).toBeDefined();
expect(widen.relation.node.file).toBe('src/widen.ts');
});
it('returns references that resolved to nothing, with a line and a plain name', async () => {
const payload = await getCode('src/report.ts');
const names = payload.outside.items.map((r: any) => r.name);
// `console.log` reaches a runtime builtin; the gutter must still show it.
expect(names).toContain('log');
for (const ref of payload.outside.items) {
expect(ref.line).toBeGreaterThan(0);
expect(ref.name).toMatch(/^[A-Za-z_$][\w$]*$/);
}
expect(payload.outside.total).toBe(payload.outside.items.length);
expect(payload.outside.shown).toBeLessThanOrEqual(MAX_FILE_OUTSIDE_REFS);
});
it('answers for a file that reaches nothing without inventing rows', async () => {
const payload = await getCode('src/quiet.ts');
expect(payload.calls.total).toBe(0);
expect(payload.calls.items).toEqual([]);
expect(payload.intraFileCalls).toBe(0);
expect(payload.file.totalLines).toBe(1);
});
it('every capped list still reports its real total', async () => {
const payload = await getCode('src/report.ts');
for (const list of [payload.outline, payload.calls, payload.outside]) {
expect(list.shown).toBe(list.items.length);
expect(list.total).toBeGreaterThanOrEqual(list.shown);
expect(list.truncated).toBe(list.shown < list.total);
}
expect(payload.calls.shown).toBeLessThanOrEqual(MAX_FILE_CALL_GROUPS);
});
it('refuses a path outside the project before it looks in the index', async () => {
// The chokepoint answers "outside the project", not "not indexed" — the
// order is what makes that true by construction. See `resolveRequestedFile`.
const res = await request('/api/filecode//etc/passwd');
expect(res.status).toBe(403);
expect(JSON.parse(res.body).code).toBe('refused');
});
it('answers 404 for a file that is fine but not indexed', async () => {
const payload = await getCode('src/nope.ts', 404);
expect(payload.code).toBe('not-found');
expect(payload.error).toMatch(/not in this CodeGraph index/);
});
it('says what the endpoint wants when given no path', async () => {
const res = await request('/api/filecode');
expect(res.status).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/\/api\/filecode\/<path>/);
});
it('is listed on the API index', async () => {
const body = JSON.parse((await request('/api')).body);
expect(body.endpoints.find((e: any) => e.path === '/api/filecode/<path>')).toBeDefined();
// The shorter route must still resolve to the File view's own endpoint.
expect(body.endpoints.find((e: any) => e.path === '/api/file/<path>')).toBeDefined();
});
});
describe('drift', () => {
it('flags a file that changed on disk and withholds its length', async () => {
const file = path.join(projectRoot, 'src/widen.ts');
const original = fs.readFileSync(file, 'utf-8');
try {
fs.writeFileSync(file, `// a new first line\n${original}`);
const payload = await getCode('src/widen.ts');
expect(payload.drift).toBe(true);
expect(payload.reason).toMatch(/changed on disk/);
// The rows are still true about the graph; only the line numbers are not,
// which is exactly why the view draws the banner instead of the source.
expect(payload.outline.total).toBeGreaterThan(0);
} finally {
fs.writeFileSync(file, original);
}
});
});