Explorar el Código

feat(ui): entry points — routes, executable files and tests as flow starting points (CG-54)

`#/entry` answers "where does anything start" at full length, and turns any row
that names a symbol into a flow.

Server. `/api/entrypoints` gains `frameworks` (from `getDetectedFrameworks`), a
`tests` list, a `routes` limit of its own, and a cache keyed on the index build
— nothing here is read from disk, so unlike `/api/source` a cached answer cannot
be stale about drift. `routes.items` is now a `WireList` like every other list on
the payload.

Routes carry where the URL is REGISTERED as well as where it is served:
`getRoutingManifest` selects the route node's id, file and line, and
`buildRoutes` splits the verb off the name against a fixed list (never "the
first word", which would take the head off a file-routed `/blog/[slug]`). All
four payroll-go routes register in one router file and three are served from
another — group by the handler file and one router becomes two groups plus an
orphan.

`isTestFile` is split into `isTestPath` (test filename and directory
conventions) + the non-production catch-all, byte-identical at every existing
call site. The Tests list uses the narrow half: an example, a benchmark or a
fixture is off-target for ranking but is not a test, and a heading that says
"Tests" must not quietly count them. Tests rank by REACH — distinct other files
touched — because Go, Rust and Java put test work inside functions where a
module-level-calls ranking sees nothing. Two read-only engine queries make that
affordable: `getFileReachCounts` (the mirror of `getFileDependentCounts`, driven
from `nodes` by path so the cost follows the files asked about rather than the
edge table) and `getFileNodes`.

Viewer. `ui/src/lib/entry-model.ts` folds the four lists into file groups —
pure, and `panel.rows` stays exactly the sections it draws. `EntryView` +
`EntrySection` render them with the caller rail's `.filegroup` / `.row` shapes
rather than a second visual language for the same idea. A row that names a
callable symbol carries a `Flow ›` chip; the other end is typed or picked with
`→ here` on another row. File and test rows carry none: `/api/flow` searches by
name, and a file has none the path finder can look up.

A project with fewer than three resolvable routes gets no Routes heading at all,
not an empty one. Typing into the search box now also returns matching entry
points under their own heading below the symbol matches, so a URL comes back
with its handler attached; rows already in the results are dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Colby McHenry hace 1 semana
padre
commit
94f4e287e6

+ 6 - 0
CHANGELOG.md

@@ -46,6 +46,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
   Nothing polls: the viewer watches for these two things and is told about them. If it loses touch with the server it retries a few times with a growing delay, then stops and says "Not live" in the top bar rather than hammering a port that isn't answering.
 
+- **"Where does anything start?" has a screen now.** The **Entry points** tab in `codegraph ui` (or press `e`) is the first thing worth opening on a codebase you have never seen. Every route with the symbol that serves it and the `file:line` you will find it at, grouped by the file the URL is registered in — your router, not your handlers — and headed with the framework CodeGraph detected it from. Under that, the files that actually *do* something when they load (a CLI, a worker entry, a build script), the tests ranked by how much of the project each one exercises, and the symbols the most code depends on.
+
+  None of it is guessed from a filename: a file "runs something" because the graph recorded a call from the file itself, and a project with fewer than three routes simply has no Routes section rather than an empty one. Every list says how much of itself it is showing, and says "at least" wherever the real total can only be a floor.
+
+  Any row that names a symbol can start a **flow**: press `Flow ›`, then type a second symbol or press `→ here` on another row, and you get the path between them — so "how does `POST /v1/payroll/cycles/{cycleID}/run` reach the database" is two clicks. Typing into the search box now finds entry points too, under their own heading below the symbol matches, so a URL comes back with its handler attached instead of on its own.
+
 ### Fixes
 
 - Fixed a long-running `codegraph ui` session serving a symbol that a sync had already deleted. The viewer keeps one connection to your index open, and its in-memory lookup didn't notice when another process — your agent's sync, or `codegraph sync` — rewrote the file underneath it, so a symbol screen could keep showing a body with no callers while search correctly reported it had moved. Because a symbol's identity includes the line it starts on, this happened after almost any edit above it.

+ 2 - 1
README.md

@@ -344,7 +344,8 @@ What you get on that screen:
 - **Callees on the right**, positioned at the line that calls them, joined by a hairline. Hover either end and both light up.
 - **Blast radius** — direct dependents, everything within three hops, and how many files and test files that touches.
 - **Honest edges.** A guess CodeGraph isn't sure about is folded away as "uncertain" rather than shown as fact, and a symbol no test reaches within three hops says so.
-- **Search** (`/` or ⌘K) over every symbol and file, **entry points** to start from (routes, hubs, files that run code at import time), and a **trail** of the path you walked that lives in the URL, so you can send someone the exact route you took.
+- **Search** (`/` or ⌘K) over every symbol and file, and a **trail** of the path you walked that lives in the URL, so you can send someone the exact route you took. Typing a name also surfaces matching **entry points** under their own heading, so a URL comes back with the symbol that serves it rather than on its own.
+- **Entry points** — the first screen on a codebase you have never opened, and the answer to "where does anything start". Every route with its handler and the line it is registered on, grouped by router file and named with the framework it was detected from; the files that run something at import time (a CLI, a worker entry, a script); the tests, ranked by how much of the project each one exercises; and the symbols the most code depends on. Nothing is guessed from a filename — it is all read out of the graph, and a project with no routes says so instead of drawing an empty list. Any row that names a symbol can start a **flow**: pick a second symbol and you get the path between them, so "how does `POST /v1/payroll/cycles/{cycleID}/run` reach the database" is two clicks.
 - Click any file path to open the **file view**: everything that file depends on, its outline in source order, and everything that depends on it. Its **Source** tab shows the whole file with the same gutter markers, plus an arc in the left margin for every call that stays inside the file — the one place a file's internal call structure is legible, because source order does the layout. A 6,800-line file scrolls at full speed.
 - **Ask for a path.** Type "how does execute reach getFile" (or `execute -> getFile`) and you get the **flow**: one card per hop, each opened at the line that makes the next call. Hops that no static edge records — a callback, an interface dispatch, a React re-render — are drawn dashed and name where the handler was wired. "Read as flow" turns a walk you did by hand into the same strip.
 - **And when the path runs out, it says where.** A flow that doesn't get there ends in "Where the graph stops": the kind of dispatch that ended it (a computed member call, a `getattr`, a reflective invoke, a message bus), its line, the key when the source spells one out, and a shortlist of what could be on the other side — plus the name-only matches CodeGraph refused to follow, with their confidence. Nothing is guessed, and a flow that does connect never shows it.

+ 342 - 0
__tests__/ui-entry-model.test.ts

@@ -0,0 +1,342 @@
+/**
+ * The entry-points panel's grouping, without a browser (CG-54).
+ *
+ * The half of `ui-entrypoints-api.test.ts` that needs no index: given a
+ * payload, which rows exist, what they say, where they group, and which of them
+ * can be clicked or turned into a flow. The rules worth pinning are the ones a
+ * refactor would quietly break:
+ *
+ * - `panel.rows` is exactly the sections' rows in draw order (the same identity
+ *   the search palette rests its keyboard on).
+ * - A route with no resolved handler still appears, but carries no target — a
+ *   row that looks clickable and is not is worse than a row that says so.
+ * - Only a row that names a callable symbol offers a flow.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  buildEntryPanel,
+  directoryOf,
+  flowPair,
+  frameworkPhrase,
+  groupRows,
+  matchEntries,
+  originLabel,
+  routeRow,
+  type EntryRow,
+} from '../ui/src/lib/entry-model';
+import type {
+  WireEntryFile,
+  WireEntryHub,
+  WireEntryPoints,
+  WireEntryRoute,
+  WireEntryTest,
+  WireNodeRef,
+} from '../ui/src/lib/api';
+
+/* ------------------------------------------------------------- fixtures -- */
+
+function ref(over: Partial<WireNodeRef> = {}): WireNodeRef {
+  return {
+    id: 'function:x',
+    name: 'x',
+    kind: 'function',
+    qualifiedName: 'x',
+    file: 'src/x.ts',
+    line: 1,
+    endLine: 2,
+    language: 'typescript',
+    signature: null,
+    exported: true,
+    generated: false,
+    test: false,
+    ...over,
+  } as WireNodeRef;
+}
+
+function route(over: Partial<WireEntryRoute> = {}): WireEntryRoute {
+  return {
+    url: 'POST /v1/payroll/cycles/{cycleID}/run',
+    method: 'POST',
+    path: '/v1/payroll/cycles/{cycleID}/run',
+    handler: 'RunCycle',
+    handlerKind: 'method',
+    file: 'internal/transport/httpapi/payroll_handler.go',
+    line: 34,
+    handlerId: 'method:RunCycle',
+    routeFile: 'internal/transport/httpapi/router.go',
+    routeLine: 9,
+    routeId: 'route:router.go:9:POST:/v1/payroll/cycles/{cycleID}/run',
+    ...over,
+  };
+}
+
+function file(over: Partial<WireEntryFile> = {}): WireEntryFile {
+  return {
+    ...ref({ id: 'file:src/bin/cli.ts', kind: 'file', name: 'cli.ts', file: 'src/bin/cli.ts' }),
+    calls: 9,
+    reaches: 37,
+    dependents: 3,
+    ...over,
+  } as WireEntryFile;
+}
+
+function test(over: Partial<WireEntryTest> = {}): WireEntryTest {
+  return {
+    ...ref({
+      id: 'file:__tests__/a.test.ts',
+      kind: 'file',
+      name: 'a.test.ts',
+      file: '__tests__/a.test.ts',
+    }),
+    reaches: 12,
+    refs: 40,
+    ...over,
+  } as WireEntryTest;
+}
+
+function hub(over: Partial<WireEntryHub> = {}): WireEntryHub {
+  return {
+    ...ref({ id: 'interface:Node', name: 'Node', kind: 'interface', file: 'src/types.ts', line: 42 }),
+    dependents: 264,
+    ...over,
+  } as WireEntryHub;
+}
+
+function payload(over: Partial<WireEntryPoints> = {}): WireEntryPoints {
+  return {
+    frameworks: ['go'],
+    routes: {
+      routed: true,
+      routeCount: 4,
+      items: { total: 2, shown: 2, truncated: false, items: [route(), route({
+        url: 'GET /healthz',
+        method: 'GET',
+        path: '/healthz',
+        handler: 'health',
+        handlerKind: 'function',
+        file: 'internal/transport/httpapi/router.go',
+        line: 16,
+        handlerId: 'function:health',
+        routeLine: 12,
+        routeId: 'route:router.go:12:GET:/healthz',
+      })] },
+    },
+    files: { total: 92, shown: 1, truncated: true, items: [file()] },
+    tests: { total: 1, shown: 1, truncated: false, items: [test()] },
+    hubs: { total: 351, shown: 1, truncated: true, items: [hub()] },
+    index: { lastIndexedAt: 1, files: 20 },
+    timing: { elapsedMs: 3, cached: false },
+    ...over,
+  } as WireEntryPoints;
+}
+
+/* ---------------------------------------------------------------- panel -- */
+
+describe('the entry-points panel', () => {
+  it('draws every section it has data for, in reading order', () => {
+    const panel = buildEntryPanel(payload());
+    expect(panel.sections.map((s) => s.id)).toEqual(['routes', 'files', 'tests', 'hubs']);
+    expect(panel.sections.map((s) => s.title)).toEqual([
+      'Routes',
+      'Top-level files with calls',
+      'Tests',
+      'Most depended on',
+    ]);
+  });
+
+  it('keeps `rows` exactly the sections it draws', () => {
+    const panel = buildEntryPanel(payload());
+    expect(panel.rows).toEqual(panel.sections.flatMap((s) => s.groups.flatMap((g) => g.rows)));
+    expect(panel.rows).toHaveLength(5);
+  });
+
+  it('names the framework beside the route count', () => {
+    const panel = buildEntryPanel(payload());
+    expect(panel.sections[0]?.meta).toBe('2 · go');
+  });
+
+  it('groups routes by where they are REGISTERED, not where they are served', () => {
+    const panel = buildEntryPanel(payload());
+    const routes = panel.sections[0];
+    // Two routes served from two different files, one router.
+    expect(routes?.groups).toHaveLength(1);
+    expect(routes?.groups[0]?.path).toBe('internal/transport/httpapi/router.go');
+    expect(routes?.groups[0]?.file).toBe('internal/transport/httpapi/router.go');
+  });
+
+  it('says a list was cut, and whether the total is a floor', () => {
+    const panel = buildEntryPanel(payload());
+    expect(panel.sections.find((s) => s.id === 'files')?.meta).toBe('1 of at least 92');
+    expect(panel.sections.find((s) => s.id === 'tests')?.meta).toBe('1');
+    expect(panel.sections.find((s) => s.id === 'hubs')?.floor).toBe(true);
+    expect(panel.sections.find((s) => s.id === 'tests')?.floor).toBe(false);
+  });
+
+  it('draws no Routes heading when the project is not a routed app', () => {
+    const panel = buildEntryPanel(
+      payload({
+        routes: { routed: false, routeCount: 0, items: { total: 0, shown: 0, truncated: false, items: [] } },
+      })
+    );
+    // The fallback is the point: an empty box under a heading reads as a
+    // failure, and a library legitimately has no routes.
+    expect(panel.sections.map((s) => s.id)).toEqual(['files', 'tests', 'hubs']);
+    expect(panel.empty).toBeNull();
+  });
+
+  it('says what is missing when there is nothing at all', () => {
+    const panel = buildEntryPanel(
+      payload({
+        routes: { routed: false, routeCount: 0, items: { total: 0, shown: 0, truncated: false, items: [] } },
+        files: { total: 0, shown: 0, truncated: false, items: [] },
+        tests: { total: 0, shown: 0, truncated: false, items: [] },
+        hubs: { total: 0, shown: 0, truncated: false, items: [] },
+      })
+    );
+    expect(panel.sections).toEqual([]);
+    expect(panel.empty).toMatch(/no routes/);
+  });
+
+  it('draws nothing at all before the answer arrives', () => {
+    const panel = buildEntryPanel(null);
+    expect(panel.sections).toEqual([]);
+    // Not an "empty" message: nothing is known yet, and saying "this index has
+    // nothing" while the request is in flight would be a claim, not a state.
+    expect(panel.empty).toBeNull();
+  });
+});
+
+/* ------------------------------------------------------------------ rows -- */
+
+describe('an entry-point row', () => {
+  it('leads a route with its verb and names the handler in the meta', () => {
+    const row = routeRow(route());
+    expect(row.method).toBe('POST');
+    expect(row.name).toBe('/v1/payroll/cycles/{cycleID}/run');
+    expect(row.meta).toBe('RunCycle · payroll_handler.go:34');
+    expect(row.title).toContain('registered at internal/transport/httpapi/router.go:9');
+  });
+
+  it('keeps an unplaceable route but does not pretend it opens', () => {
+    const row = routeRow(route({ handlerId: null }));
+    expect(row.target).toBeNull();
+    expect(row.flowFrom).toBeNull();
+    expect(row.meta).toBe('RunCycle · not in the index');
+  });
+
+  it('offers a flow only from a row that names a callable symbol', () => {
+    const panel = buildEntryPanel(payload());
+    const byId = (id: string) => panel.sections.find((s) => s.id === id);
+    expect(byId('routes')?.groups[0]?.rows[0]?.flowFrom).toBe('RunCycle');
+    expect(byId('hubs')?.groups[0]?.rows[0]?.flowFrom).toBe('Node');
+    // A file has no name `/api/flow` can look up; a chip here would always fail.
+    expect(byId('files')?.groups[0]?.rows[0]?.flowFrom).toBeNull();
+    expect(byId('tests')?.groups[0]?.rows[0]?.flowFrom).toBeNull();
+  });
+
+  it('sends a file row to the File view and a symbol row to the symbol', () => {
+    const panel = buildEntryPanel(payload());
+    expect(panel.sections.find((s) => s.id === 'files')?.groups[0]?.rows[0]?.target).toEqual({
+      type: 'file',
+      path: 'src/bin/cli.ts',
+    });
+    expect(panel.sections.find((s) => s.id === 'hubs')?.groups[0]?.rows[0]?.target).toEqual({
+      type: 'symbol',
+      id: 'interface:Node',
+      name: 'Node',
+      kind: 'interface',
+    });
+  });
+
+  it('says when nothing imports an executable file', () => {
+    const panel = buildEntryPanel(
+      payload({ files: { total: 1, shown: 1, truncated: false, items: [file({ dependents: 0 })] } })
+    );
+    expect(panel.sections.find((s) => s.id === 'files')?.groups[0]?.rows[0]?.meta).toBe(
+      '9 calls at module level · reaches 37 files · nothing imports it'
+    );
+  });
+});
+
+/* -------------------------------------------------------------- grouping -- */
+
+describe('grouping', () => {
+  it('folds by path in first-seen order, so the ranking stays visible', () => {
+    const row = (id: string): EntryRow => ({
+      id,
+      name: id,
+      method: null,
+      meta: '',
+      kind: 'file',
+      target: null,
+      flowFrom: null,
+      title: id,
+    });
+    const groups = groupRows([
+      { row: row('b1'), path: 'b', file: null },
+      { row: row('a1'), path: 'a', file: null },
+      { row: row('b2'), path: 'b', file: null },
+    ]);
+    expect(groups.map((g) => g.path)).toEqual(['b', 'a']);
+    expect(groups[0]?.rows.map((r) => r.id)).toEqual(['b1', 'b2']);
+  });
+
+  it('names the directory, or the project root', () => {
+    expect(directoryOf('src/bin/cli.ts')).toBe('src/bin');
+    expect(directoryOf('package.json')).toBe('project root');
+  });
+});
+
+/* --------------------------------------------------------------- palette -- */
+
+describe('entry points under a typed query', () => {
+  it('matches on anything the row draws, including the handler', () => {
+    const matches = matchEntries(payload(), 'runcycle', 6);
+    expect(matches).toHaveLength(1);
+    expect(matches[0]?.origin).toBe('route');
+    expect(matches[0]?.row.name).toBe('/v1/payroll/cycles/{cycleID}/run');
+  });
+
+  it('matches a URL a search for the path would find, and a verb one would not', () => {
+    expect(matchEntries(payload(), 'healthz', 6)).toHaveLength(1);
+    expect(matchEntries(payload(), 'post ', 6)).toHaveLength(1);
+  });
+
+  it('honours the cap and answers nothing for an empty query', () => {
+    expect(matchEntries(payload(), '', 6)).toEqual([]);
+    expect(matchEntries(null, 'x', 6)).toEqual([]);
+    expect(matchEntries(payload(), '.', 1)).toHaveLength(1);
+  });
+
+  it('says where each match came from', () => {
+    expect(originLabel('route')).toBe('route');
+    expect(originLabel('file')).toBe('runs at module level');
+    expect(originLabel('test')).toBe('test');
+    expect(originLabel('hub')).toBe('depended on');
+  });
+});
+
+/* ------------------------------------------------------------------ flow -- */
+
+describe('starting a flow from a row', () => {
+  it('refuses a pair that is not a question', () => {
+    expect(flowPair('RunCycle', '')).toBeNull();
+    expect(flowPair('', 'Upsert')).toBeNull();
+    // `/api/flow` refuses this with a 400; disabling the button is kinder.
+    expect(flowPair('Upsert', 'upsert')).toBeNull();
+  });
+
+  it('trims what was typed', () => {
+    expect(flowPair('  RunCycle ', ' Upsert ')).toEqual({ from: 'RunCycle', to: 'Upsert' });
+  });
+});
+
+describe('naming the frameworks', () => {
+  it('reads as a sentence, however many there are', () => {
+    expect(frameworkPhrase([])).toBe('');
+    expect(frameworkPhrase(['gin'])).toBe('gin');
+    expect(frameworkPhrase(['gin', 'spring'])).toBe('gin and spring');
+    expect(frameworkPhrase(['gin', 'spring', 'rails'])).toBe('gin, spring and rails');
+  });
+});

+ 393 - 0
__tests__/ui-entrypoints-api.test.ts

@@ -0,0 +1,393 @@
+/**
+ * `GET /api/entrypoints` and the panel it draws (CG-54).
+ *
+ * Two indexed projects over two real loopback servers, because the two answers
+ * this endpoint has to get right are opposites:
+ *
+ * - **A routed service.** `__tests__/fixtures/payroll-go` is a Go HTTP service
+ *   whose four routes are registered in one router file and served from
+ *   another, which is exactly the shape that makes "group routes by file"
+ *   ambiguous — and the reason the payload carries the registration site as
+ *   well as the handler. It is also the issue's acceptance case: the routes
+ *   appear with their handlers, and the route's own handler reaches the store
+ *   as a flow.
+ * - **A library.** A TypeScript project with no routes at all, where the panel
+ *   must fall back to the files that run something and the tests that exercise
+ *   them, and must NOT draw an empty Routes box: "this isn't a web app" is an
+ *   answer, not a failure.
+ *
+ * The grouping itself is pure and lives in `ui/src/lib/entry-model.ts`; it is
+ * driven here from the real payload so a wire change that the pure tests would
+ * happily keep passing still fails somewhere.
+ */
+
+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 { resetEntryPointsCache } from '../src/ui-server/api/entrypoints';
+import { splitRouteName } from '../src/ui-server/api/routes';
+import { isTestFile, isTestPath } from '../src/search/query-utils';
+import { buildEntryPanel, frameworkPhrase } from '../ui/src/lib/entry-model';
+import type { WireEntryPoints } from '../ui/src/lib/api';
+
+const FIXTURE_GO = path.join(__dirname, 'fixtures', 'payroll-go');
+
+interface Instance {
+  dir: string;
+  root: string;
+  cg: CodeGraph;
+  api: GraphApi;
+  server: UiServerHandle;
+}
+
+function request(port: number, requestPath: string): Promise<{ status: number; body: string; type?: string }> {
+  return new Promise((resolve, reject) => {
+    const req = http.request(
+      {
+        host: '127.0.0.1',
+        port,
+        path: requestPath,
+        method: 'GET',
+        headers: { Host: `127.0.0.1:${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 getJson(instance: Instance, requestPath: string, expected = 200): Promise<any> {
+  const res = await request(instance.server.port, requestPath);
+  expect(res.type).toBe('application/json; charset=utf-8');
+  expect(res.status).toBe(expected);
+  return JSON.parse(res.body);
+}
+
+async function serve(root: string, dir: string, cg: CodeGraph): Promise<Instance> {
+  const api = createGraphApi({ projectRoot: root });
+  const server = await startUiServer({ projectRoot: root, port: 0, api: api.handler });
+  return { dir, root, cg, api, server };
+}
+
+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);
+}
+
+async function stop(instance: Instance | undefined): Promise<void> {
+  if (!instance) return;
+  await instance.server.close();
+  instance.api.close();
+  instance.cg.destroy();
+  fs.rmSync(instance.dir, { recursive: true, force: true });
+}
+
+/* ======================================================================== */
+/* A routed Go service — the issue's acceptance case                        */
+/* ======================================================================== */
+
+describe('entry points on a routed service', () => {
+  let go: Instance;
+  let payload: WireEntryPoints;
+
+  beforeAll(async () => {
+    resetEntryPointsCache();
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-entry-go-'));
+    fs.cpSync(FIXTURE_GO, dir, { recursive: true });
+    // A stray index in the checked-in tree would be copied in and reused.
+    fs.rmSync(path.join(dir, '.codegraph'), { recursive: true, force: true });
+
+    const cg = CodeGraph.initSync(dir);
+    await cg.indexAll();
+    go = await serve(dir, dir, cg);
+    payload = (await getJson(go, '/api/entrypoints')) as WireEntryPoints;
+  }, 120_000);
+
+  afterAll(async () => {
+    await stop(go);
+  });
+
+  it('names the framework the route list came from', () => {
+    expect(payload.frameworks).toContain('go');
+    expect(frameworkPhrase(payload.frameworks)).toContain('go');
+  });
+
+  it('lists every route with the symbol that serves it', () => {
+    expect(payload.routes.routed).toBe(true);
+    expect(payload.routes.routeCount).toBe(4);
+
+    const rows = payload.routes.items.items;
+    expect(rows).toHaveLength(4);
+    expect(rows.map((r) => r.url)).toEqual(
+      expect.arrayContaining([
+        'POST /v1/payroll/cycles/{cycleID}/run',
+        'GET /v1/payroll/cycles/{cycleID}',
+        'GET /v1/payroll/cycles/{cycleID}/payslips',
+        'GET /healthz',
+      ])
+    );
+
+    const run = rows.find((r) => r.url.startsWith('POST '));
+    expect(run).toBeDefined();
+    expect(run?.method).toBe('POST');
+    expect(run?.path).toBe('/v1/payroll/cycles/{cycleID}/run');
+    expect(run?.handler).toBe('RunCycle');
+    expect(run?.file).toBe('internal/transport/httpapi/payroll_handler.go');
+    // A row has to be navigable, or it is a label.
+    expect(run?.handlerId).toBeTruthy();
+    expect(rows.every((r) => r.handlerId)).toBe(true);
+  });
+
+  it('carries where each URL is registered, which is not where it is served', () => {
+    const rows = payload.routes.items.items;
+    // Every route is registered by NewRouter; three of the four are served
+    // from a different file. Without the registration site there is nothing
+    // to group four routes under.
+    expect(new Set(rows.map((r) => r.routeFile))).toEqual(
+      new Set(['internal/transport/httpapi/router.go'])
+    );
+    expect(new Set(rows.map((r) => r.file)).size).toBe(2);
+    expect(rows.every((r) => r.routeLine > 0)).toBe(true);
+  });
+
+  it('groups the panel by the router file, with the handler in the meta line', () => {
+    const panel = buildEntryPanel(payload);
+    const routes = panel.sections.find((s) => s.id === 'routes');
+    expect(routes).toBeDefined();
+    expect(routes?.groups).toHaveLength(1);
+    expect(routes?.groups[0]?.path).toBe('internal/transport/httpapi/router.go');
+    expect(routes?.groups[0]?.rows).toHaveLength(4);
+    // The framework rides in the section header, beside the count.
+    expect(routes?.meta).toContain('go');
+
+    const run = routes?.groups[0]?.rows.find((r) => r.method === 'POST');
+    expect(run?.name).toBe('/v1/payroll/cycles/{cycleID}/run');
+    expect(run?.meta).toBe('RunCycle · payroll_handler.go:34');
+    expect(run?.target).toEqual({
+      type: 'symbol',
+      id: expect.any(String),
+      name: 'RunCycle',
+      kind: 'method',
+    });
+    // A route names a callable symbol, so it can start a flow.
+    expect(run?.flowFrom).toBe('RunCycle');
+  });
+
+  it('draws the flow from a route handler down to the store', async () => {
+    // The issue's "route -> insertNode-style flow": the POST handler reaching
+    // the row that lands in the database.
+    const flow = await getJson(go, '/api/flow?from=RunCycle&to=Upsert');
+    expect(flow.flows.length).toBeGreaterThan(0);
+    const hops = flow.flows[0].hops.map((h: any) => h.node.name);
+    expect(hops[0]).toBe('RunCycle');
+    expect(hops[hops.length - 1]).toBe('Upsert');
+    expect(hops).toContain('runPayrollCycleAll');
+    // Every hop after the first carries the edge that got there.
+    expect(flow.flows[0].hops.slice(1).every((h: any) => h.edge)).toBe(true);
+  });
+
+  it('answers a second time from the cache', async () => {
+    const again = await getJson(go, '/api/entrypoints');
+    expect(again.timing.cached).toBe(true);
+    expect(again.routes.items.items).toEqual(payload.routes.items.items);
+  });
+
+  it('refuses a route window it cannot answer truthfully', async () => {
+    // Under three rows the engine's own "is this routed" test cannot run, so
+    // the parameter is floored rather than silently answering "not routed".
+    const body = await getJson(go, '/api/entrypoints?routes=2', 400);
+    expect(body.error).toMatch(/routes/);
+  });
+});
+
+/* ======================================================================== */
+/* A library — no routes, and no empty Routes box                           */
+/* ======================================================================== */
+
+describe('entry points on a project with no routes', () => {
+  let lib: Instance;
+  let payload: WireEntryPoints;
+
+  beforeAll(async () => {
+    resetEntryPointsCache();
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-entry-lib-'));
+    const root = path.join(dir, 'project');
+    fs.mkdirSync(root, { recursive: true });
+
+    write(
+      root,
+      'src/store.ts',
+      `export function insertNode(name: string): string {
+  return name.trim();
+}
+
+export function readNode(name: string): string {
+  return insertNode(name);
+}
+`
+    );
+    // Module-level statements: the only reason an executable root is visible.
+    write(
+      root,
+      'src/main.ts',
+      `import { insertNode, readNode } from './store';
+
+const first = insertNode('boot');
+const second = readNode('warm');
+
+export const started = [first, second];
+`
+    );
+    write(
+      root,
+      '__tests__/store.test.ts',
+      `import { insertNode } from '../src/store';
+
+export function exercisesTheStore(): string {
+  return insertNode('x');
+}
+
+exercisesTheStore();
+`
+    );
+    // A fixture is not a test, even though the ranking treats it as one.
+    write(root, '__tests__/fixtures/sample.ts', `export const sample = 1;\n`);
+
+    const cg = CodeGraph.initSync(root, {
+      config: { include: ['src/**/*.ts', '__tests__/**/*.ts'], exclude: [] },
+    });
+    await cg.indexAll();
+    cg.resolveReferences();
+    lib = await serve(root, dir, cg);
+    payload = (await getJson(lib, '/api/entrypoints')) as WireEntryPoints;
+  }, 120_000);
+
+  afterAll(async () => {
+    await stop(lib);
+  });
+
+  it('says it is not a routed app instead of drawing an empty list', () => {
+    expect(payload.routes.routed).toBe(false);
+    expect(payload.routes.items.items).toEqual([]);
+    expect(payload.routes.items.total).toBe(0);
+
+    const panel = buildEntryPanel(payload);
+    // No Routes heading at all — an empty box under a heading reads as a
+    // failure, and this is the ordinary shape of a library.
+    expect(panel.sections.map((s) => s.id)).not.toContain('routes');
+    // …and the panel is not empty: it fell back to what does exist.
+    expect(panel.empty).toBeNull();
+    expect(panel.sections.length).toBeGreaterThan(0);
+  });
+
+  it('falls back to the file that runs something at module level', () => {
+    const files = payload.files.items.map((f) => f.file);
+    expect(files).toContain('src/main.ts');
+    expect(files).not.toContain('__tests__/store.test.ts');
+
+    const main = payload.files.items.find((f) => f.file === 'src/main.ts');
+    expect(main?.calls).toBeGreaterThan(0);
+    expect(main?.reaches).toBeGreaterThan(0);
+
+    const panel = buildEntryPanel(payload);
+    const section = panel.sections.find((s) => s.id === 'files');
+    expect(section?.title).toBe('Top-level files with calls');
+    expect(section?.groups[0]?.path).toBe('src');
+    // A file has no name the path finder can look up, so no flow chip.
+    expect(section?.groups[0]?.rows.every((r) => r.flowFrom === null)).toBe(true);
+    expect(section?.groups[0]?.rows[0]?.target).toEqual({ type: 'file', path: 'src/main.ts' });
+  });
+
+  it('lists the tests by what they exercise', () => {
+    const tests = payload.tests.items.map((t) => t.file);
+    expect(tests).toContain('__tests__/store.test.ts');
+    // A fixture reaches nothing and is not a test; either reason keeps it out.
+    expect(tests).not.toContain('__tests__/fixtures/sample.ts');
+
+    const suite = payload.tests.items.find((t) => t.file === '__tests__/store.test.ts');
+    expect(suite?.reaches).toBeGreaterThan(0);
+    expect(suite?.refs).toBeGreaterThanOrEqual(suite?.reaches ?? 0);
+
+    const panel = buildEntryPanel(payload);
+    const section = panel.sections.find((s) => s.id === 'tests');
+    expect(section?.title).toBe('Tests');
+    expect(section?.groups[0]?.rows[0]?.meta).toMatch(/^exercises \d+ files? · \d+ references?$/);
+  });
+
+  it('counts the tests exactly, and the derived lists as a floor', () => {
+    // Every count equals a list in the same payload, or is labelled a floor.
+    expect(payload.tests.total).toBe(payload.tests.items.length);
+    expect(payload.files.total).toBeGreaterThanOrEqual(payload.files.items.length);
+    expect(payload.hubs.total).toBeGreaterThanOrEqual(payload.hubs.items.length);
+
+    const panel = buildEntryPanel(payload);
+    expect(panel.sections.find((s) => s.id === 'tests')?.floor).toBe(false);
+    expect(panel.sections.find((s) => s.id === 'files')?.floor).toBe(true);
+  });
+});
+
+/* ======================================================================== */
+/* The narrow test predicate                                                */
+/* ======================================================================== */
+
+describe('what counts as a test', () => {
+  it('keeps the suites and drops the examples', () => {
+    for (const suite of [
+      'foo_test.go',
+      'src/foo.test.ts',
+      'src/__tests__/foo.ts',
+      'test/foo.rb',
+      'src/FooTest.java',
+      'app/src/jvmTest/Bar.kt',
+    ]) {
+      expect(isTestPath(suite), suite).toBe(true);
+      expect(isTestFile(suite), suite).toBe(true);
+    }
+
+    // Examples, benchmarks and fixtures are still off-target for RANKING —
+    // nothing about this change moves that — but they are not tests, and a
+    // heading that says "Tests" must not gather them.
+    for (const other of ['examples/demo.ts', 'benchmarks/run.ts', 'fixtures/a.ts']) {
+      expect(isTestFile(other), other).toBe(true);
+      expect(isTestPath(other), other).toBe(false);
+    }
+  });
+});
+
+/* ======================================================================== */
+/* Route names                                                              */
+/* ======================================================================== */
+
+describe('splitting a route name', () => {
+  it('takes the verb off when there is one', () => {
+    expect(splitRouteName('POST /v1/users')).toEqual({ method: 'POST', path: '/v1/users' });
+    expect(splitRouteName('ANY /healthz')).toEqual({ method: 'ANY', path: '/healthz' });
+  });
+
+  it('leaves a file-routed page whole', () => {
+    // A verb column invented out of the first path segment would be a lie, and
+    // the URL would lose its head.
+    expect(splitRouteName('/blog/[slug]')).toEqual({ method: null, path: '/blog/[slug]' });
+    expect(splitRouteName('user.created handler')).toEqual({
+      method: null,
+      path: 'user.created handler',
+    });
+  });
+});

+ 90 - 10
__tests__/ui-search-model.test.ts

@@ -186,7 +186,15 @@ describe('the palette', () => {
 
 function entryPoints(over: Partial<WireEntryPoints> = {}): WireEntryPoints {
   return {
-    routes: { routed: false, routeCount: 0, items: [] },
+    frameworks: [],
+    routes: {
+      routed: false,
+      routeCount: 0,
+      items: { total: 0, shown: 0, truncated: false, items: [] },
+    },
+    tests: { total: 0, shown: 0, truncated: false, items: [] },
+    index: { lastIndexedAt: null, files: 0 },
+    timing: { elapsedMs: 0, cached: false },
     files: {
       total: 2,
       shown: 2,
@@ -232,15 +240,26 @@ describe('the entry points', () => {
         routes: {
           routed: true,
           routeCount: 4,
-          items: [
-            {
-              url: 'GET /users',
-              handler: 'listUsers',
-              file: 'src/routes.ts',
-              line: 11,
-              handlerId: 'function:listUsers',
-            },
-          ],
+          items: {
+            total: 1,
+            shown: 1,
+            truncated: false,
+            items: [
+              {
+                url: 'GET /users',
+                method: 'GET',
+                path: '/users',
+                handler: 'listUsers',
+                handlerKind: 'function',
+                file: 'src/routes.ts',
+                line: 11,
+                handlerId: 'function:listUsers',
+                routeFile: 'src/routes.ts',
+                routeLine: 4,
+                routeId: 'route:src/routes.ts:4:GET:/users',
+              },
+            ],
+          },
         },
       })
     );
@@ -265,6 +284,67 @@ describe('the entry points', () => {
     expect(buildEntryPalette(many).items).toHaveLength(11);
   });
 
+  it('offers entry points under a typed query, BELOW the symbol matches', () => {
+    const entries = entryPoints({
+      routes: {
+        routed: true,
+        routeCount: 3,
+        items: {
+          total: 1,
+          shown: 1,
+          truncated: false,
+          items: [
+            {
+              url: 'POST /users',
+              method: 'POST',
+              path: '/users',
+              handler: 'createUser',
+              handlerKind: 'function',
+              file: 'src/handlers.ts',
+              line: 8,
+              handlerId: 'function:createUser',
+              routeFile: 'src/routes.ts',
+              routeLine: 4,
+              routeId: 'route:src/routes.ts:4:POST:/users',
+            },
+          ],
+        },
+      },
+    });
+
+    const palette = buildSearchPalette(
+      [answer([result({ id: 'class:Users', name: 'Users', kind: 'class' })])],
+      null,
+      { entries, query: 'users', entryRows: 6 }
+    );
+
+    // Symbol matches keep the top: someone typing a name asked for the name.
+    expect(palette.sections[0]?.title).toBe('Class');
+    const last = palette.sections[palette.sections.length - 1];
+    expect(last?.title).toBe('Entry points');
+    const row = last?.items[0];
+    expect(row?.type).toBe('entry');
+    // The row a plain search cannot produce: the URL WITH its handler.
+    expect(row?.name).toBe('POST /users');
+    expect(row?.meta).toBe('createUser · handlers.ts:8');
+    expect(row?.location).toBe('route');
+    // The keyboard's flat list still equals what is drawn.
+    expect(palette.items).toEqual(palette.sections.flatMap((s) => s.items));
+  });
+
+  it('does not repeat a symbol the search above already found', () => {
+    const hub = { ...result({ id: 'method:get', name: 'get' }), dependents: 264 };
+    const entries = entryPoints({
+      hubs: { total: 1, shown: 1, truncated: false, items: [hub] as any },
+    });
+    const palette = buildSearchPalette([answer([result({ id: 'method:get', name: 'get' })])], null, {
+      entries,
+      query: 'get',
+      entryRows: 6,
+    });
+    expect(palette.sections.map((s) => s.title)).not.toContain('Entry points');
+  });
+
   it('draws nothing at all before the answer arrives', () => {
     const palette = buildEntryPalette(null);
     expect(palette.sections).toEqual([]);

+ 3 - 3
__tests__/ui-server-api.test.ts

@@ -973,12 +973,12 @@ export default app;
 
       expect(body.routes.routed).toBe(true);
       expect(body.routes.routeCount).toBe(4);
-      const urls = body.routes.items.map((e: any) => e.url);
+      const urls = body.routes.items.items.map((e: any) => e.url);
       expect(urls).toEqual(
         expect.arrayContaining(['GET /users', 'GET /users/:id', 'POST /users', 'DELETE /users/:id'])
       );
       // A route row has to be navigable, or it is a label.
-      expect(body.routes.items.every((e: any) => e.handlerId)).toBe(true);
+      expect(body.routes.items.items.every((e: any) => e.handlerId)).toBe(true);
     });
 
     it('honours the limit and says when it cut the list', async () => {
@@ -1118,7 +1118,7 @@ describe('GET /api/entrypoints', () => {
   it('says a project without routes is not routed rather than failing', async () => {
     const body = await getJson('/api/entrypoints');
     expect(body.routes.routed).toBe(false);
-    expect(body.routes.items).toEqual([]);
+    expect(body.routes.items.items).toEqual([]);
     expect(body.routes.routeCount).toBe(0);
   });
 

+ 25 - 3
docs/design/codegraph-ui-design-spec.md

@@ -235,9 +235,31 @@ its name column already carries the path, and printing it twice reads as an erro
 
 At rest — an empty box, or the empty screen — the panel shows **entry points** from
 `/api/entrypoints`: routes (URL → handler), files that run something at module level (a CLI, a
-worker entry, a script — ranked by calls × the number of other files they reach), and the most
-depended-on symbols. Each section says what it is derived from, never that a file IS the entry
-point.
+worker entry, a script — ranked by calls × the number of other files they reach), tests (ranked by
+how many other files each reaches), and the most depended-on symbols. Each section says what it is
+derived from, never that a file IS the entry point.
+
+**Entry points as a screen (CG-54, `#/entry`).** The same payload at full length, drawn with the
+caller rail's file-group + row shapes (`.filegroup` padding `10px 14px 4px`, path 11px mono
+`--ink-3` with the count in `--ink-2`; rows grid `16px | 1fr`, name 12.5px mono, meta 11px
+`--ink-3`), section headings 600 15px sentence-case with the count — and the detected framework —
+as 11.5px `--ink-3` meta beside them. Sections: **Routes** (verb ahead of the URL in the same
+mono at weight 500, handler + `file:line` in the meta, grouped by the file the URL is REGISTERED
+in), **Top-level files with calls**, **Tests**, **Most depended on**. A section whose list was cut
+prints "Showing N of \[at least] M"; "at least" is the honest reading wherever the server's count
+is a floor.
+
+A row that names a callable symbol carries a `Flow ›` chip (11px mono, `--rule-soft` border) that
+arms a flow from it; the panel then shows an `--accent-soft` bar with the name, an input, and
+`Draw the flow`, while every other armed-eligible row's chip becomes `→ here`. File and test rows
+carry no chip — `/api/flow` searches by NAME, and a file has none the path finder can look up.
+A project with fewer than three resolvable routes gets **no Routes heading at all**, not an empty
+one.
+
+In the search palette, entry points that mention the query appear **last**, under their own
+`Entry points` heading (12px `--ink-3`, like every other group): they are context on rows the
+search above may already have found, and a route row here names its HANDLER, which a `/api/search`
+hit on the same URL cannot. Rows whose target is already in the results are dropped.
 
 ### 3.8 Drift banner and live refresh (CG-53)
 Drift banner: full-width block above the code, `--paper-2` fill, 1px `--rule-soft` border, padding `8px 12px`, 12.5px `--ink-2`, leading

+ 13 - 1
site/src/content/docs/guides/viewer.md

@@ -46,12 +46,24 @@ If the viewer ever loses touch with the server, it retries a handful of times wi
 ## Getting around
 
 - **Search** with `/` or Cmd-K: every symbol and file, grouped by kind, with signature and `file:line`. Arrow keys and Enter, no mouse needed.
-- **Entry points** on the opening screen: your framework's routes, the files that run code when they're imported, and the most depended-on symbols in the project.
+- **Entry points** on the opening screen, and in full on the **Entry points** tab (`e`) — see below.
+- **Typing a name also finds entry points.** They come back under their own heading below the symbol matches, so searching `payroll` returns the URL *with* the symbol that serves it, not just the URL.
 - **A trail** records the path you walked, with an arrow per hop showing whether you stepped into a call or up to a caller. Click any hop to jump back to it. The trail lives in the URL, so you can send someone the exact route you took.
 - **Keyboard:** arrow keys move within a column, left/right switch columns, Enter follows, Backspace steps back.
 
 Clicking any file path opens the **file view**: everything that file depends on, its outline in source order, and everything that depends on it.
 
+## Entry points
+
+The first screen worth opening on a codebase you have never seen. Four lists, all read out of the graph rather than guessed from filenames:
+
+- **Routes** — every URL with the symbol that serves it and the `file:line` you will find it at, grouped by the file the route is *registered* in (your router, not your handlers) and headed with the framework CodeGraph detected. A project with fewer than three routes is not a routed app, so this section is simply absent rather than empty.
+- **Top-level files with calls** — the files that *do* something when they load: a CLI, a worker entry, a build script. That is a fact about the graph (a statement outside every definition is recorded as a call from the file itself), not a guess about a filename, which is why a library module correctly shows nothing.
+- **Tests** — the other direction: what already exercises this code, widest reach first.
+- **Most depended on** — not where the project starts, but where a change radiates furthest.
+
+Every row opens the code. Every row that names a symbol also carries a **Flow ›** chip: press it, then name a second symbol — type it, or press **→ here** on another row — and you get the path between them. "How does `POST /v1/payroll/cycles/{cycleID}/run` reach the database" is two clicks once both ends are on the screen.
+
 ## The whole file
 
 The **Source** tab on that screen replaces the outline with the file itself, top to bottom, with the same gutter markers and the same right-hand list of what each line calls — a 6,800-line file scrolls as smoothly as a 60-line one, and the text pages in behind you.

+ 4 - 0
src/bin/codegraph.ts

@@ -1889,6 +1889,10 @@ box for the flow between two symbols: one card per hop, opened at the line that
 makes the next call, with dynamic-dispatch hops drawn dashed and named. The Map
 tab draws the whole project by module, with dependencies pointing down.
 
+Never opened this codebase before? The Entry points tab lists the routes with
+the symbols that serve them, the files that run something when they load, the
+tests, and what the most code depends on — and starts a flow from any of them.
+
 The page keeps up with the project while it is open: save a file and it says so
 within about a third of a second, and whatever is on screen re-reads the graph
 when something re-indexes it. It watches for that; it never polls.

+ 71 - 2
src/db/queries.ts

@@ -1024,7 +1024,17 @@ export class QueryBuilder {
    * mapping AND the handler implementations.
    */
   getRoutingManifest(limit: number = 40): {
-    entries: Array<{ url: string; handler: string; handlerFile: string; handlerLine: number; handlerKind: string }>;
+    entries: Array<{
+      url: string;
+      handler: string;
+      handlerFile: string;
+      handlerLine: number;
+      handlerKind: string;
+      /** The route node itself: where the URL is REGISTERED, not where it is served. */
+      routeId: string;
+      routeFile: string;
+      routeLine: number;
+    }>;
     topHandlerFile: string | null;
     topHandlerFileCount: number;
     totalRoutes: number;
@@ -1036,6 +1046,9 @@ export class QueryBuilder {
       this.stmts.getRoutingManifest = this.db.prepare(`
         SELECT
           r.name AS url,
+          r.id AS route_id,
+          r.file_path AS route_file,
+          r.start_line AS route_line,
           h.name AS handler,
           h.file_path AS handler_file,
           h.start_line AS handler_line,
@@ -1051,7 +1064,8 @@ export class QueryBuilder {
       `);
     }
     const rows = this.stmts.getRoutingManifest.all(limit) as Array<{
-      url: string; handler: string; handler_file: string; handler_line: number; handler_kind: string;
+      url: string; route_id: string; route_file: string; route_line: number;
+      handler: string; handler_file: string; handler_line: number; handler_kind: string;
     }>;
     // Drop test/generated handlers — same hygiene as elsewhere.
     const generated = this.getGeneratedPathsAmong(rows.map(r => r.handler_file));
@@ -1077,6 +1091,9 @@ export class QueryBuilder {
         handlerFile: r.handler_file,
         handlerLine: r.handler_line,
         handlerKind: r.handler_kind,
+        routeId: r.route_id,
+        routeFile: r.route_file,
+        routeLine: r.route_line,
       })),
       topHandlerFile,
       topHandlerFileCount,
@@ -2076,6 +2093,58 @@ export class QueryBuilder {
       .all(JSON.stringify(filePaths)) as Array<{ filePath: string; dependents: number }>;
   }
 
+  /**
+   * How far each of the given files reaches OUT: distinct other files its
+   * symbols touch, and how many references that is.
+   *
+   * The mirror of {@link getFileDependentCounts}, and the same reasoning about
+   * `contains` and same-file edges applies. It is driven from `nodes` rather
+   * than from `edges` so the work is proportional to the files asked about —
+   * the entry-points endpoint asks it about every test file in the index, and
+   * an edge-first plan would scan the whole table to answer a question about a
+   * tenth of it.
+   */
+  getFileReachCounts(filePaths: string[]): Array<{ filePath: string; reaches: number; refs: number }> {
+    if (filePaths.length === 0) return [];
+    return this.db
+      .prepare(
+        `SELECT sn.file_path AS filePath,
+                COUNT(DISTINCT tn.file_path) AS reaches,
+                COUNT(*) AS refs
+           FROM nodes sn
+           JOIN edges e ON e.source = sn.id
+           JOIN nodes tn ON tn.id = e.target
+          WHERE sn.file_path IN (SELECT value FROM json_each(?))
+            AND e.kind != 'contains'
+            AND tn.file_path <> sn.file_path
+       GROUP BY sn.file_path`
+      )
+      .all(JSON.stringify(filePaths)) as Array<{
+      filePath: string;
+      reaches: number;
+      refs: number;
+    }>;
+  }
+
+  /**
+   * The `file` nodes for the given paths, in one query.
+   *
+   * A file's own node is what makes a file row navigable, and looking it up
+   * with {@link getNodesInFile} means materialising every symbol in the file to
+   * throw all but one away.
+   */
+  getFileNodes(filePaths: string[]): Node[] {
+    if (filePaths.length === 0) return [];
+    const rows = this.db
+      .prepare(
+        `SELECT * FROM nodes
+          WHERE kind = 'file'
+            AND file_path IN (SELECT value FROM json_each(?))`
+      )
+      .all(JSON.stringify(filePaths)) as NodeRow[];
+    return rows.map(rowToNode);
+  }
+
   /**
    * Roll the whole edge table up to module granularity in one pass.
    *

+ 28 - 1
src/index.ts

@@ -1437,6 +1437,24 @@ export class CodeGraph {
     );
   }
 
+  /**
+   * How far each of the given files reaches out: distinct other files their
+   * symbols touch, and how many references that is. The mirror of
+   * {@link getFileDependentCounts}; a test file's reach is what it exercises.
+   */
+  getFileReachCounts(filePaths: string[]): Map<string, { reaches: number; refs: number }> {
+    return new Map(
+      this.queries
+        .getFileReachCounts(filePaths)
+        .map((row) => [row.filePath, { reaches: row.reaches, refs: row.refs }])
+    );
+  }
+
+  /** The `file` nodes for the given paths, in one query. */
+  getFileNodes(filePaths: string[]): Node[] {
+    return this.queries.getFileNodes(filePaths);
+  }
+
   /**
    * Roll the edge table up to module granularity, for a file → module
    * assignment the caller decides.
@@ -1725,7 +1743,16 @@ export class CodeGraph {
    * null when fewer than 3 valid (non-test) routes exist.
    */
   getRoutingManifest(limit?: number): {
-    entries: Array<{ url: string; handler: string; handlerFile: string; handlerLine: number; handlerKind: string }>;
+    entries: Array<{
+      url: string;
+      handler: string;
+      handlerFile: string;
+      handlerLine: number;
+      handlerKind: string;
+      routeId: string;
+      routeFile: string;
+      routeLine: number;
+    }>;
     topHandlerFile: string | null;
     topHandlerFileCount: number;
     totalRoutes: number;

+ 22 - 4
src/search/query-utils.ts

@@ -286,8 +286,29 @@ export function scorePathRelevance(
 
 /**
  * Check if a file path looks like a test file
+ *
+ * "Test" here is the wide reading: anything that is not production code,
+ * including examples, samples, benchmarks and fixtures. That is the right
+ * default for ranking — none of them are what a search is looking for — but it
+ * is the wrong set to put under a heading that says "Tests". A caller that
+ * means literally a test suite wants {@link isTestPath}.
  */
 export function isTestFile(filePath: string): boolean {
+  // Non-production directories: examples, samples, benchmarks, fixtures, demos.
+  // Check both mid-path (/integration/) and start-of-path (integration/) since
+  // file paths may be stored as relative paths without a leading slash.
+  return isTestPath(filePath) || matchesNonProductionDir(filePath.toLowerCase());
+}
+
+/**
+ * Check if a file path names a TEST — a suite that exercises other code.
+ *
+ * The narrow half of {@link isTestFile}: the filename and directory
+ * conventions every ecosystem uses for its test suites, and nothing else. An
+ * example, a benchmark or a fixture is not a test, and a list headed "Tests"
+ * that contains them is telling the reader something untrue.
+ */
+export function isTestPath(filePath: string): boolean {
   const lower = filePath.toLowerCase();
   const fileName = path.basename(filePath);   // original case — needed for camelCase boundaries
   const lowerName = fileName.toLowerCase();
@@ -322,10 +343,7 @@ export function isTestFile(filePath: string): boolean {
     return true;
   }
 
-  // Non-production directories: examples, samples, benchmarks, fixtures, demos.
-  // Check both mid-path (/integration/) and start-of-path (integration/) since
-  // file paths may be stored as relative paths without a leading slash.
-  return matchesNonProductionDir(lower);
+  return false;
 }
 
 /**

+ 180 - 22
src/ui-server/api/entrypoints.ts

@@ -1,15 +1,17 @@
 /**
  * `GET /api/entrypoints` — where to start reading a project you have never
- * opened.
+ * opened, and where a flow starts.
  *
- * The empty state and the resting search palette both have the same problem:
- * a graph of thirteen thousand symbols and no obvious door. Three answers,
- * every one of them derived from the graph rather than from a filename
- * convention:
+ * The empty state, the resting search palette and the entry-points panel all
+ * have the same problem: a graph of thirteen thousand symbols and no obvious
+ * door. Four answers, every one of them derived from the graph rather than
+ * from a filename convention:
  *
  * - **Routes** — a request arriving from outside is the most literal entry a
  *   codebase has. Straight from the routing manifest (`/api/routes`), and
- *   absent for a project that is not a routed app.
+ *   absent for a project that is not a routed app. Carried with the file the
+ *   URL is REGISTERED in as well as the one that serves it, because a router
+ *   file is how a reader groups routes and the two are rarely the same file.
  * - **Files that run something** — the engine records a statement at the top
  *   level of a file as an edge out of the *file* node, so a CLI, a worker
  *   entry or a build script has `calls` where a library module has none. That
@@ -17,24 +19,41 @@
  *   Ranked by calls x how many other files they reach, so the file that both
  *   runs and wires the project together outranks a registration table that
  *   makes a hundred module-level calls into itself.
+ * - **Tests** — the other direction: not where the project starts, but what
+ *   already exercises it. Ranked by how many other files a test reaches, so
+ *   the suites that cross the most of the codebase come first.
  * - **Hubs** — the most depended-on symbols. Not an entry in the "runs first"
  *   sense; an entry in the sense that reading one tells you the most about
  *   what the project is made of, and a change to one radiates furthest.
  *
- * Tests and fixtures are excluded from both derived lists. They are real code
- * with real callers, but "where do I start reading" never means a test.
+ * Tests and fixtures are excluded from the two *reading* lists — "where do I
+ * start reading" never means a test — and the Tests list is built from the
+ * narrow {@link isTestPath}, not from {@link isTestFile}: an example, a
+ * benchmark or a fixture is not a test, and a heading that says "Tests" must
+ * not be quietly counting them.
  */
 
 import type { CodeGraph } from '../../index';
 import type { Node, NodeKind } from '../../types';
 import { intParam } from './respond';
-import { buildRoutes } from './routes';
-import { isTestFile } from '../../search/query-utils';
-import { toNodeRef, wireList, type WireList, type WireNodeRef } from './wire';
+import { buildRoutes, type WireRoute } from './routes';
+import { isTestFile, isTestPath } from '../../search/query-utils';
+import { toNodeRef, toPosixPath, wireList, type WireList, type WireNodeRef } from './wire';
 
 /** Rows per derived list, and the default for `limit`. */
 const DEFAULT_LIMIT = 12;
 
+/**
+ * Route rows, and the default for `routes`.
+ *
+ * Separate from `limit` because routes are the one list whose useful length is
+ * set by the project rather than by the reader: a panel that groups 60 routes
+ * under four router files is legible, while 60 rows of "most depended on" is
+ * a wall. Both are honest — every list carries the real total.
+ */
+const DEFAULT_ROUTE_LIMIT = 60;
+const MAX_ROUTE_LIMIT = 300;
+
 /**
  * Ranked rows examined before the test filter and the per-directory cap run.
  *
@@ -56,6 +75,17 @@ const SCAN_ROWS = 400;
  */
 const MAX_FILES_PER_DIR = 2;
 
+/**
+ * Test files asked about per reach query.
+ *
+ * The reach query is driven from `nodes` by file path, so its cost is
+ * proportional to the files in the chunk rather than to the edge table — but a
+ * repo with ten thousand test files would still put ten thousand paths into
+ * one `json_each`. Chunking keeps every statement bounded WITHOUT capping the
+ * candidate list, which would silently drop test files from the ranking.
+ */
+const TEST_CHUNK = 500;
+
 /** Kinds that are never a useful hub row: a mention, a container, or a name. */
 const NON_HUB_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
   'file',
@@ -73,48 +103,131 @@ export interface WireEntryFile extends WireNodeRef {
   dependents: number;
 }
 
+export interface WireEntryTest extends WireNodeRef {
+  /** Distinct other files this test reaches — what it exercises. */
+  reaches: number;
+  /** References behind that reach. */
+  refs: number;
+}
+
 export interface WireEntryHub extends WireNodeRef {
   /** Distinct symbols that depend on this one. */
   dependents: number;
 }
 
 export interface WireEntryPoints {
+  /**
+   * Frameworks the resolver detected, e.g. `["go"]`, `["express"]`.
+   *
+   * The Routes section's header names them: a route list is a claim about a
+   * framework's conventions, and saying which one produced it is the
+   * difference between a fact and an assertion.
+   */
+  frameworks: string[];
   routes: {
     routed: boolean;
+    /** Every `route` node in the graph, resolved handler or not. */
     routeCount: number;
-    items: Array<{ url: string; handler: string; file: string; line: number; handlerId: string | null }>;
+    items: WireList<WireRoute>;
   };
+  /** `total` is a floor on these three — the server counts what its scan saw. */
   files: WireList<WireEntryFile>;
+  tests: WireList<WireEntryTest>;
   hubs: WireList<WireEntryHub>;
+  index: { lastIndexedAt: number | null; files: number };
+  timing: { elapsedMs: number; cached: boolean };
 }
 
+// =============================================================================
+// Cache
+// =============================================================================
+
+/**
+ * One answer per (project, index build, limits).
+ *
+ * Unlike `/api/source` and everything downstream of it, nothing here is read
+ * from disk: every field comes out of the index, so an answer is exactly as
+ * fresh as the index build it was keyed on. The Tests list is the reason it is
+ * worth caching at all — it asks a reach query per chunk of test files, and
+ * every screen in the viewer refetches this payload when the index moves.
+ */
+const CACHE_LIMIT = 8;
+const cache = new Map<string, WireEntryPoints>();
+
+export function resetEntryPointsCache(): void {
+  cache.clear();
+}
+
+// =============================================================================
+// Build
+// =============================================================================
+
 export function buildEntryPoints(cg: CodeGraph, query: URLSearchParams): WireEntryPoints {
+  const started = Date.now();
   const limit = intParam(query, 'limit', { min: 1, max: 50, default: DEFAULT_LIMIT });
+  const routeLimit = intParam(query, 'routes', {
+    min: 3,
+    max: MAX_ROUTE_LIMIT,
+    default: DEFAULT_ROUTE_LIMIT,
+  });
 
-  return {
-    routes: routeEntries(cg, limit),
+  const stats = cg.getStats();
+  // JSON rather than a joined string: a project root can contain any character
+  // a separator might have picked, and this key is compared for equality only.
+  const key = JSON.stringify([
+    cg.getProjectRoot(),
+    cg.getLastIndexedAt() ?? 0,
+    stats.edgeCount,
+    stats.fileCount,
+    limit,
+    routeLimit,
+  ]);
+  const hit = cache.get(key);
+  if (hit) {
+    // Re-stamp rather than mutate: the body is shared with the next caller.
+    return { ...hit, timing: { elapsedMs: Date.now() - started, cached: true } };
+  }
+
+  const payload: WireEntryPoints = {
+    frameworks: cg.getDetectedFrameworks(),
+    routes: routeEntries(cg, routeLimit),
     files: executableFiles(cg, limit),
+    tests: testFiles(cg, limit),
     hubs: hubs(cg, limit),
+    index: { lastIndexedAt: cg.getLastIndexedAt() ?? null, files: stats.fileCount },
+    timing: { elapsedMs: Date.now() - started, cached: false },
   };
+
+  if (cache.size >= CACHE_LIMIT) {
+    const oldest = cache.keys().next();
+    if (!oldest.done) cache.delete(oldest.value);
+  }
+  cache.set(key, payload);
+  return payload;
 }
 
 /**
  * The routing manifest, trimmed to a starting-points list.
  *
  * `buildRoutes` is reused rather than re-derived so a route row means exactly
- * the same thing here as on the routes endpoint — including its handler id,
- * which is what makes the row navigable.
+ * the same thing here as on the routes endpoint — including its handler id and
+ * its registration site, which are what make the row navigable and groupable.
  */
 function routeEntries(cg: CodeGraph, limit: number): WireEntryPoints['routes'] {
-  const manifest = buildRoutes(cg, new URLSearchParams()) as {
-    routed: boolean;
-    routeCount: number;
-    entries: WireEntryPoints['routes']['items'];
-  };
+  const manifest = buildRoutes(cg, new URLSearchParams([['limit', String(limit)]]));
   return {
     routed: manifest.routed,
     routeCount: manifest.routeCount,
-    items: manifest.entries.slice(0, limit),
+    // `shown` counts the rows; `truncated` is the manifest's own verdict on
+    // whether the window cut anything, and it is more trustworthy than
+    // comparing against `routeCount` (which counts URLs whose handler never
+    // resolved as well).
+    items: {
+      total: manifest.truncated ? Math.max(manifest.shown + 1, manifest.routeCount) : manifest.shown,
+      shown: manifest.shown,
+      truncated: manifest.truncated,
+      items: manifest.entries,
+    },
   };
 }
 
@@ -159,6 +272,51 @@ function executableFiles(cg: CodeGraph, limit: number): WireList<WireEntryFile>
   return wireList(items, Math.max(eligible, items.length));
 }
 
+/**
+ * The suites that exercise the most of the project, widest first.
+ *
+ * Ranked by reach rather than by size or by module-level calls: a test's
+ * useful property is how much of the codebase runs when it does, and only Go,
+ * Rust and Java put that work inside functions where a "runs something at
+ * module level" ranking cannot see it at all.
+ *
+ * `total` is exact here — the candidate list is every test file in the index,
+ * decided in JavaScript before any query runs — which is why it is the one
+ * derived list whose count is not a floor. A test file that reaches nothing
+ * outside itself is left out on purpose: it exercises nothing this graph can
+ * name.
+ */
+function testFiles(cg: CodeGraph, limit: number): WireList<WireEntryTest> {
+  const candidates = cg
+    .getFiles()
+    .map((file) => toPosixPath(file.path))
+    .filter((path) => isTestPath(path));
+  if (candidates.length === 0) return wireList([], 0);
+
+  const reach = new Map<string, { reaches: number; refs: number }>();
+  for (let i = 0; i < candidates.length; i += TEST_CHUNK) {
+    for (const [path, counts] of cg.getFileReachCounts(candidates.slice(i, i + TEST_CHUNK))) {
+      reach.set(toPosixPath(path), counts);
+    }
+  }
+
+  const ranked = [...reach.entries()]
+    .map(([path, counts]) => ({ path, ...counts }))
+    .sort((a, b) => b.reaches - a.reaches || b.refs - a.refs || a.path.localeCompare(b.path));
+
+  const top = ranked.slice(0, limit);
+  const nodes = new Map(cg.getFileNodes(top.map((row) => row.path)).map((n) => [toPosixPath(n.filePath), n]));
+
+  const items: WireEntryTest[] = [];
+  for (const row of top) {
+    const node = nodes.get(row.path);
+    if (!node) continue;
+    items.push({ ...toNodeRef(node), reaches: row.reaches, refs: row.refs });
+  }
+
+  return wireList(items, Math.max(ranked.length, items.length));
+}
+
 /** The most depended-on symbols, tests and non-navigable kinds removed. */
 function hubs(cg: CodeGraph, limit: number): WireList<WireEntryHub> {
   const ranked = cg.getTopDependedOn(SCAN_ROWS);

+ 8 - 2
src/ui-server/api/index.ts

@@ -17,7 +17,7 @@
  * GET /api/file/<path>               the File view: outline and import rails
  * GET /api/filecode/<path>           the whole-file view: ports, arcs, callee rail
  * GET /api/routes                    the URL to handler map, when there is one
- * GET /api/entrypoints               where to start reading: routes, roots, hubs
+ * GET /api/entrypoints               where to start reading: routes, roots, tests, hubs
  * GET /api/map?root=&depth=          the module map: modules, links, cycles
  * GET /api/flow?from=&to=            the flow strip: one card per hop
  * GET /api/events                    the live channel (SSE): drift and refresh
@@ -51,7 +51,13 @@ import { EventHub } from './events';
 export { GraphSession } from './session';
 export { ApiError } from './respond';
 export * from './wire';
-export type { WireEntryPoints, WireEntryFile, WireEntryHub } from './entrypoints';
+export type {
+  WireEntryPoints,
+  WireEntryFile,
+  WireEntryTest,
+  WireEntryHub,
+} from './entrypoints';
+export type { WireRoute, WireRoutes } from './routes';
 export type { WireNodeRefs } from './nodes';
 export type {
   WireFlowPayload,

+ 61 - 4
src/ui-server/api/routes.ts

@@ -24,6 +24,61 @@ import type { CodeGraph } from '../../index';
 import { intParam } from './respond';
 import { toPosixPath } from './wire';
 
+/**
+ * HTTP verbs a route name may lead with, plus the two stand-ins the resolvers
+ * emit when the registration names no verb (`mux.Handle`, `app.use`).
+ *
+ * The split is done against this list rather than against "the first word" so
+ * a file-routed page (`/blog/[slug]`) or a message-bus subscription keeps its
+ * whole name in the URL column instead of losing its first segment to a
+ * method column that was never there.
+ */
+const HTTP_METHODS: ReadonlySet<string> = new Set([
+  'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT',
+  'ANY', 'ALL', 'USE',
+]);
+
+/** One row of the URL → handler map. */
+export interface WireRoute {
+  /** The route node's name, verbatim: "POST /v1/users/{id}". */
+  url: string;
+  /** The verb, when the name leads with one. Null for file-routed pages. */
+  method: string | null;
+  /** The URL without the verb — the same string as `url` when there is none. */
+  path: string;
+  handler: string;
+  handlerKind: string;
+  /** Where the request is SERVED. */
+  file: string;
+  line: number;
+  handlerId: string | null;
+  /** Where the URL is REGISTERED — the router file, which is how routes group. */
+  routeFile: string;
+  routeLine: number;
+  routeId: string;
+}
+
+export interface WireRoutes {
+  routed: boolean;
+  /** Every URL the index holds, whether or not its handler resolved. */
+  routeCount: number;
+  /** Rows in `entries` — the ones whose handler the manifest could name. */
+  shown: number;
+  truncated: boolean;
+  topHandlerFile: string | null;
+  topHandlerFileCount: number;
+  entries: WireRoute[];
+}
+
+/** "POST /v1/users" -> { method: 'POST', path: '/v1/users' }. */
+export function splitRouteName(url: string): { method: string | null; path: string } {
+  const space = url.indexOf(' ');
+  if (space <= 0) return { method: null, path: url };
+  const head = url.slice(0, space);
+  if (!HTTP_METHODS.has(head.toUpperCase())) return { method: null, path: url };
+  return { method: head.toUpperCase(), path: url.slice(space + 1).trimStart() };
+}
+
 /** Distinct handler files we will resolve node ids for. */
 const MAX_HANDLER_FILES = 60;
 
@@ -34,7 +89,7 @@ const MAX_HANDLER_FILES = 60;
  */
 const MIN_LIMIT = 3;
 
-export function buildRoutes(cg: CodeGraph, query: URLSearchParams): unknown {
+export function buildRoutes(cg: CodeGraph, query: URLSearchParams): WireRoutes {
   const limit = intParam(query, 'limit', { min: MIN_LIMIT, max: 500, default: 200 });
 
   // One row over the limit, purely to learn whether there were more.
@@ -70,21 +125,23 @@ export function buildRoutes(cg: CodeGraph, query: URLSearchParams): unknown {
     }
   }
 
-  const entries = rows.map((entry) => ({
+  const entries: WireRoute[] = rows.map((entry) => ({
     url: entry.url,
+    ...splitRouteName(entry.url),
     handler: entry.handler,
     handlerKind: entry.handlerKind,
     file: toPosixPath(entry.handlerFile),
     line: entry.handlerLine,
     handlerId:
       byFileLineName.get(`${entry.handlerFile} ${entry.handlerLine} ${entry.handler}`) ?? null,
+    routeFile: toPosixPath(entry.routeFile),
+    routeLine: entry.routeLine,
+    routeId: entry.routeId,
   }));
 
   return {
     routed: true,
-    /** Every URL the index holds, whether or not its handler resolved. */
     routeCount,
-    /** Rows in `entries` — the ones whose handler the manifest could name. */
     shown: entries.length,
     truncated,
     topHandlerFile: manifest.topHandlerFile ? toPosixPath(manifest.topHandlerFile) : null,

+ 26 - 2
ui/README.md

@@ -39,15 +39,16 @@ src/
   main.ts                 fonts + tokens, mounts App into index.html's #app
   app.css                 design tokens (light/dark), reset, shell grid
   App.svelte              top bar / trail bar / main, global keys
-  lib/router.svelte.ts    hash router: #/s/<id>, #/file/<path>, #/map, #/flow
+  lib/router.svelte.ts    hash router: #/s/<id>, #/file/<path>, #/map, #/flow, #/entry
   lib/trail.svelte.ts     the walked path; mirrored into the `t` query param
   lib/kinds.ts            kind glyph letters
   lib/map-model.ts        the Map's deterministic layered layout (pure)
   lib/flow-model.ts       the Flow strip's card/link geometry + the end cap — a DAG (pure)
   lib/filecode-model.ts   the whole-file view: fixed line height, arcs, paging (pure)
+  lib/entry-model.ts      the entry-points panel: rows, file groups, flow arming (pure)
   lib/live.svelte.ts      /api/events: two counters every screen refreshes from
   lib/toast.svelte.ts     the one transient note ("Index updated · reloaded")
-  components/             TopBar, TrailBar, KindGlyph, DriftBanner, Toast, map/, flow/, symbol/, file/
+  components/             TopBar, TrailBar, KindGlyph, DriftBanner, Toast, map/, flow/, symbol/, file/, entry/
   views/                  one component per route
 ```
 
@@ -67,6 +68,29 @@ announce the project to a font CDN.
 | `#/flow?from=&to=` | flow strip — the call path between two symbols |
 | `#/flow?symbols=a,b,c` | flow strip — `codegraph_explore`'s own question |
 | `#/flow?t=<trail>` | flow strip — the trail you walked, read as a flow |
+| `#/entry` | entry points — routes, files that run something, tests, hubs |
+
+## Entry points
+
+`#/entry` draws `/api/entrypoints` as file groups, reusing the Symbol view's
+`.filegroup` / `.row` shapes rather than inventing a second visual language for
+"a list of code, grouped by where it lives". Three things about it are decisions,
+not accidents:
+
+- **Routes group by where the URL is REGISTERED, not where it is served.** A
+  router file is the shape a reader already has in mind; handlers scatter across
+  a package. The payload carries both, and the row's meta line names the handler
+  and its `file:line`.
+- **A row offers a flow only if it names a callable symbol.** `/api/flow`
+  searches the graph by NAME, and a file has none the path finder can look up —
+  so route and hub rows carry a `Flow ›` chip and file and test rows do not. A
+  chip that always failed would be worse than no chip.
+- **No empty Routes box.** A project with fewer than three resolvable routes is
+  not a routed app, and the section is absent rather than empty; the panel falls
+  back to the files that run something and the tests that exercise them.
+
+`buildEntryPanel` is pure and keeps `panel.rows` exactly equal to the sections it
+draws, the same identity the search palette rests its keyboard on.
 
 ## Where the graph stops
 

+ 13 - 1
ui/src/App.svelte

@@ -8,9 +8,11 @@
   import FileCodeView from './views/FileCodeView.svelte';
   import MapView from './views/MapView.svelte';
   import FlowView from './views/FlowView.svelte';
+  import EntryView from './views/EntryView.svelte';
   import NotFoundView from './views/NotFoundView.svelte';
   import Toast from './components/Toast.svelte';
-  import { router, navigate, back, mapHref, flowHref } from './lib/router.svelte';
+  import { router, navigate, back, mapHref, flowHref, entryHref } from './lib/router.svelte';
+  import { palette } from './lib/palette.svelte';
   import { trail, resolveTrailNames } from './lib/trail.svelte';
   import { project } from './lib/project.svelte';
   import { live } from './lib/live.svelte';
@@ -38,6 +40,10 @@
       if (tick === seenIndexTick) return;
       seenIndexTick = tick;
       void project.reload();
+      // The entry points describe the index, and they are fetched once and
+      // kept — so without this the resting palette, the empty screen and the
+      // entry-points panel would all keep describing the graph as it was.
+      void palette.reloadEntries();
       toast.show('Index updated · reloaded');
     });
   });
@@ -103,6 +109,10 @@
         event.preventDefault();
         navigate(flowHref());
         break;
+      case 'e':
+        event.preventDefault();
+        navigate(entryHref());
+        break;
       case 'Backspace':
       case '[':
         event.preventDefault();
@@ -132,6 +142,8 @@
       symbols={route.symbols}
       trailParam={route.trail}
     />
+  {:else if route.view === 'entry'}
+    <EntryView project={project.name} />
   {:else if route.view === 'unknown'}
     <NotFoundView path={route.path} />
   {:else}

+ 6 - 0
ui/src/components/PaletteRows.svelte

@@ -76,6 +76,12 @@
         <span class="mid">
           <span class="nm">{item.name}</span>
         </span>
+      {:else if item.type === 'entry'}
+        <KindGlyph kind={item.row.kind} />
+        <span class="mid" title={item.row.title}>
+          <span class="nm">{item.name}</span>
+          {#if item.meta}<span class="sig">{item.meta}</span>{/if}
+        </span>
       {:else}
         <KindGlyph kind={item.node.kind} />
         <span class="mid">

+ 20 - 2
ui/src/components/TopBar.svelte

@@ -1,10 +1,18 @@
 <script lang="ts">
-  import { router, mapHref, flowHref, symbolHref, fileHref, navigate } from '../lib/router.svelte';
+  import {
+    router,
+    mapHref,
+    flowHref,
+    entryHref,
+    symbolHref,
+    fileHref,
+    navigate,
+  } from '../lib/router.svelte';
   import { trail } from '../lib/trail.svelte';
   import { palette } from '../lib/palette.svelte';
   import SearchPalette from './SearchPalette.svelte';
   import type { PaletteItem } from '../lib/search-model';
-  import { walkTo } from '../lib/walk';
+  import { openEntryTarget, walkTo } from '../lib/walk';
   import { live } from '../lib/live.svelte';
 
   interface Props {
@@ -49,6 +57,15 @@
       navigate(flowHref({ from: item.from, to: item.to }));
       return;
     }
+    // An entry-point row already knows where it goes — a handler, a file, a
+    // hub — and it is the one row type that can point at a FILE.
+    if (item.type === 'entry') {
+      if (!item.row.target) return;
+      palette.reset();
+      input?.blur();
+      openEntryTarget(item.row.target);
+      return;
+    }
     const id = item.type === 'route' ? item.nodeId : item.id;
     // A route whose handler never resolved to a node has nowhere to go; the
     // row stays, because "this URL exists and we could not place it" is true.
@@ -149,6 +166,7 @@
   </a>
 
   <nav class="views" aria-label="Views">
+    <a href={entryHref()} class:active={view === 'entry'}>Entry points</a>
     <a href={mapHref()} class:active={view === 'map'}>Map</a>
     <a href={symbolTabHref} class:active={view === 'symbol' || view === 'home'}>Symbol</a>
     <a href={flowHref()} class:active={view === 'flow'}>Flow</a>

+ 239 - 0
ui/src/components/entry/EntrySection.svelte

@@ -0,0 +1,239 @@
+<!--
+  One section of the entry-points panel — routes, executable files, tests, hubs.
+
+  The file-group + row shapes are the Symbol view's caller rail (design spec
+  §3.2, `.filegroup` / `.row`), reused rather than re-invented: they are the
+  repo's established "a list of code, grouped by where it lives", and a second
+  visual language for the same idea is how a small app starts looking like two.
+
+  Every row does two things. Clicking it opens the code — a handler, a file, a
+  hub. The `Flow ›` chip beside it arms a flow FROM that symbol, which the panel
+  then completes with a second name. Rows that name no callable symbol carry no
+  chip: `/api/flow` searches by name, and a file has none the path finder can
+  look up.
+-->
+<script lang="ts">
+  import KindGlyph from '../KindGlyph.svelte';
+  import { fileHref } from '../../lib/router.svelte';
+  import type { EntryRow, EntrySection } from '../../lib/entry-model';
+
+  interface Props {
+    section: EntrySection;
+    /** The row currently armed as a flow's start, by row id. */
+    armed: string | null;
+    onopen: (row: EntryRow) => void;
+    onflow: (row: EntryRow) => void;
+  }
+
+  let { section, armed, onopen, onflow }: Props = $props();
+</script>
+
+<section class="sec" aria-labelledby={`entry-${section.id}`}>
+  <div class="sec-h">
+    <h3 id={`entry-${section.id}`}>{section.title}</h3>
+    <span class="meta">{section.meta}</span>
+  </div>
+  <p class="note">{section.note}</p>
+
+  {#each section.groups as group (group.path)}
+    <div class="filegroup">
+      <div class="fpath">
+        {#if group.file}
+          <a href={fileHref(group.file)} title={group.file}>{group.path}</a>
+        {:else}
+          <span title={group.path}>{group.path}</span>
+        {/if}
+        <b>{group.rows.length}</b>
+      </div>
+      {#each group.rows as row (row.id)}
+        <div class="row" class:armed={armed === row.id} class:stub={!row.target}>
+          <KindGlyph kind={row.kind} />
+          <div class="body">
+            <div class="line">
+              {#if row.target}
+                <button
+                  type="button"
+                  class="nm"
+                  title={row.title}
+                  data-entry-row={row.id}
+                  onclick={() => onopen(row)}
+                >
+                  {#if row.method}<span class="verb">{row.method}</span>{/if}{row.name}
+                </button>
+              {:else}
+                <span class="nm plain" title={row.title}>
+                  {#if row.method}<span class="verb">{row.method}</span>{/if}{row.name}
+                </span>
+              {/if}
+              {#if row.flowFrom}
+                {@const label =
+                  armed === null ? 'Flow ›' : armed === row.id ? 'Cancel' : '→ here'}
+                <button
+                  type="button"
+                  class="chip"
+                  title={armed === null
+                    ? `Start a flow from ${row.flowFrom}`
+                    : armed === row.id
+                      ? 'Stop drawing a flow from here'
+                      : `Draw the path that ends at ${row.flowFrom}`}
+                  data-entry-flow={row.id}
+                  onclick={() => onflow(row)}>{label}</button
+                >
+              {/if}
+            </div>
+            <div class="meta">{row.meta}</div>
+          </div>
+        </div>
+      {/each}
+    </div>
+  {/each}
+
+  {#if section.shown < section.total}
+    <p class="note dim">
+      Showing {section.shown} of {section.floor ? 'at least ' : ''}{section.total} — the rest are in
+      the index, not on this list.
+    </p>
+  {/if}
+</section>
+
+<style>
+  .sec {
+    padding: 0 0 18px;
+    border-bottom: 1px solid var(--rule-faint);
+  }
+
+  .sec:last-child {
+    border-bottom: 0;
+  }
+
+  .sec-h {
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    gap: 12px;
+    padding: 14px 14px 2px;
+  }
+
+  .sec-h h3 {
+    margin: 0;
+    font-size: 15px;
+    font-weight: 600;
+  }
+
+  .sec-h .meta {
+    color: var(--ink-3);
+    font-size: 11.5px;
+  }
+
+  .note {
+    margin: 0;
+    padding: 2px 14px 4px;
+    color: var(--ink-3);
+    font-size: 11.5px;
+    line-height: 1.4;
+  }
+
+  .note.dim {
+    color: var(--ink-4);
+  }
+
+  .filegroup {
+    padding: 10px 14px 4px;
+  }
+
+  .fpath {
+    display: flex;
+    justify-content: space-between;
+    gap: 8px;
+    margin-bottom: 4px;
+    color: var(--ink-3);
+    font: 11px var(--mono);
+  }
+
+  .fpath a:hover {
+    color: var(--ink);
+    text-decoration: underline;
+  }
+
+  .fpath b {
+    color: var(--ink-2);
+    font-weight: 500;
+  }
+
+  .row {
+    position: relative;
+    display: grid;
+    grid-template-columns: 16px 1fr;
+    gap: 8px;
+    align-items: start;
+    margin: 0 -6px;
+    padding: 5px 6px 5px 4px;
+    border: 1px solid transparent;
+  }
+
+  .row:hover {
+    background: var(--press);
+  }
+
+  .row.armed {
+    border-color: var(--accent-line);
+    background: var(--accent-soft);
+  }
+
+  .body {
+    min-width: 0;
+  }
+
+  .line {
+    display: flex;
+    align-items: baseline;
+    gap: 8px;
+  }
+
+  .nm {
+    overflow: hidden;
+    min-width: 0;
+    color: var(--ink);
+    font: 12.5px var(--mono);
+    text-align: left;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .nm:not(.plain) {
+    cursor: pointer;
+  }
+
+  .row.stub .nm {
+    color: var(--ink-2);
+  }
+
+  .verb {
+    margin-right: 6px;
+    color: var(--ink-2);
+    font-weight: 500;
+  }
+
+  .meta {
+    margin-top: 1px;
+    overflow: hidden;
+    color: var(--ink-3);
+    font-size: 11px;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .chip {
+    flex: none;
+    padding: 0 4px;
+    border: 1px solid var(--rule-soft);
+    background: var(--paper);
+    color: var(--ink-2);
+    font: 11px var(--mono);
+  }
+
+  .chip:hover {
+    border-color: var(--ink);
+    color: var(--ink);
+  }
+</style>

+ 32 - 3
ui/src/lib/api.ts

@@ -306,11 +306,22 @@ export interface WireNodeRefs {
 /* ---------------------------------------------------------- entry points -- */
 
 export interface WireEntryRoute {
+  /** The route node's name, verbatim: "POST /v1/users/{id}". */
   url: string;
+  /** The verb, when the name leads with one. Null for a file-routed page. */
+  method: string | null;
+  /** The URL without the verb — the same string as `url` when there is none. */
+  path: string;
   handler: string;
+  handlerKind: string;
+  /** Where the request is SERVED. */
   file: string;
   line: number;
   handlerId: string | null;
+  /** Where the URL is REGISTERED — the router file, which is how routes group. */
+  routeFile: string;
+  routeLine: number;
+  routeId: string;
 }
 
 export interface WireEntryFile extends WireNodeRef {
@@ -326,11 +337,28 @@ export interface WireEntryHub extends WireNodeRef {
   dependents: number;
 }
 
+export interface WireEntryTest extends WireNodeRef {
+  /** Distinct other files this test reaches — what it exercises. */
+  reaches: number;
+  /** References behind that reach. */
+  refs: number;
+}
+
 export interface WireEntryPoints {
-  routes: { routed: boolean; routeCount: number; items: WireEntryRoute[] };
-  /** `total` is a floor on both lists — the server counts what its scan saw. */
+  /** Frameworks the resolver detected — named in the Routes header. */
+  frameworks: string[];
+  routes: {
+    routed: boolean;
+    /** Every `route` node in the graph, resolved handler or not. */
+    routeCount: number;
+    items: WireList<WireEntryRoute>;
+  };
+  /** `total` is a floor on `files` and `hubs`; on `tests` it is exact. */
   files: WireList<WireEntryFile>;
+  tests: WireList<WireEntryTest>;
   hubs: WireList<WireEntryHub>;
+  index: { lastIndexedAt: number | null; files: number };
+  timing: { elapsedMs: number; cached: boolean };
 }
 
 export interface WireStats {
@@ -606,11 +634,12 @@ export function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Pro
 }
 
 export function fetchEntryPoints(
-  opts: { limit?: number } = {},
+  opts: { limit?: number; routes?: number } = {},
   signal?: AbortSignal
 ): Promise<WireEntryPoints> {
   const params = new URLSearchParams();
   if (opts.limit) params.set('limit', String(opts.limit));
+  if (opts.routes) params.set('routes', String(opts.routes));
   const query = params.toString();
   return getJson<WireEntryPoints>(`api/entrypoints${query ? `?${query}` : ''}`, signal);
 }

+ 406 - 0
ui/src/lib/entry-model.ts

@@ -0,0 +1,406 @@
+/**
+ * What the entry-points panel decides, without a browser.
+ *
+ * `/api/entrypoints` answers four questions about where a project starts —
+ * routes, files that run something, tests, hubs — as four flat ranked lists.
+ * The panel draws them as file groups, because the first thing a reader wants
+ * from twenty routes is *which router registers them*, and from twelve
+ * executable files *which directory they live in*. That regrouping is the whole
+ * of this module: it is presentation, it needs no round-trip, and it is a pure
+ * function so it can be tested without a DOM.
+ *
+ * Two rules it keeps:
+ *
+ * - **A row that cannot be opened is not offered as if it could.** A route
+ *   whose handler never resolved to a node still appears — "this URL exists and
+ *   we could not place it" is true and worth saying — but it carries no target
+ *   and the panel draws it as text.
+ * - **A row only offers a flow if it names a callable symbol.** `/api/flow`
+ *   searches the graph by NAME, and a file has no name the path finder can
+ *   look up, so a "start a flow here" affordance on a file row would be a
+ *   button that always fails.
+ *
+ * Tested in `__tests__/ui-entry-model.test.ts`.
+ */
+
+import type {
+  WireEntryFile,
+  WireEntryHub,
+  WireEntryPoints,
+  WireEntryRoute,
+  WireEntryTest,
+  WireList,
+} from './api';
+import { basename, plural } from './symbol-model';
+
+/* ---------------------------------------------------------------- shapes -- */
+
+/** Where a row goes when it is clicked. */
+export type EntryTarget =
+  | { type: 'symbol'; id: string; name: string; kind: string }
+  | { type: 'file'; path: string }
+  | null;
+
+export interface EntryRow {
+  /** Stable across refetches — the panel keys on it. */
+  id: string;
+  /** The row's own name column: a URL, a basename, a symbol name. */
+  name: string;
+  /** The verb, drawn ahead of the name in the same mono. Routes only. */
+  method: string | null;
+  /** One line under the name: handler + `file:line`, counts, what it reaches. */
+  meta: string;
+  /** Glyph kind — a NodeKind string, or 'route'. */
+  kind: string;
+  target: EntryTarget;
+  /**
+   * The symbol name a flow would start from, when this row names one.
+   * Null on file rows: the path finder looks symbols up by name.
+   */
+  flowFrom: string | null;
+  /** Hover text: the fuller truth the row had to shorten. */
+  title: string;
+}
+
+export interface EntryGroup {
+  /** The file or directory the rows share. */
+  path: string;
+  /** The file to open when the group heading is clicked, when there is one. */
+  file: string | null;
+  rows: EntryRow[];
+}
+
+export interface EntrySection {
+  id: 'routes' | 'files' | 'tests' | 'hubs';
+  title: string;
+  /** The header's right-hand meta: counts, and the framework when detected. */
+  meta: string;
+  /** A sentence saying what the section is derived from. */
+  note: string;
+  groups: EntryGroup[];
+  /** Rows drawn, and the real total behind them. */
+  shown: number;
+  total: number;
+  /** `total` is a lower bound the server could not tighten. */
+  floor: boolean;
+}
+
+export interface EntryPanel {
+  sections: EntrySection[];
+  /** Every row, in the order the sections draw them. */
+  rows: EntryRow[];
+  /** Nothing to show, and why. Null when there is something. */
+  empty: string | null;
+}
+
+/* -------------------------------------------------------------- grouping -- */
+
+/** `src/bin/codegraph.ts` -> `src/bin`; a root file -> `project root`. */
+export function directoryOf(path: string): string {
+  const cut = path.lastIndexOf('/');
+  return cut < 0 ? 'project root' : path.slice(0, cut);
+}
+
+/**
+ * Fold rows into groups, first-seen order.
+ *
+ * First-seen rather than alphabetical, so the ranking the server computed is
+ * still visible: the busiest router file, or the directory holding the highest
+ * ranked executable, leads the section.
+ */
+export function groupRows(
+  placed: ReadonlyArray<{ row: EntryRow; path: string; file: string | null }>
+): EntryGroup[] {
+  const groups: EntryGroup[] = [];
+  const byPath = new Map<string, EntryGroup>();
+  for (const { row, path, file } of placed) {
+    let group = byPath.get(path);
+    if (!group) {
+      group = { path, file, rows: [] };
+      byPath.set(path, group);
+      groups.push(group);
+    }
+    group.rows.push(row);
+  }
+  return groups;
+}
+
+/* ------------------------------------------------------------------ rows -- */
+
+export function routeRow(route: WireEntryRoute): EntryRow {
+  const where = `${basename(route.file)}:${route.line}`;
+  return {
+    id: `route:${route.routeId}`,
+    name: route.path,
+    method: route.method,
+    kind: 'route',
+    // The handler is the answer to "what serves this URL", so it leads the
+    // meta line; the file only says where to find it.
+    meta: route.handlerId ? `${route.handler} · ${where}` : `${route.handler} · not in the index`,
+    target: route.handlerId
+      ? { type: 'symbol', id: route.handlerId, name: route.handler, kind: route.handlerKind }
+      : null,
+    flowFrom: route.handlerId ? route.handler : null,
+    title: `${route.url} → ${route.handler} (${route.file}:${route.line}), registered at ${route.routeFile}:${route.routeLine}`,
+  };
+}
+
+export function fileRow(file: WireEntryFile): EntryRow {
+  return {
+    id: `file:${file.file}`,
+    name: basename(file.file),
+    method: null,
+    kind: 'file',
+    meta: `${plural(file.calls, 'call')} at module level · reaches ${plural(file.reaches, 'file')}${
+      file.dependents === 0 ? ' · nothing imports it' : ''
+    }`,
+    target: { type: 'file', path: file.file },
+    flowFrom: null,
+    title: file.file,
+  };
+}
+
+export function testRow(test: WireEntryTest): EntryRow {
+  return {
+    id: `test:${test.file}`,
+    name: basename(test.file),
+    method: null,
+    kind: 'file',
+    meta: `exercises ${plural(test.reaches, 'file')} · ${plural(test.refs, 'reference')}`,
+    target: { type: 'file', path: test.file },
+    flowFrom: null,
+    title: test.file,
+  };
+}
+
+export function hubRow(hub: WireEntryHub): EntryRow {
+  return {
+    id: `hub:${hub.id}`,
+    name: hub.name,
+    method: null,
+    kind: hub.kind,
+    meta: `${plural(hub.dependents, 'dependent')} · ${basename(hub.file)}:${hub.line}`,
+    target: { type: 'symbol', id: hub.id, name: hub.name, kind: hub.kind },
+    flowFrom: hub.name,
+    title: `${hub.qualifiedName} — ${hub.file}:${hub.line}`,
+  };
+}
+
+/* ----------------------------------------------------------------- panel -- */
+
+/** "42 of 208" when the list was cut, "42" when it was not. */
+function countMeta(list: { shown: number; total: number }, floor: boolean): string {
+  if (list.shown >= list.total) return `${list.shown}`;
+  return `${list.shown} of ${floor ? 'at least ' : ''}${list.total}`;
+}
+
+/**
+ * "gin", "express and spring" — the frameworks behind a route list.
+ *
+ * Named because a route list is a claim about a framework's conventions; a
+ * reader who knows the app is Gin and sees "spring" learns something useful
+ * about the index rather than being quietly misled by it.
+ */
+export function frameworkPhrase(frameworks: readonly string[]): string {
+  if (frameworks.length === 0) return '';
+  if (frameworks.length === 1) return frameworks[0] as string;
+  if (frameworks.length === 2) return `${frameworks[0]} and ${frameworks[1]}`;
+  return `${frameworks.slice(0, -1).join(', ')} and ${frameworks[frameworks.length - 1]}`;
+}
+
+function section(
+  id: EntrySection['id'],
+  title: string,
+  note: string,
+  list: WireList<unknown>,
+  groups: EntryGroup[],
+  floor: boolean,
+  extraMeta = ''
+): EntrySection {
+  const counts = countMeta(list, floor);
+  return {
+    id,
+    title,
+    meta: extraMeta ? `${counts} · ${extraMeta}` : counts,
+    note,
+    groups,
+    shown: list.shown,
+    total: list.total,
+    floor,
+  };
+}
+
+export function buildEntryPanel(entries: WireEntryPoints | null): EntryPanel {
+  if (!entries) return { sections: [], rows: [], empty: null };
+  const sections: EntrySection[] = [];
+
+  // A project with fewer than three resolvable routes is not a routed app, and
+  // the engine says so rather than half-answering. No Routes heading at all in
+  // that case — an empty box under a heading reads as a failure, and this is
+  // the ordinary shape of a library.
+  if (entries.routes.routed && entries.routes.items.items.length > 0) {
+    sections.push(
+      section(
+        'routes',
+        'Routes',
+        'A request from outside arrives here — the URL, and the symbol that serves it.',
+        entries.routes.items,
+        groupRows(
+          entries.routes.items.items.map((route) => ({
+            row: routeRow(route),
+            // Grouped by where the URL is REGISTERED, not by where it is
+            // served: a router file is the shape a reader already has in mind,
+            // and handlers scatter across a package.
+            path: route.routeFile,
+            file: route.routeFile,
+          }))
+        ),
+        false,
+        frameworkPhrase(entries.frameworks)
+      )
+    );
+  }
+
+  if (entries.files.items.length > 0) {
+    sections.push(
+      section(
+        'files',
+        'Top-level files with calls',
+        'Statements at the top level of the file — a CLI, a worker entry, a script.',
+        entries.files,
+        groupRows(
+          entries.files.items.map((file) => ({
+            row: fileRow(file),
+            path: directoryOf(file.file),
+            file: null,
+          }))
+        ),
+        true
+      )
+    );
+  }
+
+  if (entries.tests.items.length > 0) {
+    sections.push(
+      section(
+        'tests',
+        'Tests',
+        'What already exercises this code, widest reach first.',
+        entries.tests,
+        groupRows(
+          entries.tests.items.map((test) => ({
+            row: testRow(test),
+            path: directoryOf(test.file),
+            file: null,
+          }))
+        ),
+        false
+      )
+    );
+  }
+
+  if (entries.hubs.items.length > 0) {
+    sections.push(
+      section(
+        'hubs',
+        'Most depended on',
+        'Not where the project starts — where a change radiates furthest.',
+        entries.hubs,
+        groupRows(
+          entries.hubs.items.map((hub) => ({
+            row: hubRow(hub),
+            path: hub.file,
+            file: hub.file,
+          }))
+        ),
+        true
+      )
+    );
+  }
+
+  return {
+    sections,
+    rows: sections.flatMap((s) => s.groups.flatMap((g) => g.rows)),
+    empty:
+      sections.length === 0
+        ? 'This index has no routes, no file that runs anything at module level, no test that reaches outside itself, and nothing depended on yet.'
+        : null,
+  };
+}
+
+/* --------------------------------------------------------------- palette -- */
+
+/** Entry-point rows the palette shows under a typed query, ranked and capped. */
+export interface EntryMatch {
+  row: EntryRow;
+  /** Which list it came from, for the row's location column. */
+  origin: 'route' | 'file' | 'test' | 'hub';
+}
+
+/**
+ * Entry points that mention what was typed.
+ *
+ * The palette already searches the graph, and route nodes, files and symbols
+ * all come back from that search — so what this adds is not the row but its
+ * CONTEXT: a `/api/search` hit on `POST /v1/payroll/cycles/{cycleID}/run` is a
+ * route node with no handler attached, and this one carries the handler, its
+ * file and line, and a target that opens the code rather than the URL.
+ *
+ * Matching is a plain case-insensitive substring over the text the row draws.
+ * Anything cleverer would rank differently from the search above it, and two
+ * different rankings of the same words in one panel is how a palette stops
+ * being predictable.
+ */
+export function matchEntries(
+  entries: WireEntryPoints | null,
+  query: string,
+  limit: number
+): EntryMatch[] {
+  const needle = query.trim().toLowerCase();
+  if (!entries || needle === '') return [];
+
+  const pools: Array<[EntryMatch['origin'], EntryRow[]]> = [
+    ['route', entries.routes.routed ? entries.routes.items.items.map(routeRow) : []],
+    ['file', entries.files.items.map(fileRow)],
+    ['test', entries.tests.items.map(testRow)],
+    ['hub', entries.hubs.items.map(hubRow)],
+  ];
+
+  const matches: EntryMatch[] = [];
+  for (const [origin, rows] of pools) {
+    for (const row of rows) {
+      if (matches.length >= limit) return matches;
+      const haystack = `${row.method ?? ''} ${row.name} ${row.meta} ${row.title}`.toLowerCase();
+      if (haystack.includes(needle)) matches.push({ row, origin });
+    }
+  }
+  return matches;
+}
+
+/** The location column for a palette entry row — where it came from. */
+export function originLabel(origin: EntryMatch['origin']): string {
+  switch (origin) {
+    case 'route':
+      return 'route';
+    case 'file':
+      return 'runs at module level';
+    case 'test':
+      return 'test';
+    case 'hub':
+      return 'depended on';
+  }
+}
+
+/* ------------------------------------------------------------------ flow -- */
+
+/**
+ * The href a flow between two named symbols opens at, or null when the pair is
+ * not a question. Same name twice has no path to draw, and `/api/flow` refuses
+ * it — better to disable the button than to navigate into a 400.
+ */
+export function flowPair(from: string, to: string): { from: string; to: string } | null {
+  const a = from.trim();
+  const b = to.trim();
+  if (!a || !b || a.toLowerCase() === b.toLowerCase()) return null;
+  return { from: a, to: b };
+}

+ 49 - 4
ui/src/lib/palette.svelte.ts

@@ -35,6 +35,19 @@ const SEARCH_LIMIT = 40;
 const ENTRY_LIMIT = 24;
 export const PALETTE_ENTRY_ROWS = 6;
 
+/**
+ * Route rows fetched.
+ *
+ * Separate from `ENTRY_LIMIT` because routes are the one list whose useful
+ * length is the project's, not the reader's: the panel groups them under their
+ * router files, where two hundred rows are still navigable, while two hundred
+ * "most depended on" symbols are a wall.
+ */
+const ENTRY_ROUTE_LIMIT = 200;
+
+/** Entry-point rows the palette adds under a typed query. */
+export const PALETTE_ENTRY_MATCHES = 6;
+
 /**
  * Milliseconds of quiet before a query is sent.
  *
@@ -51,6 +64,9 @@ let loading = $state(false);
 let failure = $state<string | null>(null);
 let answers = $state<WireSearch[]>([]);
 let entries = $state<WireEntryPoints | null>(null);
+/** Null until the first attempt settles — the panel says "reading" until then. */
+let entriesSettled = $state(false);
+let entriesFailure = $state<string | null>(null);
 
 let inflight: AbortController | null = null;
 let timer: ReturnType<typeof setTimeout> | null = null;
@@ -61,14 +77,21 @@ let entriesInflight: Promise<void> | null = null;
 
 function loadEntries(): Promise<void> {
   if (entriesInflight) return entriesInflight;
-  entriesInflight = fetchEntryPoints({ limit: ENTRY_LIMIT })
+  entriesInflight = fetchEntryPoints({ limit: ENTRY_LIMIT, routes: ENTRY_ROUTE_LIMIT })
     .then((value) => {
       entries = value;
+      entriesFailure = null;
     })
-    .catch(() => {
+    .catch((cause: unknown) => {
       // The palette still works without them; a failed "where do I start"
-      // should never stop someone from typing a name.
+      // should never stop someone from typing a name. The entry-points panel
+      // is the one screen that has nothing else to show, so the reason is
+      // kept rather than swallowed.
       entries = null;
+      entriesFailure = cause instanceof Error ? cause.message : String(cause);
+    })
+    .finally(() => {
+      entriesSettled = true;
     });
   return entriesInflight;
 }
@@ -122,7 +145,11 @@ function schedule(text: string): void {
 /** The palette as it should be drawn right now. */
 function current(): Palette {
   if (query.trim() === '') return buildEntryPalette(entries, { perSection: PALETTE_ENTRY_ROWS });
-  return buildSearchPalette(answers, parseFlowQuery(query));
+  return buildSearchPalette(answers, parseFlowQuery(query), {
+    entries,
+    query,
+    entryRows: PALETTE_ENTRY_MATCHES,
+  });
 }
 
 export const palette = {
@@ -185,7 +212,25 @@ export const palette = {
   },
   /** Load the entry points without opening the panel (the empty screen wants them). */
   ensureEntries: loadEntries,
+  /**
+   * Ask again, because the index moved.
+   *
+   * Entry points describe the index, so they are fetched once and kept — which
+   * means a sync would otherwise leave the resting palette, the empty screen
+   * and the entry-points panel all describing the graph as it was.
+   */
+  reloadEntries(): Promise<void> {
+    entriesInflight = null;
+    return loadEntries();
+  },
   get entries(): WireEntryPoints | null {
     return entries;
   },
+  /** False until the first fetch settles, however it settled. */
+  get entriesSettled(): boolean {
+    return entriesSettled;
+  },
+  get entriesFailure(): string | null {
+    return entriesFailure;
+  },
 };

+ 8 - 0
ui/src/lib/router.svelte.ts

@@ -10,6 +10,7 @@
  *   #/file/<path>          file view        (?hl=<line>, ?src=1 for whole-file source)
  *   #/map                  module map       (?root=&depth=&tests=1)
  *   #/flow                 flow strip       (?from=&to= | ?symbols= | ?t=<trail>)
+ *   #/entry                entry points     (where a flow starts)
  *
  * Node ids are opaque engine strings shaped `<kind>:<hash>` or
  * `<kind>:<relative/path>` (see src/extraction/tree-sitter-helpers.ts), so
@@ -40,6 +41,7 @@ export type Route =
       /** An encoded trail, read as a flow. Same format the `t` param uses. */
       trail: string | null;
     }
+  | { view: 'entry' }
   | { view: 'unknown'; path: string };
 
 export type ViewName = Route['view'];
@@ -99,6 +101,8 @@ export function parseHash(hash: string): RouterLocation {
       depth: Number.isFinite(depth) && depth >= 1 && depth <= 4 ? depth : 1,
       tests: params.get('tests') === '1',
     };
+  } else if (head === 'entry' && rest.length === 0) {
+    route = { view: 'entry' };
   } else if (head === 'flow' && rest.length === 0) {
     // The question travels in the URL exactly as it was asked, so a flow can be
     // linked in a review and reopen as the same path.
@@ -150,6 +154,10 @@ export function mapHref(
   return `#/map${query ? `?${query}` : ''}`;
 }
 
+export function entryHref(): string {
+  return '#/entry';
+}
+
 export function flowHref(
   opts: { from?: string; to?: string; symbols?: string; trail?: string } = {}
 ): string {

+ 50 - 6
ui/src/lib/search-model.ts

@@ -18,6 +18,7 @@ import type {
   WireSearch,
   WireSearchResult,
 } from './api';
+import { matchEntries, originLabel, type EntryRow } from './entry-model';
 import { basename, plural } from './symbol-model';
 
 /* ------------------------------------------------------------ flow query -- */
@@ -60,7 +61,13 @@ export function parseFlowQuery(query: string): FlowQuery | null {
 export type PaletteItem =
   | { type: 'symbol'; id: string; node: WireNodeRef; name: string; meta: string; location: string }
   | { type: 'route'; id: string; url: string; handler: string; location: string; nodeId: string | null }
-  | { type: 'flow'; id: string; from: string; to: string; name: string; meta: string; location: string };
+  | { type: 'flow'; id: string; from: string; to: string; name: string; meta: string; location: string }
+  /**
+   * An entry point that mentions what was typed. It carries the panel's own
+   * row, so a route here names its HANDLER — which is the thing a `/api/search`
+   * hit on the same URL cannot do.
+   */
+  | { type: 'entry'; id: string; row: EntryRow; name: string; meta: string; location: string };
 
 export interface PaletteSection {
   /** Sentence-case caption, e.g. "Methods", "Files that run something". */
@@ -171,7 +178,8 @@ export function groupByKind(results: readonly WireSearchResult[]): PaletteSectio
 
 export function buildSearchPalette(
   answers: readonly WireSearch[],
-  flow: FlowQuery | null
+  flow: FlowQuery | null,
+  entryOpts: { entries: WireEntryPoints | null; query: string; entryRows: number } | null = null
 ): Palette {
   const results =
     answers.length > 1
@@ -198,6 +206,32 @@ export function buildSearchPalette(
       ],
     });
   }
+  // Entry points come LAST, under their own heading: they are context on rows
+  // the search above may already have found, and putting context above matches
+  // would push what was actually asked for off the panel. Rows whose target is
+  // already in the results are dropped — the same symbol twice under two
+  // headings makes the panel look like it is guessing.
+  if (entryOpts) {
+    const seen = new Set(results.map((result) => result.id));
+    const matches = matchEntries(entryOpts.entries, entryOpts.query, entryOpts.entryRows).filter(
+      (match) => !(match.row.target?.type === 'symbol' && seen.has(match.row.target.id))
+    );
+    if (matches.length > 0) {
+      sections.push({
+        title: 'Entry points',
+        note: 'Where a flow starts — routes, files that run something, tests.',
+        items: matches.map(({ row, origin }) => ({
+          type: 'entry' as const,
+          id: `entry:${row.id}`,
+          row,
+          name: row.method ? `${row.method} ${row.name}` : row.name,
+          meta: row.meta,
+          location: originLabel(origin),
+        })),
+      });
+    }
+  }
+
   const items = sections.flatMap((section) => section.items);
 
   const hint = flow
@@ -232,13 +266,13 @@ export function buildEntryPalette(
     Number.isFinite(cap) ? items.slice(0, cap) : [...items];
   const sections: PaletteSection[] = [];
 
-  if (entries.routes.routed && entries.routes.items.length > 0) {
+  if (entries.routes.routed && entries.routes.items.items.length > 0) {
     sections.push({
       title: 'Routes',
       note: 'A request from outside arrives here.',
-      items: take(entries.routes.items).map((route) => ({
+      items: take(entries.routes.items.items).map((route) => ({
         type: 'route' as const,
-        id: `route:${route.url}:${route.file}:${route.line}`,
+        id: `route:${route.routeId}`,
         url: route.url,
         handler: route.handler,
         location: `${basename(route.file)}:${route.line}`,
@@ -257,6 +291,16 @@ export function buildEntryPalette(
     });
   }
 
+  if (entries.tests.items.length > 0) {
+    sections.push({
+      title: 'Tests',
+      note: 'What already exercises this code, widest reach first.',
+      items: take(entries.tests.items).map((test) =>
+        symbolItem(test, `exercises ${plural(test.reaches, 'file')}`)
+      ),
+    });
+  }
+
   if (entries.hubs.items.length > 0) {
     sections.push({
       title: 'Most depended on',
@@ -273,7 +317,7 @@ export function buildEntryPalette(
     hint: null,
     empty:
       sections.length === 0
-        ? 'This index has no routes, no file that runs anything, and nothing depended on yet.'
+        ? 'This index has no routes, no file that runs anything, no test that reaches outside itself, and nothing depended on yet.'
         : null,
   };
 }

+ 20 - 1
ui/src/lib/walk.ts

@@ -13,8 +13,9 @@
  * link reproduces the walk rather than starting a fresh one at the same symbol.
  */
 
-import { navigate, symbolHref } from './router.svelte';
+import { fileHref, navigate, symbolHref } from './router.svelte';
 import { encodeTrail, trail, type HopDirection } from './trail.svelte';
+import type { EntryTarget } from './entry-model';
 
 export interface WalkTarget {
   id: string;
@@ -52,3 +53,21 @@ export function arrivedFrom(): { id: string; rail: 'left' | 'right' } | null {
   if (current.dir === 'up') return { id: previous.id, rail: 'right' };
   return null;
 }
+
+/**
+ * Open whatever an entry-point row points at.
+ *
+ * A file goes to the File view rather than to the file node's Symbol view —
+ * the outline is on both, but only the File view carries the import rails —
+ * and it does NOT join the trail: a trail is a path through calls, and "I
+ * opened a file" is not a call. A symbol is a `start` hop, like any other jump
+ * that nothing on screen was stepped through to reach.
+ */
+export function openEntryTarget(target: EntryTarget): void {
+  if (!target) return;
+  if (target.type === 'file') {
+    navigate(fileHref(target.path));
+    return;
+  }
+  walkTo({ id: target.id, name: target.name, kind: target.kind }, 'start');
+}

+ 245 - 0
ui/src/views/EntryView.svelte

@@ -0,0 +1,245 @@
+<script lang="ts">
+  /**
+   * Entry points — where a project starts, and where a flow starts.
+   *
+   * Four lists, all derived from the graph rather than from a filename
+   * convention (see `src/ui-server/api/entrypoints.ts` for what each is derived
+   * from), regrouped by the file or directory their rows share.
+   *
+   * The second half of the screen is the flow: a row that names a callable
+   * symbol arms a flow from it, and the panel then wants one more name. That
+   * second name can be typed, or picked by arming another row — "how does
+   * `POST /v1/payroll/cycles/{cycleID}/run` reach the database" is two clicks
+   * once both ends are on screen, which is the whole reason this list and the
+   * Flow strip belong on speaking terms.
+   *
+   * The payload is the palette's: one `/api/entrypoints` serves the search box
+   * at rest, the empty screen and this panel, so all three agree on the order.
+   */
+  import EntrySection from '../components/entry/EntrySection.svelte';
+  import { palette } from '../lib/palette.svelte';
+  import { buildEntryPanel, flowPair, type EntryRow } from '../lib/entry-model';
+  import { flowHref, navigate } from '../lib/router.svelte';
+  import { openEntryTarget } from '../lib/walk';
+
+  interface Props {
+    project?: string | null;
+  }
+  let { project = null }: Props = $props();
+
+  $effect(() => {
+    void palette.ensureEntries();
+  });
+
+  let panel = $derived(buildEntryPanel(palette.entries));
+
+  /** The row a flow is being drawn from, and the name it will start at. */
+  let armed = $state<{ id: string; name: string } | null>(null);
+  let reaches = $state('');
+  let input: HTMLInputElement | null = $state(null);
+
+  // A refetch (the index moved) can retire the armed row. Dropping the arming
+  // is the honest response: the symbol it named may not be there any more.
+  $effect(() => {
+    const id = armed?.id;
+    if (id && !panel.rows.some((row) => row.id === id)) armed = null;
+  });
+
+  function open(row: EntryRow): void {
+    openEntryTarget(row.target);
+  }
+
+  function draw(from: string, to: string): void {
+    const pair = flowPair(from, to);
+    if (!pair) return;
+    armed = null;
+    reaches = '';
+    navigate(flowHref(pair));
+  }
+
+  function onflow(row: EntryRow): void {
+    if (!row.flowFrom) return;
+    if (armed === null) {
+      armed = { id: row.id, name: row.flowFrom };
+      reaches = '';
+      // The input is the faster path for anyone who already knows the other
+      // end; focusing it costs nothing to anyone who would rather click a row.
+      queueMicrotask(() => input?.focus());
+      return;
+    }
+    if (armed.id === row.id) {
+      armed = null;
+      return;
+    }
+    draw(armed.name, row.flowFrom);
+  }
+
+  function onkeydown(event: KeyboardEvent): void {
+    if (event.key === 'Escape') {
+      event.preventDefault();
+      armed = null;
+    }
+  }
+</script>
+
+<div class="scroll">
+  <div class="head">
+    <h2>Entry points</h2>
+    <p>
+      Where a flow starts{project ? ` in ${project}` : ''} — every list below is read out of the
+      graph, not guessed from a filename. Open a row to read the code, or use
+      <span class="chiplike">Flow ›</span> to draw the path from it to a second symbol.
+    </p>
+  </div>
+
+  {#if armed}
+    <div class="arming" role="group" aria-label="Draw a flow">
+      <span class="from">{armed.name}</span>
+      <span class="arrow" aria-hidden="true">→</span>
+      <input
+        bind:this={input}
+        bind:value={reaches}
+        {onkeydown}
+        type="text"
+        autocomplete="off"
+        spellcheck="false"
+        placeholder="a symbol it reaches"
+        aria-label={`The symbol ${armed.name} should reach`}
+        onkeypress={(event) => {
+          if (event.key === 'Enter' && armed) draw(armed.name, reaches);
+        }}
+      />
+      <button
+        type="button"
+        class="go"
+        disabled={flowPair(armed.name, reaches) === null}
+        onclick={() => armed && draw(armed.name, reaches)}>Draw the flow</button
+      >
+      <button type="button" class="cancel" onclick={() => (armed = null)}>Cancel</button>
+      <span class="hint">or pick the other end with <span class="chiplike">→ here</span></span>
+    </div>
+  {/if}
+
+  {#if palette.entriesFailure}
+    <p class="state">Could not read the entry points — {palette.entriesFailure}</p>
+  {:else if !palette.entriesSettled}
+    <p class="state">Reading the graph…</p>
+  {:else if panel.empty}
+    <p class="state">{panel.empty}</p>
+  {:else}
+    <div class="sections">
+      {#each panel.sections as section (section.id)}
+        <EntrySection {section} armed={armed?.id ?? null} onopen={open} {onflow} />
+      {/each}
+    </div>
+  {/if}
+</div>
+
+<style>
+  .scroll {
+    height: 100%;
+    overflow: auto;
+  }
+
+  .head {
+    max-width: 760px;
+    padding: 26px 40px 6px;
+  }
+
+  .head h2 {
+    margin: 0 0 6px;
+    font-size: 20px;
+    font-weight: 600;
+    letter-spacing: -0.01em;
+  }
+
+  .head p {
+    margin: 0;
+    color: var(--ink-2);
+    font-size: 13px;
+    line-height: 1.45;
+  }
+
+  .chiplike {
+    padding: 0 4px;
+    border: 1px solid var(--rule-soft);
+    color: var(--ink-2);
+    font: 11px var(--mono);
+  }
+
+  .arming {
+    position: sticky;
+    top: 0;
+    z-index: 4;
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 8px;
+    margin: 12px 40px 0;
+    padding: 8px 12px;
+    border: 1px solid var(--accent-line);
+    background: var(--accent-soft);
+  }
+
+  .arming .from {
+    color: var(--ink);
+    font: 500 12.5px var(--mono);
+  }
+
+  .arming .arrow {
+    color: var(--ink-3);
+  }
+
+  .arming input {
+    width: 220px;
+    height: 26px;
+    padding: 0 8px;
+    border: 1px solid var(--rule-soft);
+    background: var(--paper);
+    color: var(--ink);
+    font: 12.5px var(--mono);
+  }
+
+  .arming input:focus {
+    border-color: var(--ink);
+    outline: none;
+  }
+
+  .arming button {
+    height: 26px;
+    padding: 0 10px;
+    border: 1px solid var(--rule-soft);
+    background: var(--paper);
+    color: var(--ink-2);
+    font-size: 12px;
+  }
+
+  .arming button:hover:not(:disabled) {
+    border-color: var(--ink);
+    color: var(--ink);
+  }
+
+  .arming button:disabled {
+    color: var(--ink-4);
+    cursor: default;
+  }
+
+  .arming .hint {
+    color: var(--ink-3);
+    font-size: 11.5px;
+  }
+
+  .state {
+    max-width: 760px;
+    padding: 16px 40px 40px;
+    color: var(--ink-3);
+    font-size: 12.5px;
+    line-height: 1.5;
+  }
+
+  .sections {
+    max-width: 760px;
+    margin: 14px 40px 48px;
+    border: 1px solid var(--rule-soft);
+  }
+</style>

+ 36 - 7
ui/src/views/HomeView.svelte

@@ -5,15 +5,19 @@
    * Nothing selected is the normal first state of a viewer opened on a project
    * nobody has read before, so it carries the same entry points the palette
    * shows at rest, at full length: the routes a request arrives on, the files
-   * that run something at module level, and the symbols the most code depends
-   * on. Every one of them is derived from the graph — see
-   * `src/ui-server/api/entrypoints.ts` for what each is derived from.
+   * that run something at module level, the tests that exercise the most of the
+   * project, and the symbols the most code depends on. Every one of them is
+   * derived from the graph — see `src/ui-server/api/entrypoints.ts` for what
+   * each is derived from.
+   *
+   * The full-length version, with the same rows grouped by file and able to
+   * start a flow, is `#/entry` (`EntryView`); this screen links to it.
    */
   import PaletteRows from '../components/PaletteRows.svelte';
   import { palette } from '../lib/palette.svelte';
   import { buildEntryPalette, type PaletteItem } from '../lib/search-model';
-  import { fileHref, flowHref, navigate } from '../lib/router.svelte';
-  import { walkTo } from '../lib/walk';
+  import { entryHref, fileHref, flowHref, navigate } from '../lib/router.svelte';
+  import { openEntryTarget, walkTo } from '../lib/walk';
 
   interface Props {
     project?: string | null;
@@ -33,6 +37,10 @@
       navigate(flowHref({ from: item.from, to: item.to }));
       return;
     }
+    if (item.type === 'entry') {
+      openEntryTarget(item.row.target);
+      return;
+    }
     const id = item.type === 'route' ? item.nodeId : item.id;
     if (!id) return;
     // A file opens the File view — its outline plus the import rails. The
@@ -65,7 +73,10 @@
 
   {#if entries.sections.length > 0}
     <section class="entries" aria-label="Where to start">
-      <h3>Where to start</h3>
+      <div class="entries-h">
+        <h3>Where to start</h3>
+        <a href={entryHref()}>All entry points ›</a>
+      </div>
       <div class="rows">
         <PaletteRows palette={entries} onpick={pick} />
       </div>
@@ -90,12 +101,30 @@
     padding: 8px 40px 48px;
   }
 
+  .entries-h {
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    gap: 12px;
+    margin-bottom: 8px;
+  }
+
   .entries h3 {
-    margin: 0 0 8px;
+    margin: 0;
     font-size: 14px;
     font-weight: 600;
   }
 
+  .entries-h a {
+    color: var(--ink-2);
+    font-size: 12px;
+  }
+
+  .entries-h a:hover {
+    color: var(--ink);
+    text-decoration: underline;
+  }
+
   .rows {
     border: 1px solid var(--rule-soft);
   }