Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

#### Symbols, tests and the viewer

- In `codegraph ui`, "Read as flow" now works on any trail the viewer can save, up to 64 hops, and a shared link to a long trail gets all its hop names back. A trail longer than that keeps its most recent 64 hops. (#1976)
- Kotlin functions and methods now carry their signature — `(params): ReturnType` — in `codegraph_explore`, `node` and the viewer, instead of no signature at all. Re-index Kotlin projects after upgrading. (#1495)
- TypeScript/JavaScript value aliases — `export const alias = fn`, `export { fn as alias }`, object-literal `api = { run: fn }`, and same-file `const local = fn` — now forward calls edges to the aliased function, so callers and impact on the implementation include consumers that call through the alias instead of stopping at the binding. Genuine wrappers (`() => fn()`) are unchanged. Re-index after upgrading. Thanks @valkyriweb. (#1482, #1485)
- `codegraph affected` now finds Go, Python and JVM test files that previously went unreported, while preserving custom `--filter` behavior (thanks @danusha2345; #1507, #1688).
Expand Down
12 changes: 8 additions & 4 deletions __tests__/ui-flow-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import * as path from 'path';
import CodeGraph from '../src/index';
import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
import { flowEdgeLabel, parseFlowQuery } from '../src/ui-server/api/flow';
import { MAX_TRAIL_HOPS } from '../src/ui-server/api/trail-store';
import { resolveNamedSymbolFlow } from '../src/graph/named-symbol-flow';
import { ToolHandler } from '../src/mcp/tools';
import { continuationsFrom } from '../src/graph/dynamic-boundary-report';
Expand Down Expand Up @@ -600,11 +601,14 @@ describe('GET /api/flow — refusals', () => {
expect(payload.hint).toMatch(/\?from=/);
});

it('caps the number of trail hops it will read', async () => {
const query = Array.from({ length: 40 }, (_, i) => `hop=s${i}xx`).join('&');
const payload = await getFlow(`?${query}`, 400);
it('caps the number of trail hops it will read at what the trail store saves (#1976)', async () => {
const hopsQuery = (n: number) => Array.from({ length: n }, (_, i) => `hop=s${i}xx`).join('&');
// A 64-hop trail is one the store saves, so it must be readable as a flow.
const saved = await request(`/api/flow?${hopsQuery(MAX_TRAIL_HOPS)}`);
expect(saved.status).toBe(200);
const payload = await getFlow(`?${hopsQuery(MAX_TRAIL_HOPS + 1)}`, 400);
expect(payload.code).toBe('bad-request');
expect(payload.error).toMatch(/longer than this endpoint reads/);
expect(payload.error).toMatch(/longer than this endpoint reads \(64\)/);
});

it('is listed on the API index', async () => {
Expand Down
37 changes: 37 additions & 0 deletions __tests__/ui-package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
setNavigationDriver,
symbolHref,
trail,
resolveTrailNames,
type GraphAdapter,
type NavigationDriver,
type WireFlowPayload,
Expand Down Expand Up @@ -784,3 +785,39 @@ describe('@colbymchenry/codegraph-ui — the published shape', () => {
expect(manifest.dependencies['@xyflow/svelte']).toBeDefined();
});
});

describe('a long trail (#1976)', () => {
it('keeps at most the 64 hops the store saves and /api/flow reads, dropping the oldest', () => {
for (let i = 0; i < 70; i++) trail.push({ id: `function:h${i}` });
expect(trail.hops).toHaveLength(64);
expect(trail.hops[0]?.id).toBe('function:h6');
expect(trail.hops[63]?.id).toBe('function:h69');
});

it('asks for its names in batches /api/nodes accepts', async () => {
const { adapter } = mockAdapter();
const batches: number[] = [];
adapter.nodes = (ids) => {
batches.push(ids.length);
const items = ids.map(
(id) =>
({
id,
kind: 'function',
name: id.slice('function:'.length),
qualifiedName: id,
file: 'src/a.ts',
line: 1,
endLine: 1,
language: 'typescript',
}) as WireNodeRef
);
return Promise.resolve({ items, missing: [] });
};
setGraphAdapter(adapter);
for (let i = 0; i < 64; i++) trail.push({ id: `function:h${i}` });
await resolveTrailNames();
expect(batches).toEqual([60, 4]);
expect(trail.hops.every((hop) => hop.name)).toBe(true);
});
});
5 changes: 3 additions & 2 deletions src/ui-server/api/flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ import {
} from '../../graph/dynamic-boundary-report';
import { highlightLines, type HighlightResult } from '../highlight';
import { badRequest, intParam } from './respond';
// Hops accepted from a trail: the same cap the trail store saves under, so a
// trail that can be saved can also be read as a flow (#1976).
import { MAX_TRAIL_HOPS } from './trail-store';
import { findIndexedFile, hasDriftedOnDisk, splitLines, toRequestPath } from './source';
import { resolveProjectFile } from '../security';
import {
Expand Down Expand Up @@ -80,8 +83,6 @@ const HIGHLIGHT_LEAD_MAX = 200;
/** Distinct paths returned. The header's flow picker is a short list or nothing. */
export const MAX_FLOWS = 4;

/** Hops accepted from a trail. The trail bar itself is not much longer than this. */
const MAX_TRAIL_HOPS = 24;

// =============================================================================
// Wire shapes
Expand Down
18 changes: 16 additions & 2 deletions ui/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,22 @@ export function fetchSearch(
}

/** Names and locations for ids you already have — what the trail redraws with. */
export function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Promise<WireNodeRefs> {
return getGraphAdapter().nodes(ids, signal);
/** Ids `/api/nodes` answers per request (`MAX_NODE_REFS` on the server). */
const NODE_REFS_PER_REQUEST = 60;

/**
* Names and kinds for a set of ids. A trail can hold more hops than one request
* answers, so the ids go out in batches and the answers are merged (#1976).
*/
export async function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Promise<WireNodeRefs> {
if (ids.length <= NODE_REFS_PER_REQUEST) return getGraphAdapter().nodes(ids, signal);
const merged: WireNodeRefs = { items: [], missing: [] };
for (let i = 0; i < ids.length; i += NODE_REFS_PER_REQUEST) {
const batch = await getGraphAdapter().nodes(ids.slice(i, i + NODE_REFS_PER_REQUEST), signal);
merged.items.push(...batch.items);
merged.missing.push(...batch.missing);
}
return merged;
}

export function fetchEntryPoints(
Expand Down
11 changes: 9 additions & 2 deletions ui/src/lib/trail.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export type { HopDirection, TrailHop } from './trail-codec';

let hops = $state<TrailHop[]>([]);

/**
* Hops one trail keeps — the trail store's `MAX_TRAIL_HOPS`, which `/api/flow`
* reads up to as well, so any trail on the bar can be saved and read as a flow.
* Past it the oldest hop falls off the front (#1976).
*/
const MAX_TRAIL_HOPS = 64;

/**
* Every name this session has learned, by id.
*
Expand Down Expand Up @@ -70,7 +77,7 @@ export const trail = {
kind: hop.kind ?? null,
dir: hop.dir ?? (hops.length === 0 ? 'start' : 'down'),
},
];
].slice(-MAX_TRAIL_HOPS);
},

/**
Expand Down Expand Up @@ -119,7 +126,7 @@ export const trail = {
if (encodeTrail(decoded) === encodeTrail(hops)) return;
// Names survive the change — including for hops this trail dropped earlier
// and history has just brought back.
hops = decoded.map((h) => {
hops = decoded.slice(-MAX_TRAIL_HOPS).map((h) => {
const seen = known.get(h.id);
return seen ? { ...h, name: seen.name, kind: seen.kind } : h;
});
Expand Down