diff --git a/.gitignore b/.gitignore index 47d16c06c..b85152cc0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ node_modules/ # Build output dist/ +# svelte-package's scratch dir (ui/ library build) +.svelte-kit/ + .cmem # IDE diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca5b9ff1..78d87e111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,83 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- **Read your graph in a browser: `codegraph ui`.** Point it at a project you've already indexed and it opens a viewer for it on your own machine. Pick a symbol and you see who calls it on the left, its real source in the middle with a marker on every line that calls something, and what it calls on the right, each one drawn level with the line that calls it. Hover either end and both light up; click anything to step into it. Test callers fold into a single line so real callers stay in view, edges CodeGraph isn't confident about are folded away as "uncertain" rather than shown as fact, and a symbol no test reaches within three caller hops says so on a badge. A blast-radius strip counts what a change would reach. Search with `/` or Cmd-K across every symbol and file, start from suggested entry points (routes, hubs, files that run code when imported), and follow a trail of the path you walked that lives in the URL, so you can send someone the exact route you took. Click any file path for that file's outline in source order between everything it depends on and everything that depends on it. + + Run `codegraph ui` in an indexed project, or `codegraph ui /path/to/project` for one indexed elsewhere (`codegraph web` is an alias). It takes port 4747, or the next free one; `--port ` pins a specific port and `--no-open` just prints the URL for a headless box or an SSH session. Set `CODEGRAPH_BROWSER=` to choose the browser, or `CODEGRAPH_BROWSER=none` to never open one. + + The viewer listens on `127.0.0.1` only, so nothing on your network can reach it, and requests claiming to come from any other host are refused. It opens an index that already exists, never creates one, and never changes your graph or a line of your code — the one thing it writes is a trail you asked it to save (see below), and `--read-only` turns even that off. It sends nothing anywhere. + +- **A map of the whole project, in `codegraph ui`.** The Map tab draws your repository at module granularity — one box per directory — with dependencies pointing down, so the top of the picture is what runs first and the bottom is what everything else stands on. Nothing is placed by hand and nothing floats: a module sits one layer above whatever it depends on, line weight is how many calls, imports and type references cross the link, and the same project always draws the same picture. Hover a link for what crosses it, including the busiest symbol pairs behind the weight; click a module to isolate its links, list its dependencies and dependents with counts, and jump straight into one of its files. + + It says what it leaves out. Links carrying only a handful of references stay hidden until you select a module they touch, references CodeGraph isn't confident about are excluded from every count on the screen and the number is printed, and mutual dependencies, module loops and circular imports between files are listed rather than straightened away. The vertical order rests on the dependencies your code actually writes down — imports, qualified names, inheritance, typed receivers — because a method name shared by two unrelated folders should not be able to move a box. + + It opens on your project's source directory; a picker switches to any other top-level folder or the whole repository, a checkbox brings tests in, and `depth` splits a large folder into its sub-folders — useful on a monorepo. What you're looking at lives in the address, so the view is shareable. + +- **Ask how one symbol reaches another, in `codegraph ui`.** Type "how does execute reach getFile" into the search box — or `execute -> getFile` — and the Flow strip draws the call path between them, left to right, one card per hop. Each card is opened at the exact line that makes the next call rather than at the top of the function, so reading the strip is reading the handful of lines that actually carry the work; the identifier being called is a link, and clicking a card opens it in the symbol screen with the trail already set to the path you've read so far. + + A dashed link is a hop nobody can see in the source — a callback, an interface dispatch, a React re-render, a JSX child — and it names the mechanism and, where CodeGraph knows it, the exact line the handler was wired at. When a name means several definitions, the strip says so and names the one the path runs through, offers the alternatives in a picker, and can draw them together as one branching diagram. + + The **"Read as flow"** button on the trail turns a walk you did by hand into the same strip. It is the same path finder `codegraph_explore` leads its answers with, so the picture and what your agent tells you can't disagree. + +- **When a path runs out, the Flow strip says where — and why.** A flow that doesn't reach what you asked about now ends in a small block: *"Where the graph stops."* It names the kind of dispatch that ended it — a computed member call, a `getattr`, a reflective invoke, a typed message bus — and the line it's on, and the card beside it opens at that exact line so you can read the code the block is talking about. Where the key is written in the source (`handlers['save']`) it shows the key and shortlists the symbols that could be on the other side, marking any you already named; where the key is a runtime value it says so rather than guessing. + + It also lists what CodeGraph chose not to follow: name-only matches it wasn't confident enough about, with their confidence, and a count of the other calls the symbol makes that this path didn't need. Nothing is invented — no edge is guessed and none is added to your graph — and a flow that does reach what you asked for never shows the block at all. It's the same finding `codegraph_explore` announces to your agent when a flow breaks, so the screen and the answer agree. + +- **Read a whole file, with its call graph in the margin, in `codegraph ui`.** The file screen gained a **Source** tab: the file itself, top to bottom, with the same gutter markers as the symbol view and the same right-hand list of what each line calls, positioned level with the line that calls it. A 6,800-line file scrolls at full speed — only the lines on screen are ever drawn, and the text pages in behind you while the markers are there from the first frame. + + In the left margin is an arc for every call that stays inside the file, drawn from the calling line to the line the callee is defined on. Nothing is laid out by an algorithm — the author already put the symbols in order, so source order does the work, and this is the one place a file's internal call structure is legible at a glance. Hover a line to light the arcs the function under your cursor takes part in, and click an arc to jump to the other end. On a file with more than forty of them the picture narrows to the symbol you're reading instead of drawing a wash of overlapping sweeps, with the total in the header. A rail on the far left lists the file's symbols and follows you as you scroll, when the window is wide enough for it. + +- **The viewer keeps up with your project while it's open.** Save a file and `codegraph ui` says so within about a third of a second: a banner on the file's screen explaining that the index hasn't caught up yet, and the file's **current** source in place of a body sliced at line numbers it no longer has — the call arcs, gutter markers and call list go away with the old numbering rather than pointing at the wrong lines. It's the same answer `codegraph_node` gives your agent about a file that changed after its last sync. + + When anything re-indexes the project — your agent's background sync, `codegraph sync`, a git hook — whatever is on screen re-reads the graph and a small "Index updated · reloaded" note appears. A symbol that moved because you added a line above it is followed to its new place, with your trail intact, instead of turning into a dead link. + + 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. + +- **Take a flow or a map with you: copy it as an image, or save it as an SVG.** The Flow strip and the Map both gained **Copy image** and **Download SVG**. Copy image puts a PNG on your clipboard, ready to paste into a pull-request comment or a chat — the fastest way to say "here is what your change actually touches" without asking anyone to install anything. Download SVG saves a file for a README: it is real text rather than a bitmap, so it stays sharp at any size and the symbol names in it are selectable. + + Both render the light theme whichever one you are reading in, because the image is going to be read on somebody else's screen, and both carry a caption saying what the picture is. What comes out is exactly what is on screen — the same hops, the same dashed dynamic-dispatch links with their wiring sites, the same "where the graph stops" block, the same modules dimmed or brought forward by your selection — because the image is drawn from the same measurements the screen is, not photographed off it. An eight-hop strip comes out around half a megabyte, well inside what GitHub takes inline. + +- **"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. + +- **Syntax colouring in `codegraph ui` now comes from CodeGraph's own reading of your code.** The viewer used to run a second syntax highlighter over source CodeGraph had already parsed, with its own separate set of grammars. It doesn't any more: the colouring is taken straight from the parse that built your graph, so a file is coloured by exactly the grammar that decided what its symbols are. Three things you will notice — the name a definition declares now stands out on the line that declares it, wherever it appears; calls written inside a string (`${user.name()}`, `#{...}`, `$"{...}"`) are read as code and are now clickable links like every other call site; and built-in type words such as `string`, `int` and `void` look the same in every language instead of one way in Go and another in TypeScript. A big file paints far faster, most visibly in TypeScript, which was by a wide margin the slowest before. + + Two formats change for the worse and it is worth saying so: Liquid, Razor, YAML, Twig, XML and `.properties` files are shown without colouring now, and in `.svelte`, `.vue` and `.astro` files the `\n', 'svelte'); + expect(regions).toHaveLength(1); + expect(regions?.[0]?.language).toBe('typescript'); + }); + + it('has no grammar for the formats that only have file-level extraction', () => { + for (const language of ['yaml', 'xml', 'properties', 'twig', 'unknown']) { + expect(grammarFor(language)).toBeNull(); + } + }); +}); + +describe('classification', () => { + beforeAll(() => clearHighlightCache()); + + it('reads TypeScript with the classes the theme paints', async () => { + const result = await highlightLines(['const answer = 42; // note'], { + language: 'typescript', + }); + expect(result.engine).toBe('tree-sitter'); + expect(result.grammar).toBe('typescript'); + expect(result.classes).toEqual([...TOKEN_CLASSES]); + const rendered = shape(result, 0); + expect(rendered).toContain('keyword:const'); + expect(rendered).toContain('ident:answer'); + expect(rendered).toContain('number:42'); + expect(rendered).toContain('comment:// note'); + }); + + it('reads a # comment as a comment in Python and as code in TypeScript', async () => { + const python = await highlightLines(['x = 1 # note'], { language: 'python' }); + expect(shape(python, 0).at(-1)).toBe('comment:# note'); + + const ts = await highlightLines(['x = 1 # note'], { language: 'typescript' }); + expect(shape(ts, 0).at(-1)).not.toBe('comment:# note'); + }); + + it('carries a block comment across lines within one slice', async () => { + const result = await highlightLines(['/* open', 'still comment', 'done */ const x = 1;'], { + language: 'typescript', + }); + expect(shape(result, 1)).toEqual(['comment:still comment']); + expect(shape(result, 2)[0]).toBe('comment:done */'); + expect(shape(result, 2)).toContain('keyword:const'); + }); + + it('reads Go, which has its own idea of what a keyword is', async () => { + const result = await highlightLines(['func Greet(name string) string {'], { language: 'go' }); + expect(shape(result, 0)).toContain('keyword:func'); + expect(shape(result, 0)).toContain('def:Greet'); + }); + + it('reads ArkTS with its own grammar, not TypeScript’s', async () => { + const result = await highlightLines(['@Entry struct Index { build() {} }'], { + language: 'arkts', + }); + expect(result.engine).toBe('tree-sitter'); + expect(result.grammar).toBe('arkts'); + }); + + it('does not read a type annotation’s `string` as a string literal', async () => { + // An anonymous tree-sitter node's type IS its text, so `string` in a + // signature arrives as a node literally typed `string`. Reading that as a + // string literal greys out half of every signature in TypeScript and PHP. + for (const [language, line] of [ + ['typescript', 'function put(key: string): void {}'], + ['php', ' { + // The grammars disagree: `string` is a `type_identifier` in Go and an + // anonymous token inside a `predefined_type` in TypeScript. Left alone that + // is one word painting two ways on the same screen. + for (const [language, line] of [ + ['typescript', 'let a: string;'], + ['go', 'var a string'], + ['csharp', 'string a;'], + ['rust', 'let a: u32 = 1;'], + ] as const) { + const rendered = shape(await highlightLines([line], { language }), 0); + expect(rendered.some((t) => t.startsWith('type:'))).toBe(true); + expect(rendered.some((t) => t === 'keyword:string' || t === 'keyword:u32')).toBe(false); + } + }); + + it('keeps a template literal’s interpolated call as code, so it can link', async () => { + const line = 'const s = `n=${store.size()} done`;'; + const result = await highlightLines([line], { language: 'typescript' }); + expect(shape(result, 0)).toContain('ident:size'); + expect(claimedText(result, 0, lineRef({ ident: 'size' }))).toBe('size'); + }); + + it('marks a definition’s own name, from the extractor’s tables', async () => { + const cases: [string, string, string][] = [ + ['typescript', 'export class Store {}', 'Store'], + ['python', 'def put(self):', 'put'], + ['rust', 'pub fn put(&self) {}', 'put'], + ['ruby', 'class Store', 'Store'], + ['csharp', 'public class Store {}', 'Store'], + ['swift', 'final class Store {}', 'Store'], + ]; + for (const [language, line, name] of cases) { + const result = await highlightLines([line], { language }); + expect(shape(result, 0)).toContain(`def:${name}`); + } + }); + + it('emits one entry per source line, always', async () => { + const lines = ['a();', '', 'b();', '']; + const result = await highlightLines(lines, { language: 'typescript' }); + // The code block indexes rows positionally: one short answer and every + // line below it renders the wrong source. + expect(result.lines).toHaveLength(lines.length); + expect(result.lines[1]).toEqual([]); + }); + + it('reproduces every line of a real file exactly', async () => { + // The code block renders these tokens and nothing else, so a dropped or + // duplicated character is a corrupted file on screen — silently. + const file = path.join(__dirname, '..', 'src', 'ui-server', 'api', 'source.ts'); + const lines = fs.readFileSync(file, 'utf-8').split('\n'); + const result = await highlightLines(lines, { language: 'typescript' }); + expect(result.engine).toBe('tree-sitter'); + result.lines.forEach((row, i) => { + expect(row.map(([, text]) => text).join('')).toBe(lines[i]); + }); + }); + + it('classifies a component’s script and leaves its markup plain', async () => { + const lines = [ + '', + '', + '', + ]; + const result = await highlightLines(lines, { language: 'svelte' }); + expect(result.engine).toBe('tree-sitter'); + expect(shape(result, 1)).toContain('keyword:let'); + // The markup still splits into identifiers, so a call site in it links. + expect(claimedText(result, 4, lineRef({ ident: 'bump' }))).toBe('bump'); + expect(result.lines.map((row) => row.map(([, t]) => t).join(''))).toEqual(lines); + }); +}); + +describe('the plain fallback', () => { + beforeAll(() => clearHighlightCache()); + + it('answers plain, with a reason, for a language no grammar covers', async () => { + const result = await highlightLines(['whatever this is'], { language: 'unknown' }); + expect(result.engine).toBe('plain'); + expect(result.grammar).toBeNull(); + expect(result.reason).toBeTruthy(); + expect(result.lines).toHaveLength(1); + }); + + it('still splits identifiers when it cannot highlight, so the links land', async () => { + const result = await highlightLines([' return this.mutex.withLock();'], { + language: 'unknown', + }); + expect(claimedText(result, 0, lineRef({ ident: 'withLock', col: 9 }))).toBe('withLock'); + }); + + it('refuses to classify a minified line rather than wedging on it', async () => { + const enormous = 'a'.repeat(MAX_HIGHLIGHT_CHARS + 1); + const result = await highlightLines([enormous], { language: 'javascript' }); + expect(result.engine).toBe('plain'); + expect(result.reason).toMatch(/minified/); + // The source still comes back whole — that is the part that matters. + expect(result.lines[0]?.map(([, text]) => text).join('')).toHaveLength(enormous.length); + }); + + it('answers plain for a component whose script block is empty', async () => { + const result = await highlightLines(['

hello

'], { language: 'svelte' }); + expect(result.engine).toBe('plain'); + expect(result.lines[0]?.map(([, text]) => text).join('')).toBe('

hello

'); + }); +}); + +describe('graph links land on the right token', () => { + beforeAll(() => clearHighlightCache()); + + it('marks the callee, not the receiver the recorded column points at', async () => { + // The recorded column is the start of the calling EXPRESSION — `this` — + // and the underline has to end up on `withLock`. + const line = ' return this.indexMutex.withLock(async () => {'; + const result = await highlightLines([line], { language: 'typescript' }); + expect(claimedText(result, 0, lineRef({ ident: 'withLock', col: line.indexOf('this') }))).toBe( + 'withLock' + ); + }); + + it('lands on a real call site in the engine’s own src/index.ts', async () => { + const file = path.join(__dirname, '..', 'src', 'index.ts'); + const source = fs.readFileSync(file, 'utf-8').split('\n'); + // A line the engine actually contains, found rather than hard-coded, so a + // refactor of index.ts retires this test instead of silently passing. + const index = source.findIndex((l) => /^\s*(?:return |const \w+ = )?this\.\w+\.\w+\(/.test(l)); + expect(index).toBeGreaterThanOrEqual(0); + const line = source[index] as string; + const match = /this\.(\w+)\.(\w+)\(/.exec(line) as RegExpExecArray; + const callee = match[2] as string; + + const result = await highlightLines([line], { language: 'typescript' }); + expect(claimedText(result, 0, lineRef({ ident: callee, col: line.indexOf('this') }))).toBe( + callee + ); + }); + + it('lands on a Go method call', async () => { + const line = '\tresult := s.repo.FindByID(ctx, id)'; + const result = await highlightLines([line], { language: 'go' }); + expect(claimedText(result, 0, lineRef({ ident: 'FindByID', col: line.indexOf('s.repo') }))).toBe( + 'FindByID' + ); + }); + + it('lands on a Python method call, not on the receiver of the same name', async () => { + const line = ' return self.store.join(self.store.path)'; + const result = await highlightLines([line], { language: 'python' }); + expect(claimedText(result, 0, lineRef({ ident: 'join', col: line.indexOf('self') }))).toBe( + 'join' + ); + }); + + it('leaves a word inside a comment or a string alone', async () => { + const result = await highlightLines( + [' // call render here', ' const s = "render";'], + { language: 'typescript' } + ); + expect(claimedText(result, 0, lineRef({ ident: 'render' }))).toBeUndefined(); + expect(claimedText(result, 1, lineRef({ ident: 'render' }))).toBeUndefined(); + }); + + it('keeps every identifier separately claimable', async () => { + const result = await highlightLines(['render(); render();'], { language: 'typescript' }); + const tokens = tokensOf(result, 0); + const claimed = assignRefs(tokens, [ + lineRef({ ident: 'render', targetId: 'a' }), + lineRef({ ident: 'render', targetId: 'b' }), + ]); + expect(claimed.size).toBe(2); + }); + + it('keeps a type name claimable — it is a distinct class, not an excluded one', async () => { + const result = await highlightLines(['let store: Store = make();'], { language: 'typescript' }); + expect(shape(result, 0)).toContain('type:Store'); + expect(claimedText(result, 0, lineRef({ ident: 'Store' }))).toBe('Store'); + }); + + it('reproduces the line exactly — the code block renders these tokens', async () => { + const line = ' const s = `a ${b.c()} d`; // 1 + 2'; + const result = await highlightLines([line], { language: 'typescript' }); + expect( + tokensOf(result, 0) + .map((t) => t.text) + .join('') + ).toBe(line); + }); +}); + +describe('cost', () => { + it('classifies three thousand lines of TypeScript well inside the budget', async () => { + clearHighlightCache(); + const lines = fs + .readFileSync(path.join(__dirname, '..', 'src', 'extraction', 'tree-sitter.ts'), 'utf-8') + .split('\n') + .slice(0, 3000); + // Warm the grammar load, which is a one-off per language per process. + await highlightLines(lines.slice(0, 5), { language: 'typescript' }); + clearHighlightCache(); + + const started = Date.now(); + const result = await highlightLines(lines, { language: 'typescript' }); + const elapsed = Date.now() - started; + + expect(result.engine).toBe('tree-sitter'); + // The whole point of CG-57's swap: the TextMate grammar took ~700 ms here. + // Generous against a loaded CI box; the dev Mac measures 24–41 ms. + expect(elapsed).toBeLessThan(400); + }); + + it('answers a cached slice without re-classifying it', async () => { + clearHighlightCache(); + const lines = fs + .readFileSync(path.join(__dirname, '..', 'src', 'ui-server', 'api', 'source.ts'), 'utf-8') + .split('\n'); + + const cold = Date.now(); + await highlightLines(lines, { language: 'typescript', cacheKey: 'a:1:9999' }); + const coldMs = Date.now() - cold; + + const warm = Date.now(); + const second = await highlightLines(lines, { language: 'typescript', cacheKey: 'a:1:9999' }); + const warmMs = Date.now() - warm; + + expect(second.engine).toBe('tree-sitter'); + // The cache is what makes a re-render free: every resize, theme flip and + // step back through the trail re-asks for the same slice. + expect(warmMs).toBeLessThan(Math.max(20, coldMs / 4)); + }); + + it('bounds the cache by total lines, not just by entry count', async () => { + clearHighlightCache(); + const big = new Array(Math.ceil(SLICE_CACHE_LINES / 2) + 10).fill('x'); + // The entry count alone would let a reader left open on a big repo grow + // without limit: three of these is well inside SLICE_CACHE_LIMIT and well + // over the line budget. + for (const key of ['one', 'two', 'three']) { + await highlightLines(big, { language: 'unknown', cacheKey: key }); + } + const stats = highlightCacheStats(); + expect(stats.entries).toBeLessThan(3); + expect(stats.lines).toBeLessThanOrEqual(SLICE_CACHE_LINES); + }); + + it('keys the cache on the content, so an edited file re-classifies', async () => { + clearHighlightCache(); + const first = await highlightLines(['const a = 1;'], { + language: 'typescript', + cacheKey: 'hash-one:1:1', + }); + const second = await highlightLines(['const bbb = 2;'], { + language: 'typescript', + cacheKey: 'hash-two:1:1', + }); + expect(first.lines[0]?.map(([, t]) => t).join('')).toBe('const a = 1;'); + expect(second.lines[0]?.map(([, t]) => t).join('')).toBe('const bbb = 2;'); + }); +}); + +describe('the classifier itself', () => { + it('covers the source with ordered, non-overlapping spans', async () => { + const source = fs + .readFileSync(path.join(__dirname, '..', 'src', 'ui-server', 'api', 'flow.ts'), 'utf-8') + .slice(0, 40_000); + await initGrammars(); + await loadGrammarsForLanguages(['typescript']); + const parser = getParser('typescript'); + expect(parser).not.toBeNull(); + const tree = (parser as NonNullable).parse(source); + const spans = classifyTree((tree as NonNullable).rootNode, source, 'typescript'); + + expect(spans.length).toBeGreaterThan(1000); + let previous = 0; + for (const span of spans) { + expect(span.start).toBeGreaterThanOrEqual(previous); + expect(span.end).toBeGreaterThan(span.start); + previous = span.end; + } + expect(previous).toBeLessThanOrEqual(source.length); + // Everything the walk did not claim is whitespace the caller fills in. + const uncovered: string[] = []; + let at = 0; + for (const span of spans) { + if (span.start > at) uncovered.push(source.slice(at, span.start)); + at = span.end; + } + expect(uncovered.every((gap) => gap.trim() === '')).toBe(true); + }); +}); diff --git a/__tests__/ui-map-api.test.ts b/__tests__/ui-map-api.test.ts new file mode 100644 index 000000000..5feb9ca0d --- /dev/null +++ b/__tests__/ui-map-api.test.ts @@ -0,0 +1,446 @@ +/** + * `GET /api/map` — the module aggregation behind the Map (CG-49). + * + * Against a real indexed fixture over a real loopback server, like the rest of + * the viewer's API suite. The fixture is shaped to produce exactly the things + * the endpoint has to get right and that a synthetic payload cannot prove: + * + * - a façade (`src/index.ts`) that must stay its own box rather than being + * folded in with the loose type declarations beside it, + * - real `imports` edges, so the `declared` subset is not always equal to the + * raw count and the layering has something trustworthy to rest on, + * - a two-file import cycle, so the file-level cycle report has a component to + * find, + * - a test directory, so the `test` flag and the root default can be checked. + * + * The pure layout — layering, cycle-breaking, ports — is tested without a + * server in `ui-map-model.test.ts`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as http from 'http'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; +import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server'; +import { moduleIdFor, normalizeRoot, pickDefaultRoot, resetMapCache } from '../src/ui-server/api/map'; + +let server: UiServerHandle; +let api: GraphApi; +let tempDir: string; +let projectRoot: string; + +function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> { + return new Promise((resolve, reject) => { + const req = http.request( + { + host: '127.0.0.1', + port: server.port, + path: requestPath, + method: 'GET', + headers: { Host: `127.0.0.1:${server.port}` }, + setHost: false, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => + resolve({ + status: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString('utf-8'), + type: res.headers['content-type'], + }) + ); + } + ); + req.on('error', reject); + req.end(); + }); +} + +async function getMap(query = ''): Promise { + const res = await request(`/api/map${query}`); + expect(res.type).toBe('application/json; charset=utf-8'); + expect(res.status).toBe(200); + return JSON.parse(res.body); +} + +function write(root: string, rel: string, body: string): void { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, body); +} + +beforeAll(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-map-')); + projectRoot = path.join(tempDir, 'project'); + + write(projectRoot, 'src/types.ts', `export interface Row {\n id: string;\n}\n`); + + write( + projectRoot, + 'src/db/schema.ts', + `export const TABLES = ['rows'];\n` + ); + // db -> core, the LIGHT direction of the mutual pair below. + write( + projectRoot, + 'src/db/store.ts', + `import { Row } from '../types'; +import { normalise } from '../core/util'; + +export class Store { + rows: Row[] = []; + put(row: Row): void { + this.rows.push(normalise(row)); + } +} +` + ); + + // util <-> store is a deliberate two-file import cycle: it gives the file + // cycle report a component to find and the module graph a mutual pair. + write( + projectRoot, + 'src/core/util.ts', + `import { Row } from '../types'; +import { Store } from '../db/store'; + +export function normalise(row: Row): Row { + return { id: row.id.trim() }; +} + +export function count(store: Store): number { + return store.rows.length; +} +` + ); + + // Two directory levels under `src`, so depth=2 has something real to split. + write( + projectRoot, + 'src/core/passes/trim.ts', + `import { Row } from '../../types'; + +export function trim(row: Row): Row { + return { id: row.id.slice(0, 8) }; +} +` + ); + // core -> db, several times over: the HEAVY direction. + write( + projectRoot, + 'src/core/engine.ts', + `import { Store } from '../db/store'; +import { TABLES } from '../db/schema'; +import { trim } from './passes/trim'; +import { Row } from '../types'; + +export class Engine { + store = new Store(); + boot(): string[] { + return TABLES; + } + add(row: Row): void { + this.store.put(trim(row)); + this.store.put(row); + } +} +` + ); + + write( + projectRoot, + 'src/api/handler.ts', + `import { Engine } from '../core/engine'; +import { Row } from '../types'; + +export function handle(engine: Engine, row: Row): void { + engine.add(row); +} +` + ); + write( + projectRoot, + 'src/api/routes.ts', + `import { Engine } from '../core/engine'; +import { handle } from './handler'; + +export function route(engine: Engine): void { + handle(engine, { id: 'x' }); +} +` + ); + + write( + projectRoot, + 'src/index.ts', + `import { Engine } from './core/engine'; +import { route } from './api/routes'; + +export function start(): void { + route(new Engine()); +} +` + ); + + write( + projectRoot, + '__tests__/engine.test.ts', + `import { Engine } from '../src/core/engine'; + +export function testBoot(): string[] { + return new Engine().boot(); +} +` + ); + + const cg = CodeGraph.initSync(projectRoot, { + config: { include: ['src/**/*.ts', '__tests__/**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + cg.resolveReferences(); + cg.close(); + + const viewerDir = path.join(tempDir, 'viewer'); + fs.mkdirSync(viewerDir, { recursive: true }); + fs.writeFileSync(path.join(viewerDir, 'index.html'), '
'); + + resetMapCache(); + api = createGraphApi({ projectRoot }); + server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler }); +}, 120_000); + +afterAll(async () => { + api?.close(); + await server?.close(); + resetMapCache(); + if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('moduleIdFor', () => { + it('names a module after the first `depth` segments under the root', () => { + expect(moduleIdFor('src/core/engine.ts', 'src', 1)).toEqual({ id: 'src/core', facade: false }); + expect(moduleIdFor('src/a/b/c.ts', 'src', 2)).toEqual({ id: 'src/a/b', facade: false }); + expect(moduleIdFor('a/b/c.ts', '', 1)).toEqual({ id: 'a', facade: false }); + }); + + it('keeps a façade as its own box and buckets the other loose files', () => { + expect(moduleIdFor('src/index.ts', 'src', 1)).toEqual({ id: 'src/index.ts', facade: true }); + expect(moduleIdFor('src/lib.rs', 'src', 1)?.facade).toBe(true); + expect(moduleIdFor('pkg/__init__.py', 'pkg', 1)?.facade).toBe(true); + expect(moduleIdFor('src/types.ts', 'src', 1)).toEqual({ + id: 'src/(root files)', + facade: false, + }); + expect(moduleIdFor('types.ts', '', 1)).toEqual({ id: '(root files)', facade: false }); + }); + + it('buckets a loose file into the directory it is actually in, not the top one', () => { + // Two segments at depth 2 is a loose file inside `src/a`, so it belongs to + // that directory's bucket. Folding it into `src/(root files)` would claim a + // file lives somewhere it does not. + expect(moduleIdFor('src/a/loose.ts', 'src', 2)).toEqual({ + id: 'src/a/(root files)', + facade: false, + }); + }); + + it('returns null for a file outside the root', () => { + expect(moduleIdFor('__tests__/x.test.ts', 'src', 1)).toBeNull(); + // A sibling whose name merely starts with the root is not under it. + expect(moduleIdFor('srcx/y.ts', 'src', 1)).toBeNull(); + }); +}); + +describe('normalizeRoot', () => { + it('treats `src`, `src/` and `./src` as one root', () => { + expect(normalizeRoot('src')).toBe('src'); + expect(normalizeRoot('src/')).toBe('src'); + expect(normalizeRoot('./src')).toBe('src'); + expect(normalizeRoot('src\\')).toBe('src'); + }); + + it('treats the repository root as the empty string however it is written', () => { + expect(normalizeRoot('')).toBe(''); + expect(normalizeRoot('.')).toBe(''); + expect(normalizeRoot('/')).toBe(''); + expect(normalizeRoot(undefined)).toBe(''); + }); +}); + +describe('pickDefaultRoot', () => { + it('picks the directory holding a clear majority of the non-test symbols', () => { + expect( + pickDefaultRoot([ + { path: 'src/a.ts', symbols: 80, test: false }, + { path: 'scripts/b.ts', symbols: 5, test: false }, + { path: '__tests__/c.ts', symbols: 900, test: true }, + ]) + ).toBe('src'); + }); + + it('falls back to the repository root when no directory dominates', () => { + expect( + pickDefaultRoot([ + { path: 'a/one.ts', symbols: 10, test: false }, + { path: 'b/two.ts', symbols: 10, test: false }, + { path: 'c/three.ts', symbols: 10, test: false }, + ]) + ).toBe(''); + expect(pickDefaultRoot([{ path: 'flat.ts', symbols: 4, test: false }])).toBe(''); + }); +}); + +describe('GET /api/map', () => { + it('is listed by the API index', async () => { + const res = await request('/api'); + const body = JSON.parse(res.body); + expect(body.endpoints.map((e: any) => e.path)).toContain('/api/map'); + }); + + it('opens on the source directory and keeps the façade its own box', async () => { + const map = await getMap(); + expect(map.root).toBe('src'); + expect(map.depth).toBe(1); + + const ids = map.modules.map((m: any) => m.id); + expect(ids).toEqual(['src/(root files)', 'src/api', 'src/core', 'src/db', 'src/index.ts']); + expect(map.modules.find((m: any) => m.id === 'src/core').files).toBe(3); + + const facade = map.modules.find((m: any) => m.id === 'src/index.ts'); + expect(facade.facade).toBe(true); + expect(facade.files).toBe(1); + expect(facade.symbols).toBeGreaterThan(0); + // Nothing under `src` is a test, so the default root already excludes them. + expect(map.modules.every((m: any) => m.test === false)).toBe(true); + }); + + it('offers every top-level directory as a root, plus the repository itself', async () => { + const map = await getMap(); + expect(map.roots[0]).toEqual({ root: '', label: 'whole repository', files: map.index.files }); + expect(map.roots.map((r: any) => r.root)).toEqual( + expect.arrayContaining(['', 'src', '__tests__']) + ); + }); + + it('counts cross-module edges only, with a declared subset and named pairs', async () => { + const map = await getMap(); + const link = map.links.find((l: any) => l.source === 'src/api' && l.target === 'src/core'); + expect(link).toBeTruthy(); + expect(link.count).toBeGreaterThan(0); + // Every kind's count has to add up to the link's own count, or the tooltip + // and the stroke width are describing two different things. + expect(link.byKind.reduce((sum: number, k: any) => sum + k.count, 0)).toBe(link.count); + // `import { Engine }` is a declared dependency; it must survive as one. + expect(link.declared).toBeGreaterThan(0); + expect(link.declared).toBeLessThanOrEqual(link.count); + expect(link.topPairs.length).toBeGreaterThan(0); + expect(link.topPairs.length).toBeLessThanOrEqual(4); + expect(link.topPairs.every((p: any) => p.declared <= p.count)).toBe(true); + + // No module ever links to itself: same-module edges are not dependencies. + expect(map.links.every((l: any) => l.source !== l.target)).toBe(true); + }); + + it('keeps the heavier direction of a mutual pair heavier', async () => { + const map = await getMap(); + const coreToDb = map.links.find((l: any) => l.source === 'src/core' && l.target === 'src/db'); + const dbToCore = map.links.find((l: any) => l.source === 'src/db' && l.target === 'src/core'); + expect(coreToDb).toBeTruthy(); + expect(dbToCore).toBeTruthy(); + expect(coreToDb.count).toBeGreaterThan(dbToCore.count); + }); + + it('reports the file-level cycle the fixture contains', async () => { + const map = await getMap(); + expect(map.cycles.total).toBeGreaterThanOrEqual(1); + const knot = map.cycles.items.find((c: any) => + c.files.includes('src/core/util.ts') && c.files.includes('src/db/store.ts') + ); + expect(knot, JSON.stringify(map.cycles)).toBeTruthy(); + expect(knot.size).toBe(knot.files.length); + expect(knot.modules).toEqual(expect.arrayContaining(['src/core', 'src/db'])); + expect(map.cycles.shown).toBe(map.cycles.items.length); + }); + + it('lists each module\'s files, capped, with the true total beside them', async () => { + const map = await getMap(); + for (const module of map.modules) { + expect(module.fileList.total).toBe(module.files); + expect(module.fileList.shown).toBe(module.fileList.items.length); + expect(module.fileList.truncated).toBe(module.fileList.shown < module.fileList.total); + expect(module.fileList.items).toEqual([...module.fileList.items].sort()); + } + // A module's files are everything BELOW it, not just the files directly in + // it: `src/core` at depth 1 owns `src/core/passes/trim.ts` too, and the + // panel's list has to match the count on the box. + const core = map.modules.find((m: any) => m.id === 'src/core'); + expect(core.fileList.items).toEqual([ + 'src/core/engine.ts', + 'src/core/passes/trim.ts', + 'src/core/util.ts', + ]); + }); + + it('says how many references the confidence floor excluded', async () => { + const map = await getMap(); + expect(map.excluded.confidenceBelow).toBe(0.6); + expect(map.excluded.uncertainEdges).toBeGreaterThanOrEqual(0); + }); + + it('answers the whole repository, where the tests are a test module', async () => { + const map = await getMap('?root=&depth=1'); + expect(map.root).toBe(''); + const ids = map.modules.map((m: any) => m.id); + expect(ids).toEqual(expect.arrayContaining(['src', '__tests__'])); + expect(map.modules.find((m: any) => m.id === '__tests__').test).toBe(true); + expect(map.modules.find((m: any) => m.id === 'src').test).toBe(false); + expect(map.links.some((l: any) => l.source === '__tests__' && l.target === 'src')).toBe(true); + }); + + it('splits deeper when asked, and `src/` is the same root as `src`', async () => { + const deep = await getMap('?root=src&depth=2'); + const ids = deep.modules.map((m: any) => m.id); + // A directory two levels down becomes its own box; a file loose one level + // down joins that level's bucket rather than being promoted to a module. + expect(ids).toContain('src/core/passes'); + expect(ids).toContain('src/core/(root files)'); + expect(ids).toContain('src/api/(root files)'); + expect(ids).not.toContain('src/core'); + + const slashed = await getMap('?root=src%2F&depth=2'); + expect(slashed.modules).toEqual(deep.modules); + }); + + it('rejects an out-of-range depth as JSON, not as a crash', async () => { + const res = await request('/api/map?depth=9'); + expect(res.status).toBe(400); + expect(res.type).toBe('application/json; charset=utf-8'); + const body = JSON.parse(res.body); + expect(body.code).toBe('bad-request'); + expect(body.error).toContain('depth'); + }); + + it('serves the second identical request from the cache, byte for byte', async () => { + // Other cases in this file have already warmed `src` at depth 1; the point + // here is the first-then-second transition, so start from a cold cache. + resetMapCache(); + const first = await getMap('?root=src&depth=1'); + const second = await getMap('?root=src&depth=1'); + expect(first.timing.cached).toBe(false); + expect(second.timing.cached).toBe(true); + // Everything except the timing stamp must be identical — a map that is not + // reproducible between two reloads is not a map of anything. + const strip = (m: any) => JSON.stringify({ ...m, timing: undefined }); + expect(strip(second)).toBe(strip(first)); + }); + + it('does not let one root\'s answer be served for another', async () => { + const src = await getMap('?root=src&depth=1'); + const all = await getMap('?root=&depth=1'); + expect(all.root).toBe(''); + expect(all.modules.map((m: any) => m.id)).not.toEqual(src.modules.map((m: any) => m.id)); + }); +}); diff --git a/__tests__/ui-map-model.test.ts b/__tests__/ui-map-model.test.ts new file mode 100644 index 000000000..d271a5460 --- /dev/null +++ b/__tests__/ui-map-model.test.ts @@ -0,0 +1,401 @@ +/** + * The Map's layout, without a browser (CG-49). + * + * The properties under test are the ones that make the picture mean something. + * A map is only worth reading if the vertical position of a box is a claim + * about the code — so the tests here are mostly about *why* a module ends up + * where it does: + * + * - the layering rests on `declared` weight, not raw counts, because bare name + * matching invents cross-module links out of shared method names; + * - a two-cycle keeps its heavier direction and the lighter one is reported, + * never quietly dropped; + * - the same payload always produces the same picture, because a diagram you + * cannot recognise between two visits is not a map of anything. + * + * The endpoint that feeds it is tested against a real index in + * `ui-map-api.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildMapLayout, + isEdgeVisible, + linkId, + moduleMetaLabel, + nodeWidth, + strokeWidthFor, + LAYER_GAP, + MIN_WEIGHT, + MIN_WEIGHT_WITH_TESTS, + NODE_HEIGHT, + type MapLayout, +} from '../ui/src/lib/map-model'; +import type { WireMapLink, WireMapModule } from '../ui/src/lib/api'; + +/* ------------------------------------------------------------- fixtures -- */ + +function mod(id: string, over: Partial = {}): WireMapModule { + return { + id, + label: id.slice(id.lastIndexOf('/') + 1) || id, + files: over.files ?? 3, + symbols: over.symbols ?? 30, + languages: over.languages ?? [{ language: 'typescript', files: over.files ?? 3 }], + test: over.test ?? false, + facade: over.facade ?? false, + fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] }, + }; +} + +function link( + source: string, + target: string, + count: number, + declared = count +): WireMapLink { + return { + source, + target, + count, + declared, + byKind: [{ kind: 'calls', count }], + topPairs: [], + }; +} + +function layerOf(layout: MapLayout, id: string): number { + const node = layout.nodes.find((n) => n.id === id); + expect(node, `no node ${id}`).toBeTruthy(); + return node!.layer; +} + +const OPTS = { includeTests: false }; + +/* ---------------------------------------------------------------- specs -- */ + +describe('nodeWidth', () => { + it('fits the wider of the two lines and never goes under the floor', () => { + expect(nodeWidth('ui')).toBe(110); + // A long id outgrows the floor; a long meta line outgrows a short id. + expect(nodeWidth('src/resolution/(root files)')).toBeGreaterThan(200); + expect(nodeWidth('src/db', '1218 symbols · 54 files')).toBeGreaterThan(nodeWidth('src/db')); + }); +}); + +describe('moduleMetaLabel', () => { + it('says the counts in singular when there is one of them', () => { + expect(moduleMetaLabel(mod('src/x', { symbols: 1, files: 1 }))).toBe('1 symbol · 1 file'); + expect(moduleMetaLabel(mod('src/x', { symbols: 9, files: 2 }))).toBe('9 symbols · 2 files'); + }); +}); + +describe('strokeWidthFor', () => { + it('grows with the logarithm of the count and stops at 6', () => { + expect(strokeWidthFor(1)).toBe(1); + expect(strokeWidthFor(700)).toBeLessThanOrEqual(6); + expect(strokeWidthFor(1_000_000)).toBe(6); + expect(strokeWidthFor(64)).toBeGreaterThan(strokeWidthFor(8)); + // A count of zero must not produce -Infinity. + expect(Number.isFinite(strokeWidthFor(0))).toBe(true); + }); +}); + +describe('layering', () => { + const modules = [mod('src/bin'), mod('src/core'), mod('src/db')]; + + it('puts a module one layer above everything it depends on', () => { + const layout = buildMapLayout( + { modules, links: [link('src/bin', 'src/core', 10), link('src/core', 'src/db', 10)] }, + OPTS + ); + expect(layerOf(layout, 'src/db')).toBe(0); + expect(layerOf(layout, 'src/core')).toBe(1); + expect(layerOf(layout, 'src/bin')).toBe(2); + // Layer 0 is the foundations, and it is drawn at the BOTTOM. + const bin = layout.nodes.find((n) => n.id === 'src/bin')!; + const db = layout.nodes.find((n) => n.id === 'src/db')!; + expect(bin.y).toBeLessThan(db.y); + expect(db.y - bin.y).toBe(2 * (NODE_HEIGHT + LAYER_GAP)); + }); + + it('names only the top and bottom layers', () => { + const layout = buildMapLayout( + { modules, links: [link('src/bin', 'src/core', 10), link('src/core', 'src/db', 10)] }, + OPTS + ); + expect(layout.layers.map((l) => l.label)).toEqual([ + 'foundations — depend on nothing below', + null, + 'entry points', + ]); + }); + + it('ignores a link with nothing declared behind it', () => { + // `src/db -> src/bin` is 40 name-only matches (`run`, `push`, `finish`) and + // would otherwise lift the storage layer above the CLI. It is still drawn — + // as a back-edge — but it must not decide the vertical order. + const layout = buildMapLayout( + { + modules, + links: [ + link('src/bin', 'src/core', 10, 10), + link('src/core', 'src/db', 10, 10), + link('src/db', 'src/bin', 40, 0), + ], + }, + OPTS + ); + expect(layout.basis.kind).toBe('declared'); + expect(layerOf(layout, 'src/db')).toBe(0); + expect(layerOf(layout, 'src/bin')).toBe(2); + const noisy = layout.edges.find((e) => e.source === 'src/db' && e.target === 'src/bin')!; + expect(noisy).toBeTruthy(); + expect(noisy.back).toBe(true); + }); + + it('falls back to raw counts, and says so, when almost nothing is declared', () => { + const layout = buildMapLayout( + { + modules, + links: [ + link('src/bin', 'src/core', 10, 0), + link('src/core', 'src/db', 10, 0), + link('src/db', 'src/core', 2, 1), + ], + }, + OPTS + ); + expect(layout.basis.kind).toBe('all'); + expect(layout.basis.declaredLinks).toBe(1); + expect(layout.basis.totalLinks).toBe(3); + expect(layout.basis.declaredLinks / layout.basis.totalLinks).toBeLessThan(0.4); + // With raw counts the chain is still a chain, and the light back-reference + // becomes the mutual one. + expect(layerOf(layout, 'src/db')).toBe(0); + expect(layerOf(layout, 'src/bin')).toBe(2); + expect(layout.mutual.map((m) => m.back.source)).toEqual(['src/db']); + }); + + it('survives a three-module loop instead of recursing forever', () => { + const layout = buildMapLayout( + { + modules, + links: [ + link('src/bin', 'src/core', 5), + link('src/core', 'src/db', 5), + link('src/db', 'src/bin', 5), + ], + }, + OPTS + ); + expect(layout.nodes).toHaveLength(3); + expect(layout.moduleCycles).toEqual([['src/bin', 'src/core', 'src/db']]); + // Every module still got a finite layer. + expect(layout.nodes.every((n) => Number.isInteger(n.layer))).toBe(true); + }); +}); + +describe('two-cycles', () => { + const modules = [mod('src/a'), mod('src/b')]; + + it('keeps the heavier direction and reports the lighter as mutual', () => { + const layout = buildMapLayout( + { modules, links: [link('src/a', 'src/b', 20), link('src/b', 'src/a', 3)] }, + OPTS + ); + expect(layerOf(layout, 'src/a')).toBe(1); + expect(layerOf(layout, 'src/b')).toBe(0); + expect(layout.mutual).toHaveLength(1); + expect(layout.mutual[0]!.forward.source).toBe('src/a'); + expect(layout.mutual[0]!.back.source).toBe('src/b'); + // Both directions are still on the canvas; the lighter one points up. + expect(layout.edges).toHaveLength(2); + expect(layout.edges.find((e) => e.source === 'src/b')!.back).toBe(true); + expect(layout.edges.find((e) => e.source === 'src/a')!.back).toBe(false); + }); + + it('breaks an exact tie the same way every time', () => { + const one = buildMapLayout( + { modules, links: [link('src/a', 'src/b', 7), link('src/b', 'src/a', 7)] }, + OPTS + ); + const two = buildMapLayout( + { modules, links: [link('src/b', 'src/a', 7), link('src/a', 'src/b', 7)] }, + OPTS + ); + expect(one.mutual[0]!.back.source).toBe('src/b'); + expect(two.mutual[0]!.back.source).toBe('src/b'); + expect(layerOf(one, 'src/a')).toBe(layerOf(two, 'src/a')); + }); +}); + +describe('tests and thresholds', () => { + const modules = [mod('src/core'), mod('__tests__', { test: true })]; + const links = [link('__tests__', 'src/core', 30), link('src/core', '__tests__', 2)]; + + it('leaves test modules out until they are asked for, and their links with them', () => { + const off = buildMapLayout({ modules, links }, { includeTests: false }); + expect(off.nodes.map((n) => n.id)).toEqual(['src/core']); + expect(off.edges).toHaveLength(0); + expect(off.minWeight).toBe(MIN_WEIGHT); + + const on = buildMapLayout({ modules, links }, { includeTests: true }); + expect(on.nodes).toHaveLength(2); + expect(on.edges).toHaveLength(2); + // A test module touches everything, so the bar for a visible link is higher. + expect(on.minWeight).toBe(MIN_WEIGHT_WITH_TESTS); + }); + + it('marks a link under the threshold thin rather than deleting it', () => { + const layout = buildMapLayout( + { + modules: [mod('src/a'), mod('src/b'), mod('src/c')], + links: [link('src/a', 'src/b', 12), link('src/a', 'src/c', 2)], + }, + OPTS + ); + const thin = layout.edges.find((e) => e.target === 'src/c')!; + expect(thin.thin).toBe(true); + expect(isEdgeVisible(thin, null)).toBe(false); + // Selecting either end brings it back — that is the whole point of hiding + // it rather than dropping it. + expect(isEdgeVisible(thin, 'src/a')).toBe(true); + expect(isEdgeVisible(thin, 'src/c')).toBe(true); + expect(isEdgeVisible(thin, 'src/b')).toBe(false); + + const fat = layout.edges.find((e) => e.target === 'src/b')!; + expect(isEdgeVisible(fat, null)).toBe(true); + expect(isEdgeVisible(fat, 'src/c')).toBe(false); + }); +}); + +describe('ports', () => { + it('gives every link its own port, ordered by where the other end sits', () => { + const layout = buildMapLayout( + { + modules: [mod('src/top'), mod('src/left'), mod('src/mid'), mod('src/right')], + links: [ + link('src/top', 'src/left', 9), + link('src/top', 'src/mid', 9), + link('src/top', 'src/right', 9), + ], + }, + OPTS + ); + const top = layout.nodes.find((n) => n.id === 'src/top')!; + expect(top.sourceHandles).toHaveLength(3); + expect(new Set(top.sourceHandles).size).toBe(3); + + // The handle order must follow the targets' left-to-right order, or the + // three edges cross each other inside the gap for no reason. + const xOf = (id: string) => { + const n = layout.nodes.find((m) => m.id === id)!; + return n.x + n.width / 2; + }; + const targets = top.sourceHandles.map( + (id) => layout.edges.find((e) => e.id === id)!.target + ); + const xs = targets.map(xOf); + expect(xs).toEqual([...xs].sort((a, b) => a - b)); + + // Each target's single incoming link is its only target handle. + for (const id of ['src/left', 'src/mid', 'src/right']) { + expect(layout.nodes.find((n) => n.id === id)!.targetHandles).toHaveLength(1); + } + }); + + it('names an edge by its endpoints, so two runs key the same', () => { + expect(linkId({ source: 'a', target: 'b' })).toBe(linkId({ source: 'a', target: 'b' })); + expect(linkId({ source: 'a', target: 'b' })).not.toBe(linkId({ source: 'b', target: 'a' })); + }); +}); + +describe('determinism', () => { + const modules = [ + mod('src/alpha'), + mod('src/beta'), + mod('src/gamma'), + mod('src/delta'), + mod('src/epsilon'), + ]; + const links = [ + link('src/alpha', 'src/beta', 12), + link('src/alpha', 'src/gamma', 8), + link('src/beta', 'src/delta', 15), + link('src/gamma', 'src/delta', 6), + link('src/delta', 'src/epsilon', 20), + link('src/beta', 'src/epsilon', 5), + ]; + + it('produces an identical layout from an identical payload', () => { + const a = buildMapLayout({ modules, links }, OPTS); + const b = buildMapLayout({ modules, links }, OPTS); + expect(JSON.stringify(b)).toBe(JSON.stringify(a)); + }); + + it('does not depend on the order the payload happened to arrive in', () => { + const a = buildMapLayout({ modules, links }, OPTS); + const b = buildMapLayout( + { modules: [...modules].reverse(), links: [...links].reverse() }, + OPTS + ); + const positions = (l: MapLayout) => + l.nodes + .map((n) => `${n.id}@${n.layer}:${Math.round(n.x)},${Math.round(n.y)}`) + .sort() + .join('|'); + expect(positions(b)).toBe(positions(a)); + }); + + it('places an unconnected module without stretching the canvas around it', () => { + const withIsland = buildMapLayout( + { modules: [...modules, mod('src/island')], links }, + OPTS + ); + const island = withIsland.nodes.find((n) => n.id === 'src/island')!; + expect(island).toBeTruthy(); + expect(island.layer).toBe(0); + // Parked at the right-hand end of its layer, not interleaved through the + // modules that actually connect. + const sameLayer = withIsland.nodes.filter((n) => n.layer === 0); + expect(Math.max(...sameLayer.map((n) => n.x))).toBe(island.x); + // And the canvas is no wider than the boxes standing shoulder to shoulder. + const widest = Math.max( + ...[0, 1, 2, 3].map((layer) => + withIsland.nodes + .filter((n) => n.layer === layer) + .reduce((sum, n) => sum + n.width, 0) + ) + ); + expect(withIsland.width).toBeLessThan(widest + 6 * 34 + 200); + }); +}); + +describe('empty and degenerate inputs', () => { + it('answers an empty payload without throwing', () => { + const layout = buildMapLayout({ modules: [], links: [] }, OPTS); + expect(layout.nodes).toHaveLength(0); + expect(layout.edges).toHaveLength(0); + expect(layout.basis.kind).toBe('all'); + expect(Number.isFinite(layout.width)).toBe(true); + expect(Number.isFinite(layout.height)).toBe(true); + }); + + it('drops a link whose other end was filtered out', () => { + const layout = buildMapLayout( + { + modules: [mod('src/a'), mod('__tests__', { test: true })], + links: [link('src/a', '__tests__', 9), link('src/a', 'src/ghost', 9)], + }, + OPTS + ); + expect(layout.edges).toHaveLength(0); + }); + + it('leaves a single layer unlabelled', () => { + const layout = buildMapLayout({ modules: [mod('src/only')], links: [] }, OPTS); + expect(layout.layers).toHaveLength(1); + expect(layout.layers[0]!.label).toBeNull(); + }); +}); diff --git a/__tests__/ui-package.test.ts b/__tests__/ui-package.test.ts new file mode 100644 index 000000000..05fadaa1e --- /dev/null +++ b/__tests__/ui-package.test.ts @@ -0,0 +1,746 @@ +/** + * `@colbymchenry/codegraph-ui` — the package's own test (task CG-61). + * + * A minimal Svelte host mounts the three headline components from the package + * entry against a MOCK adapter and asserts what lands in the document. That is + * the whole promise of the package in one file: CodeGraph Pro renders these + * same components over its own in-process engine reads, so if a screen can be + * drawn from an object literal here, it can be drawn from a graph there. + * + * The import is `ui/src/index.ts` — the package entry itself, not the + * components one by one — so a name dropped from the public surface fails here + * rather than in the Pro app. + * + * Everything below is deliberately about the SEAM, not about the screens: + * layout, geometry and the rails have their own suites (`ui-symbol-model`, + * `ui-flow-model`, `ui-map-model`). What is being proved here is that no + * component reaches past the adapter for anything. + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { flushSync, mount, unmount } from 'svelte'; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { + ArchitectureMap, + CodegraphUi, + FlowStrip, + SearchPalette, + SymbolView, + SavedTrails, + TrailBar, + TypeHierarchy, + createHttpAdapter, + fileHref, + flowHref, + getGraphAdapter, + hashNavigation, + live, + mapHref, + setGraphAdapter, + setNavigationDriver, + symbolHref, + trail, + type GraphAdapter, + type NavigationDriver, + type WireFlowPayload, + type WireMapPayload, + type WireNodeRef, + type WireSource, + type WireStats, + type WireHierarchy, + type WireSymbolPayload, +} from '../ui/src/index'; + +/* ---------------------------------------------------------------- fixtures */ + +const ROOT = join(import.meta.dirname, '..'); + +function nodeRef(overrides: Partial = {}): WireNodeRef { + return { + id: 'function:parseToken@src/auth/token.ts:12', + kind: 'function', + name: 'parseToken', + qualifiedName: 'parseToken', + file: 'src/auth/token.ts', + line: 12, + endLine: 18, + language: 'typescript', + test: false, + ...overrides, + }; +} + +const CALLER = nodeRef({ + id: 'function:handleCallback@src/auth/callback.ts:40', + name: 'handleCallback', + qualifiedName: 'handleCallback', + file: 'src/auth/callback.ts', + line: 40, + endLine: 60, +}); + +const CALLEE = nodeRef({ + id: 'function:decodeJwt@src/auth/jwt.ts:3', + name: 'decodeJwt', + qualifiedName: 'decodeJwt', + file: 'src/auth/jwt.ts', + line: 3, + endLine: 9, +}); + +const SYMBOL: WireSymbolPayload = { + node: { + ...nodeRef(), + startColumn: 0, + endColumn: 1, + lines: 7, + exported: true, + }, + ancestors: [nodeRef({ id: 'file:src/auth/token.ts', kind: 'file', name: 'token.ts' })], + members: { total: 0, shown: 0, truncated: false, items: [] }, + incoming: { + total: 1, + shown: 1, + truncated: false, + items: [ + { + node: CALLER, + edgeKinds: ['calls'], + edges: [{ kind: 'calls', line: 44, col: 6, confidence: 1 }], + edgeCount: 1, + lines: [44], + confidence: 1, + uncertain: false, + synthesized: false, + }, + ], + }, + outgoing: { + total: 1, + shown: 1, + truncated: false, + items: [ + { + node: CALLEE, + edgeKinds: ['calls'], + edges: [{ kind: 'calls', line: 14, col: 10, confidence: 1 }], + edgeCount: 1, + lines: [14], + confidence: 1, + uncertain: false, + synthesized: false, + }, + ], + }, + typesUsed: [], + hierarchy: null, + counts: { callers: 1, callees: 1, typesUsed: 0, fanIn: 1, fanOut: 1, members: 0, hub: false }, + tests: { reached: false, hops: null, fileCount: 0, files: [], exhaustive: true, hopsSearched: 3 }, + outsideIndex: { total: 0, byKind: {}, samples: [] }, + blast: { + direct: 1, + withinHops: 2, + hops: 3, + files: 2, + testFiles: 0, + routes: 0, + topFiles: [{ file: 'src/auth/callback.ts', symbols: 1, test: false }], + }, + drift: false, +}; + +const SOURCE_LINES = [ + 'export function parseToken(raw: string): Token {', + ' // Normalize expiry before anything else reads it.', + ' const claims = decodeJwt(raw);', + ' return { ...claims, expiresAt: claims.exp * 1000 };', + '}', +]; + +const SOURCE: WireSource = { + file: 'src/auth/token.ts', + language: 'typescript', + drift: false, + showing: 'indexed', + contentHash: 'abc123', + indexedAt: 1_700_000_000_000, + generated: false, + totalLines: 40, + from: 12, + to: 18, + lines: SOURCE_LINES, +}; + +const FLOW: WireFlowPayload = { + query: { kind: 'directed', from: 'handleCallback', to: 'decodeJwt', symbols: [] }, + flows: [ + { + id: 'flow-1', + label: 'handleCallback → decodeJwt', + partial: false, + boundary: null, + hops: [ + { + node: CALLER, + edge: null, + callRef: { line: 44, col: 6, name: 'parseToken', targetId: SYMBOL.node.id, backwards: false }, + source: { + file: 'src/auth/callback.ts', + language: 'typescript', + from: 44, + to: 46, + lines: [' const token = parseToken(raw);'], + drift: false, + }, + }, + { + node: nodeRef(), + edge: { + kind: 'calls', + line: 44, + label: 'calls', + upward: false, + uncertain: false, + synthesized: false, + }, + callRef: null, + source: { + file: 'src/auth/token.ts', + language: 'typescript', + from: 12, + to: 14, + lines: SOURCE_LINES.slice(0, 3), + drift: false, + }, + }, + ], + }, + ], + ambiguous: [], + unresolved: [], + reason: null, + index: { lastIndexedAt: 1_700_000_000_000, edges: 4, files: 3 }, + timing: { elapsedMs: 2 }, +}; + +const MAP: WireMapPayload = { + root: 'src', + depth: 1, + roots: [{ root: 'src', label: 'src', files: 3 }], + modules: [ + { + id: 'src/auth', + label: 'auth', + files: 2, + symbols: 6, + languages: [{ language: 'typescript', files: 2 }], + test: false, + facade: false, + fileList: { total: 2, shown: 2, truncated: false, items: ['src/auth/token.ts', 'src/auth/callback.ts'] }, + }, + { + id: 'src/http', + label: 'http', + files: 1, + symbols: 3, + languages: [{ language: 'typescript', files: 1 }], + test: false, + facade: false, + fileList: { total: 1, shown: 1, truncated: false, items: ['src/http/server.ts'] }, + }, + ], + links: [ + { + source: 'src/http', + target: 'src/auth', + count: 9, + declared: 7, + byKind: [{ kind: 'calls', count: 9 }], + topPairs: [{ from: 'src/http/server.ts', to: 'src/auth/token.ts', count: 9, declared: 7 }], + }, + ], + cycles: { total: 0, shown: 0, truncated: false, items: [] }, + excluded: { uncertainEdges: 0, confidenceBelow: 0.6 }, + index: { lastIndexedAt: 1_700_000_000_000, edges: 9, files: 3 }, + timing: { elapsedMs: 1, cached: false }, +}; + +const STATS: WireStats = { + project: { root: '/tmp/demo', name: 'demo' }, + index: { + state: 'ready', + lastIndexedAt: 1_700_000_000_000, + stale: false, + version: '1.0.0', + extractionVersion: 1, + backend: 'node-sqlite', + journalMode: 'wal', + pendingReferences: 0, + generatedFiles: 0, + watching: false, + watcherDegraded: false, + }, + graph: { + nodes: 9, + edges: 9, + files: 3, + nodesByKind: { function: 9 }, + edgesByKind: { calls: 9 }, + filesByLanguage: { typescript: 3 }, + dbSizeBytes: 1024, + walSizeBytes: 0, + }, + frameworks: [], + thresholds: { hub: 40, uncertainBelow: 0.6 }, + blastScale: { maxDirect: 20, maxWithinHops: 60, hops: 3, sampled: 24, estimated: true }, +}; + +/* ------------------------------------------------------------ mock adapter */ + +/** Every method the components can reach, and a record of which ones they did. */ +function mockAdapter(): { adapter: GraphAdapter; calls: string[] } { + const calls: string[] = []; + const seen = (name: string, value: T): Promise => { + calls.push(name); + return Promise.resolve(value); + }; + const adapter: GraphAdapter = { + stats: () => seen('stats', STATS), + search: () => + seen('search', { + query: '', + text: '', + filters: { kinds: [], languages: [], paths: [], names: [] }, + results: { total: 0, shown: 0, truncated: false, items: [] }, + groups: [], + }), + node: (id) => { + calls.push(`node:${id}`); + return Promise.resolve(SYMBOL); + }, + nodes: () => seen('nodes', { items: [], missing: [] }), + source: (request) => { + calls.push(`source:${request.file}`); + return Promise.resolve(SOURCE); + }, + file: () => + seen('file', { + file: { + path: 'src/auth/token.ts', + language: 'typescript', + size: 900, + modifiedAt: 0, + indexedAt: 0, + contentHash: 'abc123', + nodeCount: 3, + generated: false, + test: false, + errors: [], + id: 'file:src/auth/token.ts', + }, + topLevel: { calls: 0 }, + drift: false, + outline: { total: 0, shown: 0, truncated: false, items: [] }, + imports: { total: 0, shown: 0, truncated: false, items: [] }, + importedBy: { total: 0, shown: 0, truncated: false, items: [] }, + unresolvedImports: [], + dependencies: [], + dependents: [], + }), + fileCode: () => + seen('fileCode', { + file: { + path: 'src/auth/token.ts', + language: 'typescript', + size: 900, + indexedAt: 0, + contentHash: 'abc123', + generated: false, + test: false, + errors: [], + id: 'file:src/auth/token.ts', + totalLines: 40, + }, + drift: false, + outline: { total: 0, shown: 0, truncated: false, items: [] }, + calls: { total: 0, shown: 0, truncated: false, items: [] }, + outside: { total: 0, shown: 0, truncated: false, items: [] }, + intraFileCalls: 0, + timing: { elapsedMs: 1 }, + }), + flow: () => seen('flow', FLOW), + map: () => seen('map', MAP), + routes: () => + seen('routes', { + routed: false, + routeCount: 0, + shown: 0, + truncated: false, + topHandlerFile: null, + topHandlerFileCount: 0, + entries: [], + }), + entryPoints: () => + seen('entryPoints', { + frameworks: [], + 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: [] }, + index: { lastIndexedAt: null, files: 3 }, + timing: { elapsedMs: 1, cached: false }, + }), + deadCode: () => + seen('deadCode', { + rows: { total: 0, shown: 0, truncated: false, items: [] }, + groups: [], + candidates: 0, + excluded: [], + excludedTotal: 0, + kinds: ['function'], + includeExported: false, + includeTests: false, + includeGenerated: false, + bounded: false, + corroborated: true, + timing: { elapsedMs: 1 }, + }), + trails: () => + seen('trails', { + trails: [], + // A host with nowhere to keep trails still ANSWERS the question — it + // says it is read-only rather than omitting the method, so the screens + // show the section explained instead of showing a Save that does + // nothing. + readOnly: true, + readOnlyReason: 'This host does not store trails.', + directory: '.codegraph/ui/trails', + skipped: 0, + bounded: false, + }), + // Deliberately no `events`, `saveTrail` or `deleteTrail`: a host without a + // live channel and without anywhere to write is the normal case, and + // nothing may poll or offer to save in their absence. + }; + return { adapter, calls }; +} + +/* ----------------------------------------------------------------- harness */ + +let host: HTMLDivElement; +let mounted: Record | null = null; + +/** jsdom has none of the observers a canvas library expects. */ +beforeAll(() => { + class NoopObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } + const globals = globalThis as Record; + globals.ResizeObserver ??= NoopObserver; + globals.IntersectionObserver ??= NoopObserver; + globals.MutationObserver ??= NoopObserver; + globals.requestAnimationFrame ??= (fn: FrameRequestCallback) => + setTimeout(() => fn(0), 0) as unknown as number; + globals.cancelAnimationFrame ??= (handle: number) => clearTimeout(handle); + // jsdom's own `matchMedia` is a stub that is not callable here, and Svelte's + // `MediaQuery` (which `@xyflow/svelte`'s store constructs eagerly) calls it + // the moment a canvas mounts. Replace it outright rather than guarding. + const media = (query: string) => ({ + media: query, + matches: false, + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent: () => false, + }); + Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: media }); + globals.matchMedia = media; + if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}; +}); + +beforeEach(() => { + host = document.createElement('div'); + document.body.appendChild(host); + trail.clear(); +}); + +afterEach(() => { + if (mounted) { + void unmount(mounted); + mounted = null; + } + host.remove(); + setGraphAdapter(null); + setNavigationDriver(null); +}); + +/** + * Mount a component and let its data effects settle. + * + * Every screen fetches inside an `$effect`, so a render is not finished until + * the promise the adapter returned has resolved and the follow-up render has + * flushed. Two macrotask turns cover the deepest chain any of them has (the + * Symbol view: node, then its source). + */ +async function render( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + component: any, + props: Record +): Promise { + mounted = mount(component, { target: host, props }) as Record; + for (let turn = 0; turn < 4; turn += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + flushSync(); + } +} + +describe('@colbymchenry/codegraph-ui — a host renders the package', () => { + it('SymbolView draws callers, source and the callee rail from a mock adapter', async () => { + const { adapter, calls } = mockAdapter(); + setGraphAdapter(adapter); + + await render(SymbolView, { id: SYMBOL.node.id, line: null }); + + // It asked the adapter, by id, and it asked for the symbol's own slice. + expect(calls).toContain(`node:${SYMBOL.node.id}`); + expect(calls).toContain('source:src/auth/token.ts'); + + const text = host.textContent ?? ''; + expect(text).toContain('parseToken'); + // The caller rail (left) and the callee rail (right) are both drawn. + expect(text).toContain('handleCallback'); + expect(text).toContain('decodeJwt'); + // The verbatim source, not a summary of it. + expect(text).toContain('expiresAt'); + // The honesty badge: nothing in the fixture's graph tests this symbol. + expect(text.toLowerCase()).toContain('test'); + }); + + it('TypeHierarchy draws the fan, its wiring and its fold from a payload alone', async () => { + const implementers = Array.from({ length: 14 }, (_, i) => ({ + id: `impl-${i}`, + kind: 'class' as const, + name: `Target${i}`, + qualifiedName: `Target${i}`, + file: `src/targets/target-${i}.ts`, + line: 1, + endLine: 9, + language: 'typescript' as const, + test: false, + depth: 1, + parentId: SYMBOL.node.id, + relation: 'implements' as const, + // The first one arrived through a resolver rather than a parse, which is + // the case the block has to draw differently. + synthesized: i === 0, + ...(i === 0 ? { via: 'go-implements', registeredAt: 'src/clock.go:11' } : {}), + hiddenSubtypes: 0, + })); + const hierarchy: WireHierarchy = { + ancestors: { total: 0, shown: 0, truncated: false, items: [] }, + descendants: { + total: implementers.length, + shown: implementers.length, + truncated: false, + items: implementers, + }, + direct: implementers.length, + implementers: implementers.length, + bounded: false, + polymorphic: true, + }; + + await render(TypeHierarchy, { hierarchy, focus: SYMBOL.node, onopen: () => {} }); + + const text = host.textContent ?? ''; + // The claim a reader cannot get by counting rows. + expect(text).toContain('14 implementations'); + // The wiring site of the synthesized edge. + expect(text).toContain('go-implements'); + // Twelve rows, then the fold — never a silent truncation. + expect(text).toContain('+2 more implementations'); + expect(text).toContain('Target0'); + expect(text).not.toContain('Target13'); + // It draws no network of its own: this component was handed a payload. + expect(host.querySelectorAll('path').length).toBe(12); + }); + + it('FlowStrip draws one card per hop from a mock adapter', async () => { + const { adapter, calls } = mockAdapter(); + setGraphAdapter(adapter); + + await render(FlowStrip, { + from: 'handleCallback', + to: 'decodeJwt', + symbols: null, + trailParam: null, + }); + + expect(calls).toContain('flow'); + const text = host.textContent ?? ''; + expect(text).toContain('handleCallback'); + expect(text).toContain('parseToken'); + }); + + it('ArchitectureMap draws modules and their dependency from a mock adapter', async () => { + const { adapter, calls } = mockAdapter(); + setGraphAdapter(adapter); + + await render(ArchitectureMap, { root: 'src', depth: 1, tests: false }); + + expect(calls).toContain('map'); + const text = host.textContent ?? ''; + expect(text).toContain('auth'); + expect(text).toContain('http'); + }); + + it('TrailBar and SearchPalette mount and read through the same adapter', async () => { + const { adapter } = mockAdapter(); + setGraphAdapter(adapter); + + trail.push({ id: SYMBOL.node.id, name: 'parseToken', kind: 'function', dir: 'start' }); + await render(TrailBar, {}); + expect(host.textContent ?? '').toContain('parseToken'); + + void unmount(mounted as Record); + mounted = null; + host.innerHTML = ''; + + await render(SearchPalette, {}); + expect(host.querySelector('input[role="combobox"]')).not.toBeNull(); + }); + + it('offers no Save when the adapter cannot write, and says why in the list', async () => { + const { adapter } = mockAdapter(); + setGraphAdapter(adapter); + + trail.push({ id: SYMBOL.node.id, name: 'parseToken', kind: 'function', dir: 'start' }); + await render(TrailBar, {}); + // The one screen affordance that must never appear against a read-only + // host: an adapter with no `saveTrail` has no button, not a button that + // fails. + expect(host.textContent ?? '').not.toContain('Save trail'); + + void unmount(mounted as Record); + mounted = null; + host.innerHTML = ''; + + await render(SavedTrails, { hideWhenEmpty: false }); + const text = host.textContent ?? ''; + expect(text).toContain('Saved trails'); + expect(text).toContain('This host does not store trails.'); + }); + + it('CodegraphUi installs the adapter before its children ask for data', async () => { + const { adapter, calls } = mockAdapter(); + // NOT installed by hand — the provider is the only thing that installs it. + expect(getGraphAdapter()).not.toBe(adapter); + + mounted = mount(CodegraphUi, { target: host, props: { adapter } }) as Record; + flushSync(); + expect(getGraphAdapter()).toBe(adapter); + expect(calls).toEqual([]); + }); +}); + +describe('@colbymchenry/codegraph-ui — the seams', () => { + it('a host navigation driver replaces every href the components build', () => { + const seen: string[] = []; + const driver: NavigationDriver = { + symbolHref: (id) => `/review/42/symbol/${encodeURIComponent(id)}`, + fileHref: (path) => `/review/42/file/${path}`, + mapHref: () => '/review/42/map', + flowHref: () => '/review/42/flow', + entryHref: () => '/review/42', + navigate: (href) => seen.push(href), + back: () => seen.push('back'), + }; + setNavigationDriver(driver); + + expect(symbolHref('function:x')).toBe('/review/42/symbol/function%3Ax'); + expect(fileHref('src/a.ts')).toBe('/review/42/file/src/a.ts'); + expect(mapHref()).toBe('/review/42/map'); + expect(flowHref()).toBe('/review/42/flow'); + + setNavigationDriver(null); + // Back to the viewer's own address space, unchanged. + expect(symbolHref('function:x')).toBe(hashNavigation.symbolHref('function:x')); + expect(symbolHref('function:x')).toBe('#/s/function%3Ax'); + }); + + it('the default adapter is the loopback JSON API and asks for `api/...`', async () => { + const asked: string[] = []; + const adapter = createHttpAdapter({ + fetch: async (input) => { + asked.push(String(input)); + return new Response(JSON.stringify(STATS), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + await adapter.stats(); + await adapter.node('function:parse@a.ts:1'); + await adapter.source({ file: 'src/a.ts', from: 1, to: 4 }); + await adapter.nodes(['a', 'b']); + + expect(asked[0]).toBe('api/stats'); + // Ids are encoded per slash-separated segment, so ':' survives and '/' is + // still a path separator. + expect(asked[1]).toBe('api/node/function%3Aparse%40a.ts%3A1'); + expect(asked[2]).toBe('api/source?file=src%2Fa.ts&from=1&to=4'); + // Repeated `id` params, never a comma-joined list. + expect(asked[3]).toBe('api/nodes?id=a&id=b'); + }); + + it('an adapter with no live channel never connects and never polls', () => { + const { adapter } = mockAdapter(); + setGraphAdapter(adapter); + expect(adapter.events).toBeUndefined(); + // `live.start()` is a no-op in a jsdom test that never called it; what is + // asserted here is the counters a host can still drive by hand. + const before = live.indexTick; + live.signal('index', { index: { lastIndexedAt: 1, files: 3 } }); + expect(live.indexTick).toBe(before + 1); + }); +}); + +describe('@colbymchenry/codegraph-ui — the published shape', () => { + const manifest = JSON.parse( + readFileSync(join(ROOT, 'ui', 'package.json'), 'utf8') + ) as Record; + + it('is versioned with the engine', () => { + const engine = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as { + version: string; + }; + expect(manifest.version).toBe(engine.version); + }); + + it('is named, scoped and not publishable by accident', () => { + expect(manifest.name).toBe('@colbymchenry/codegraph-ui'); + // The package is PREPARED, not published (CG-61). `private` is the guard: + // npm refuses to publish it until the maintainer deliberately removes this. + expect(manifest.private).toBe(true); + }); + + it('exports the entry, the theme and nothing else', () => { + expect(Object.keys(manifest.exports).sort()).toEqual(['.', './package.json', './theme.css']); + expect(manifest.exports['.'].svelte).toBe('./dist/index.js'); + expect(manifest.exports['.'].types).toBe('./dist/index.d.ts'); + }); + + it('takes svelte as a peer, so a host never gets a second copy', () => { + expect(manifest.peerDependencies.svelte).toBeDefined(); + expect(manifest.dependencies?.svelte).toBeUndefined(); + // The canvas library is a real dependency: the Map and the Flow strip are + // unusable without it and a host must not have to know its version. + expect(manifest.dependencies['@xyflow/svelte']).toBeDefined(); + }); +}); diff --git a/__tests__/ui-search-model.test.ts b/__tests__/ui-search-model.test.ts new file mode 100644 index 000000000..3f3342383 --- /dev/null +++ b/__tests__/ui-search-model.test.ts @@ -0,0 +1,414 @@ +/** + * The search palette and the trail, without a browser (CG-45). + * + * Two things here can be silently wrong rather than merely ugly. The palette's + * flat item list must be exactly the concatenation of the sections it draws, or + * ↑/↓/Enter follows a different row than the one under the highlight. And the + * trail's wire format must round-trip, because it is the whole reason a walk + * survives a reload or travels in a shared link. + * + * The geometry-free half of the same split as `ui-symbol-model.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildEntryPalette, + buildSearchPalette, + groupByKind, + interleaveResults, + kindGroupTitle, + locationOf, + moveSelection, + parseFlowQuery, +} from '../ui/src/lib/search-model'; +import { decodeTrail, encodeTrail, hopLabel, type TrailHop } from '../ui/src/lib/trail-codec'; +import type { WireEntryPoints, WireSearch, WireSearchResult } from '../ui/src/lib/api'; + +/* ------------------------------------------------------------- fixtures -- */ + +function result(over: Partial = {}): WireSearchResult { + return { + id: over.id ?? `method:${over.name ?? 'load'}`, + kind: 'method', + name: 'load', + qualifiedName: 'Service::load', + file: 'src/service.ts', + line: 42, + endLine: 60, + language: 'typescript', + test: false, + matchKind: 'exact', + ...over, + } as WireSearchResult; +} + +function answer(items: WireSearchResult[]): WireSearch { + return { + query: 'q', + text: 'q', + filters: { kinds: [], languages: [], paths: [], names: [] }, + results: { total: items.length, shown: items.length, truncated: false, items }, + groups: [], + }; +} + +/* ----------------------------------------------------------- flow query -- */ + +describe('the flow grammar', () => { + it('recognises the three shapes the placeholder advertises', () => { + expect(parseFlowQuery('how does execute reach getFile')).toEqual({ + from: 'execute', + to: 'getFile', + }); + expect(parseFlowQuery('execute -> getFile')).toEqual({ from: 'execute', to: 'getFile' }); + expect(parseFlowQuery('execute → getFile')).toEqual({ from: 'execute', to: 'getFile' }); + expect(parseFlowQuery(' sync reaches indexFile? ')).toEqual({ + from: 'sync', + to: 'indexFile', + }); + }); + + it('asks about the last segment of a qualified name', () => { + // `Class.method` names the method; the class is how you say WHICH one, and + // the search ranks that out on its own. + expect(parseFlowQuery('how does CodeGraph.sync reach Cache.read')).toEqual({ + from: 'sync', + to: 'read', + }); + }); + + it('leaves an ordinary search alone', () => { + expect(parseFlowQuery('getImpactRadius')).toBeNull(); + expect(parseFlowQuery('kind:class Cache')).toBeNull(); + expect(parseFlowQuery('how does this work')).toBeNull(); + // A symbol reaching itself is not a path worth asking about. + expect(parseFlowQuery('sync -> sync')).toBeNull(); + }); +}); + +/* -------------------------------------------------------------- palette -- */ + +describe('the palette', () => { + it('flattens exactly what it draws, in draw order', () => { + const palette = buildSearchPalette( + [ + answer([ + result({ id: 'm1', name: 'load', kind: 'method' }), + result({ id: 'f1', name: 'loader', kind: 'function' }), + result({ id: 'm2', name: 'reload', kind: 'method' }), + ]), + ], + null + ); + + // Groups appear where their best result did, so flattening reproduces the + // ranking the keyboard walks. + expect(palette.sections.map((s) => s.title)).toEqual(['Methods', 'Function']); + expect(palette.items.map((i) => i.id)).toEqual(['m1', 'm2', 'f1']); + expect(palette.items).toEqual(palette.sections.flatMap((s) => s.items)); + expect(palette.empty).toBeNull(); + }); + + it('says nothing matched instead of drawing an empty box', () => { + const palette = buildSearchPalette([answer([])], null); + expect(palette.items).toEqual([]); + expect(palette.empty).toContain('No symbol or file'); + }); + + it('interleaves a flow question so neither endpoint outranks the other', () => { + const a = [result({ id: 'a1' }), result({ id: 'a2' })]; + const b = [result({ id: 'b1' }), result({ id: 'b2' })]; + expect(interleaveResults(a, b).map((r) => r.id)).toEqual(['a1', 'b1', 'a2', 'b2']); + + // A symbol that matched both halves keeps its earliest position. + expect(interleaveResults(a, [result({ id: 'a2' })]).map((r) => r.id)).toEqual(['a1', 'a2']); + }); + + it('offers the flow FIRST for a flow question, then what each name matches', () => { + const palette = buildSearchPalette( + [answer([result({ id: 'a', name: 'sync' })]), answer([result({ id: 'b', name: 'read' })])], + { from: 'sync', to: 'read' } + ); + // First row, so Enter opens the path: the question asked for the path. + expect(palette.sections[0]?.title).toBe('Flow'); + expect(palette.items[0]).toMatchObject({ type: 'flow', from: 'sync', to: 'read' }); + expect(palette.items.map((i) => i.id).slice(1)).toEqual(['a', 'b']); + expect(palette.items).toEqual(palette.sections.flatMap((s) => s.items)); + expect(palette.hint).toContain('sync'); + expect(palette.hint).toContain('read'); + }); + + it('offers no flow row when the query is not a flow question', () => { + const palette = buildSearchPalette([answer([result({ id: 'a' })])], null); + expect(palette.sections.some((s) => s.title === 'Flow')).toBe(false); + expect(palette.items.every((i) => i.type !== 'flow')).toBe(true); + }); + + it('names a kind bucket in sentence case, singular when there is one', () => { + expect(kindGroupTitle('method', 3)).toBe('Methods'); + expect(kindGroupTitle('method', 1)).toBe('Method'); + expect(kindGroupTitle('type_alias', 2)).toBe('Type aliases'); + expect(kindGroupTitle('class', 2)).toBe('Classes'); + }); + + it('locates a symbol by file and line, and a file by its directory', () => { + expect(locationOf(result({ file: 'src/mcp/tools.ts', line: 412 }))).toBe('tools.ts:412'); + // The name column is already the basename; repeating the path says nothing. + expect( + locationOf(result({ kind: 'file', file: 'src/bin/codegraph.ts', name: 'codegraph.ts' })) + ).toBe('src/bin'); + expect(locationOf(result({ kind: 'file', file: 'README.md', name: 'README.md' }))).toBe( + 'project root' + ); + }); + + it('groups by kind without losing a row', () => { + const results = [ + result({ id: '1', kind: 'class' }), + result({ id: '2', kind: 'method' }), + result({ id: '3', kind: 'class' }), + ]; + const sections = groupByKind(results); + expect(sections.map((s) => s.title)).toEqual(['Classes', 'Method']); + expect(sections.flatMap((s) => s.items).map((i) => i.id)).toEqual(['1', '3', '2']); + }); + + it('wraps the selection at both ends', () => { + expect(moveSelection(0, -1, 3)).toBe(2); + expect(moveSelection(2, 1, 3)).toBe(0); + expect(moveSelection(0, 1, 3)).toBe(1); + // An empty list has one legal selection, and it is not -1. + expect(moveSelection(0, 1, 0)).toBe(0); + }); +}); + +/* --------------------------------------------------------- entry points -- */ + +function entryPoints(over: Partial = {}): WireEntryPoints { + return { + 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, + truncated: false, + items: [ + { + ...result({ id: 'file:src/bin/codegraph.ts', kind: 'file', name: 'codegraph.ts' }), + file: 'src/bin/codegraph.ts', + calls: 9, + reaches: 37, + dependents: 3, + }, + ] as any, + }, + hubs: { + total: 1, + shown: 1, + truncated: false, + items: [{ ...result({ id: 'method:get', name: 'get' }), dependents: 264 }] as any, + }, + ...over, + } as WireEntryPoints; +} + +describe('the entry points', () => { + it('says what each row is derived from, not that it IS the entry point', () => { + const palette = buildEntryPalette(entryPoints()); + + expect(palette.sections.map((s) => s.title)).toEqual([ + 'Files that run something', + 'Most depended on', + ]); + expect(palette.sections[0]?.items[0]?.meta).toBe( + '9 calls at module level · reaches 37 files' + ); + expect(palette.sections[1]?.items[0]?.meta).toBe('264 dependents'); + expect(palette.items).toHaveLength(2); + }); + + it('puts routes first, and carries the id that makes a row clickable', () => { + const palette = buildEntryPalette( + entryPoints({ + routes: { + routed: true, + routeCount: 4, + 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', + }, + ], + }, + }, + }) + ); + + expect(palette.sections[0]?.title).toBe('Routes'); + const row = palette.items[0]; + expect(row?.type).toBe('route'); + if (row?.type === 'route') { + expect(row.url).toBe('GET /users'); + expect(row.nodeId).toBe('function:listUsers'); + expect(row.location).toBe('routes.ts:11'); + } + }); + + it('shortens each section for the panel under the box', () => { + const many = entryPoints(); + (many.hubs.items as any) = Array.from({ length: 10 }, (_, i) => ({ + ...result({ id: `m${i}`, name: `hub${i}` }), + dependents: 100 - i, + })); + expect(buildEntryPalette(many, { perSection: 3 }).items).toHaveLength(4); + 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([]); + // 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(palette.empty).toBeNull(); + }); +}); + +/* ----------------------------------------------------------------- trail -- */ + +function hop(id: string, dir: TrailHop['dir']): TrailHop { + return { id, name: null, kind: null, dir }; +} + +describe('the trail in the URL', () => { + it('round-trips six hops with their directions intact', () => { + const walked: TrailHop[] = [ + hop('method:a', 'start'), + hop('method:b', 'down'), + hop('method:c', 'down'), + hop('method:d', 'up'), + hop('method:e', 'down'), + hop('file:src/bin/codegraph.ts', 'up'), + ]; + + const encoded = encodeTrail(walked); + const decoded = decodeTrail(encoded); + + expect(decoded).toHaveLength(6); + expect(decoded.map((h) => h.id)).toEqual(walked.map((h) => h.id)); + expect(decoded.map((h) => h.dir)).toEqual(['start', 'down', 'down', 'up', 'down', 'up']); + // Re-encoding is byte-identical, which is what makes a shared link stable. + expect(encodeTrail(decoded)).toBe(encoded); + }); + + it('keeps an id that begins with a direction letter', () => { + // `union:…` and `default:…` start with 'u' and 'd'; an optional direction + // prefix would swallow the first character of the id. + const hops = [hop('union:Shape', 'start'), hop('declaration:x', 'down')]; + expect(decodeTrail(encodeTrail(hops)).map((h) => h.id)).toEqual([ + 'union:Shape', + 'declaration:x', + ]); + }); + + it('survives an id carrying the separator, and a hand-mangled param', () => { + const hops = [hop('file:src/a,b.ts', 'start')]; + expect(decodeTrail(encodeTrail(hops))[0]?.id).toBe('file:src/a,b.ts'); + + expect(decodeTrail(null)).toEqual([]); + expect(decodeTrail('')).toEqual([]); + // A token with no direction letter is dropped; a lone '%' would throw in + // decodeURIComponent, so the raw text is kept instead — a hop that names + // nothing is better than a trail that silently loses a position. + expect(decodeTrail('x,,smethod%3Aa,d%')).toEqual([ + { id: 'method:a', name: null, kind: null, dir: 'start' }, + { id: '%', name: null, kind: null, dir: 'down' }, + ]); + }); + + it('labels an unresolved hop with something readable, never a raw hash', () => { + expect(hopLabel({ ...hop('method:x', 'down'), name: 'load' })).toBe('load'); + expect(hopLabel(hop('file:src/bin/codegraph.ts', 'start'))).toBe('codegraph.ts'); + expect(hopLabel(hop('method:ada8ef1603fc03e3566eec72dc91138f', 'down'))).toBe('ada8ef16…'); + }); +}); diff --git a/__tests__/ui-server-api.test.ts b/__tests__/ui-server-api.test.ts new file mode 100644 index 000000000..512887266 --- /dev/null +++ b/__tests__/ui-server-api.test.ts @@ -0,0 +1,1216 @@ +/** + * The `codegraph ui` read-only JSON API (CG-42). + * + * Everything runs against a real indexed fixture project over a real loopback + * server — no mocks — because the properties worth pinning are the ones that + * only exist end to end: the drift verdict comes from hashing bytes on disk + * against what the index stored, the refusals come from the same chokepoint the + * static server uses, and the caps only matter once a symbol really does have + * hundreds of callers. + * + * The fixture is built to produce each of those: a call chain three deep, a + * test file that reaches it, a type used only as a type, an import that cannot + * resolve, and one deliberately hot function with 500 callers. + */ + +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'; + +interface Response { + status: number; + headers: http.IncomingHttpHeaders; + body: string; +} + +let server: UiServerHandle; +let api: GraphApi; +let tempDir: string; +let projectRoot: string; +let viewerDir: string; + +/** + * One request against a live server, with the loopback `Host` the boundary + * wants. Written with `http.request` rather than `fetch` so the `Host` header + * is ours to set — undici treats it as forbidden. + */ +function requestOn(port: number, requestPath: string, method = 'GET'): Promise { + return new Promise((resolve, reject) => { + const req = http.request( + { + host: '127.0.0.1', + port, + path: requestPath, + method, + 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, + headers: res.headers, + body: Buffer.concat(chunks).toString('utf-8'), + }) + ); + } + ); + req.on('error', reject); + req.end(); + }); +} + +/** The same, against the main fixture's server. */ +function request(requestPath: string, method = 'GET'): Promise { + return requestOn(server.port, requestPath, method); +} + +/** + * Payloads are read as `any` on purpose: these tests assert the JSON contract + * the viewer sees over the wire, so typing them against the server's own + * interfaces would only prove the server agrees with itself. + */ +async function getJson(requestPath: string): Promise { + const res = await request(requestPath); + expect(res.headers['content-type']).toBe('application/json; charset=utf-8'); + return JSON.parse(res.body); +} + +async function getStatusAndJson(requestPath: string): Promise<{ status: number; body: any }> { + const res = await request(requestPath); + expect(res.headers['content-type']).toBe('application/json; charset=utf-8'); + return { status: res.status, body: JSON.parse(res.body) }; +} + +/** Find a symbol in the fixture by name, through the API itself. */ +async function idOf(name: string, kind?: string): Promise { + const search = await getJson(`/api/search?q=${encodeURIComponent(name)}`); + const hit = search.results.items.find( + (r: any) => r.name === name && (kind === undefined || r.kind === kind) + ); + expect(hit, `no ${kind ?? 'symbol'} named ${name} in the fixture`).toBeTruthy(); + return hit.id as string; +} + +beforeAll(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-api-')); + projectRoot = path.join(tempDir, 'project'); + const srcDir = path.join(projectRoot, 'src'); + const testsDir = path.join(projectRoot, '__tests__'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.mkdirSync(testsDir, { recursive: true }); + + fs.writeFileSync( + path.join(srcDir, 'types.ts'), + `export interface Config { + ttlMs: number; + label: string; +} + +export type CacheKey = string; +` + ); + + fs.writeFileSync( + path.join(srcDir, 'cache.ts'), + `import { Config, CacheKey } from './types'; + +export class Cache { + private store = new Map(); + private config: Config; + + constructor(config: Config) { + this.config = config; + } + + read(key: CacheKey): string | undefined { + return this.store.get(key); + } + + write(key: CacheKey, value: string): void { + this.store.set(key, value); + } +} +` + ); + + fs.writeFileSync( + path.join(srcDir, 'service.ts'), + `import { Cache } from './cache'; +import { Config } from './types'; +// Not in the index: a package that was never installed here. +import { serialize } from 'some-external-package'; + +export class Service { + private cache: Cache; + + constructor(config: Config) { + this.cache = new Cache(config); + } + + load(key: string): string { + const hit = this.cache.read(key); + if (hit !== undefined) return hit; + const fresh = serialize(key); + this.cache.write(key, fresh); + return fresh; + } +} +` + ); + + fs.writeFileSync( + path.join(srcDir, 'handler.ts'), + `import { Service } from './service'; + +export function handleRequest(service: Service, key: string): string { + return service.load(key); +} +` + ); + + // Module-level statements: the engine records them as edges out of the FILE + // node, which is the only reason `/api/entrypoints` can see an executable + // root at all. Nothing else in the fixture runs anything on the way down. + fs.writeFileSync( + path.join(srcDir, 'main.ts'), + `import { Service } from './service'; +import { handleRequest } from './handler'; + +const service = new Service({ ttlMs: 5, label: 'main' }); +const first = handleRequest(service, 'boot'); +const second = service.load('warm'); + +export const started = [first, second]; +` + ); + + // 500 callers into one function: the N+1 and capping behaviour only shows up + // at this scale, and the fixture keeps CI honest without needing the engine's + // own index to be present. + const callers = Array.from( + { length: 500 }, + (_, i) => `export function caller${i}(): number {\n return hot(${i});\n}` + ).join('\n\n'); + fs.writeFileSync( + path.join(srcDir, 'hot.ts'), + `export function hot(n: number): number { + return n * 2; +} + +${callers} +` + ); + + // CRLF on purpose: tree-sitter numbers rows by `\n`, so a CRLF file must come + // back with the same line numbers the graph recorded — and without the stray + // `\r` rendering at the end of every line. This is what a Windows checkout + // with core.autocrlf looks like, and it is decided by bytes, not by the OS. + fs.writeFileSync( + path.join(srcDir, 'crlf.ts'), + ['export function windowsStyle(n: number): number {', ' return n + 1;', '}', ''].join('\r\n') + ); + + fs.writeFileSync( + path.join(testsDir, 'service.test.ts'), + `import { Service } from '../src/service'; + +export function testLoadsThroughCache(): void { + const service = new Service({ ttlMs: 1, label: 'x' }); + service.load('k'); +} + +// Module level, on purpose: a test file that RUNS something must still be +// excluded from the entry points. +testLoadsThroughCache(); +` + ); + + const cg = CodeGraph.initSync(projectRoot, { + config: { include: ['src/**/*.ts', '__tests__/**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + cg.resolveReferences(); + // Hand the index over: the API opens its own read-only connection, which is + // also what happens in production (the CLI never shares an instance). + cg.close(); + + viewerDir = path.join(tempDir, 'viewer'); + fs.mkdirSync(viewerDir, { recursive: true }); + fs.writeFileSync(path.join(viewerDir, 'index.html'), '
'); + + api = createGraphApi({ projectRoot }); + server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler }); +}, 120_000); + +afterAll(async () => { + api?.close(); + await server?.close(); + if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('GET /api', () => { + it('lists the endpoints it answers', async () => { + const body = await getJson('/api'); + // Not a blanket claim any more (CG-60): saved trails are the one thing + // this server writes, and it names it rather than implying there is none. + expect(body.readOnly).toBe(false); + expect(body.writes).toEqual(['POST /api/trails', 'DELETE /api/trails/']); + const paths = body.endpoints.map((e: any) => e.path); + expect(paths).toEqual( + expect.arrayContaining([ + '/api/stats', + '/api/search', + '/api/node/', + '/api/source', + '/api/file/', + '/api/routes', + ]) + ); + }); + + it('404s an unknown endpoint as JSON, never as the app shell', async () => { + const { status, body } = await getStatusAndJson('/api/nope'); + expect(status).toBe(404); + expect(body.code).toBe('not-found'); + }); + + it('answers HEAD with the headers and no body', async () => { + const res = await request('/api/stats', 'HEAD'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toBe('application/json; charset=utf-8'); + expect(Number(res.headers['content-length'])).toBeGreaterThan(0); + expect(res.body).toBe(''); + }); +}); + +describe('GET /api/stats', () => { + it('reports the project, the index state and the graph counts', async () => { + const body = await getJson('/api/stats'); + + expect(body.project.root).toBe(projectRoot); + expect(body.project.name).toBe('project'); + + expect(body.index.state).toBe('complete'); + expect(body.index.stale).toBe(false); + expect(typeof body.index.lastIndexedAt).toBe('number'); + expect(body.index.backend).toBe('node-sqlite'); + expect(typeof body.index.extractionVersion).toBe('number'); + + expect(body.graph.nodes).toBeGreaterThan(0); + expect(body.graph.edges).toBeGreaterThan(0); + expect(body.graph.files).toBeGreaterThanOrEqual(6); + expect(body.graph.nodesByKind.class).toBeGreaterThanOrEqual(2); + expect(body.graph.filesByLanguage.typescript).toBeGreaterThanOrEqual(6); + + // The thresholds travel with the data so the viewer's copy cannot drift. + expect(body.thresholds).toEqual({ hub: 40, uncertainBelow: 0.6 }); + }); + + it('reports a blast-radius scale the widest symbol in the index reaches', async () => { + const body = await getJson('/api/stats'); + const scale = body.blastScale; + + // `hot` is called by 500 distinct functions and nothing else in the fixture + // comes close, so the exact maximum is knowable here. + expect(scale.maxDirect).toBe(500); + // Its radius is at least its own callers; the sample is capped, so the + // count is a floor and the flag says so rather than claiming exhaustive. + expect(scale.maxWithinHops).toBeGreaterThanOrEqual(500); + expect(scale.hops).toBe(3); + expect(scale.sampled).toBeGreaterThan(0); + expect(scale.sampled).toBeLessThanOrEqual(24); + expect(scale.estimated).toBe(true); + }); + + it('serves the scale from cache — the second call does not re-traverse', async () => { + const first = await getJson('/api/stats'); + const started = Date.now(); + const second = await getJson('/api/stats'); + expect(second.blastScale).toEqual(first.blastScale); + // 24 depth-3 traversals over a 500-caller graph are not free; a cached + // answer is. The margin is wide because this is a smoke test for the + // memo existing at all, not a benchmark. + expect(Date.now() - started).toBeLessThan(250); + }); +}); + +describe('GET /api/search', () => { + it('ranks exact over prefix over substring, and groups by kind', async () => { + const body = await getJson('/api/search?q=Cache'); + + const first = body.results.items[0]; + expect(first.name).toBe('Cache'); + expect(first.kind).toBe('class'); + expect(first.matchKind).toBe('exact'); + + const ranks = body.results.items.map((r: any) => r.matchKind); + const order = ['exact', 'prefix', 'substring', 'qualified', 'file', 'related']; + const asNumbers = ranks.map((r: string) => order.indexOf(r)); + expect(asNumbers).toEqual([...asNumbers].sort((a, b) => a - b)); + + // Flattening the groups reproduces the flat ranking, so the palette can use + // either without them disagreeing. + const flattened = body.groups.flatMap((g: any) => g.items.map((i: any) => i.id)); + expect(new Set(flattened)).toEqual(new Set(body.results.items.map((r: any) => r.id))); + for (const group of body.groups) expect(group.count).toBe(group.items.length); + }); + + it('returns a signature and a file:line for every result', async () => { + const body = await getJson('/api/search?q=handleRequest'); + const hit = body.results.items.find((r: any) => r.name === 'handleRequest'); + expect(hit.file).toBe('src/handler.ts'); + expect(hit.line).toBeGreaterThan(0); + expect(hit.endLine).toBeGreaterThanOrEqual(hit.line); + expect(hit.signature).toContain('service'); + expect(hit.qualifiedName).toBeTruthy(); + expect(hit.language).toBe('typescript'); + }); + + it('finds a mid-name match FTS tokens cannot', async () => { + const body = await getJson('/api/search?q=quest'); + const names = body.results.items.map((r: any) => r.name); + expect(names).toContain('handleRequest'); + const hit = body.results.items.find((r: any) => r.name === 'handleRequest'); + expect(hit.matchKind).toBe('substring'); + }); + + it('honours the kind: filter grammar', async () => { + const body = await getJson('/api/search?q=' + encodeURIComponent('kind:class Cache')); + expect(body.filters.kinds).toEqual(['class']); + expect(body.results.items.every((r: any) => r.kind === 'class')).toBe(true); + }); + + it('marks test files so the palette can rank them down', async () => { + const body = await getJson('/api/search?q=testLoadsThroughCache'); + const hit = body.results.items.find((r: any) => r.name === 'testLoadsThroughCache'); + expect(hit.test).toBe(true); + }); + + it('answers an empty search box with nothing, and a missing q with 400', async () => { + const empty = await getStatusAndJson('/api/search?q='); + expect(empty.status).toBe(200); + expect(empty.body.results.total).toBe(0); + expect(empty.body.groups).toEqual([]); + + const missing = await getStatusAndJson('/api/search'); + expect(missing.status).toBe(400); + expect(missing.body.code).toBe('bad-request'); + }); + + it('returns an empty result set for a name nothing has', async () => { + const body = await getJson('/api/search?q=zzznotasymbolanywhere'); + expect(body.results.total).toBe(0); + }); +}); + +describe('GET /api/node/', () => { + it('returns the symbol, its ancestors and its members in source order', async () => { + const body = await getJson(`/api/node/${await idOf('Cache', 'class')}`); + + expect(body.node.name).toBe('Cache'); + expect(body.node.kind).toBe('class'); + expect(body.node.file).toBe('src/cache.ts'); + expect(body.node.lines).toBe(body.node.endLine - body.node.line + 1); + expect(body.node.exported).toBe(true); + + // Outermost first: the file, then anything between it and the symbol. + expect(body.ancestors[0].kind).toBe('file'); + expect(body.ancestors[0].file).toBe('src/cache.ts'); + + const members = body.members.items.map((m: any) => m.name); + expect(members).toEqual(expect.arrayContaining(['read', 'write', 'store', 'config'])); + const lines = body.members.items.map((m: any) => m.line); + expect(lines).toEqual([...lines].sort((a, b) => a - b)); + for (const member of body.members.items) { + expect(member.parentId).toBe(body.node.id); + expect(member.depth).toBe(1); + } + expect(body.members.total).toBe(body.members.shown); + }); + + it('gives every member its own fan-in and fan-out — the outline is the body', async () => { + const body = await getJson(`/api/node/${await idOf('Cache', 'class')}`); + const byName = new Map(body.members.items.map((m: any) => [m.name, m])); + + for (const member of body.members.items) { + expect(typeof member.fanIn).toBe('number'); + expect(typeof member.fanOut).toBe('number'); + expect(member.fanIn).toBeGreaterThanOrEqual(0); + expect(member.fanOut).toBeGreaterThanOrEqual(0); + } + + // `Service.load` calls both, and `Cache` contains them: at least the + // containment edge plus one call each. Without these numbers a 700-line + // class's outline cannot say which member carries weight. + expect((byName.get('read') as any).fanIn).toBeGreaterThanOrEqual(2); + expect((byName.get('write') as any).fanIn).toBeGreaterThanOrEqual(2); + // The class itself calls nothing — its methods do, which is exactly why + // the per-member counts have to come from the members. + expect(body.counts.callees).toBe(0); + expect(body.members.items.some((m: any) => m.fanOut > 0)).toBe(true); + }); + + it('nests a file outline one level deeper, so a class shows its methods', async () => { + const body = await getJson(`/api/node/${await idOf('cache.ts', 'file')}`); + const byDepth = new Map(); + for (const member of body.members.items) { + byDepth.set(member.depth, [...(byDepth.get(member.depth) ?? []), member.name]); + } + expect(byDepth.get(1)).toContain('Cache'); + expect(byDepth.get(2)).toEqual(expect.arrayContaining(['read', 'write'])); + }); + + it('groups incoming edges by the calling symbol, with their call sites', async () => { + const readId = await idOf('read', 'method'); + const body = await getJson(`/api/node/${readId}`); + + const fromLoad = body.incoming.items.find((r: any) => r.node.name === 'load'); + expect(fromLoad, 'Service.load should call Cache.read').toBeTruthy(); + expect(fromLoad.node.file).toBe('src/service.ts'); + expect(fromLoad.edgeKinds).toContain('calls'); + expect(fromLoad.edgeCount).toBeGreaterThanOrEqual(1); + expect(fromLoad.lines.length).toBeGreaterThanOrEqual(1); + expect(fromLoad.lines).toEqual([...fromLoad.lines].sort((a: number, b: number) => a - b)); + expect(typeof fromLoad.fanIn).toBe('number'); + expect(fromLoad.hub).toBe(false); + }); + + it('carries every edge attribute the viewer draws with', async () => { + const body = await getJson(`/api/node/${await idOf('read', 'method')}`); + const relation = body.incoming.items.find((r: any) => r.node.name === 'load'); + const edge = relation.edges[0]; + + expect(edge.kind).toBe('calls'); + expect(typeof edge.line).toBe('number'); + expect(typeof edge.col).toBe('number'); + expect(typeof edge.confidence).toBe('number'); + expect(typeof edge.resolvedBy).toBe('string'); + // Confidence decides the uncertain fold; the group agrees with its edges. + expect(relation.confidence).toBe( + Math.max(...relation.edges.map((e: any) => e.confidence ?? -1)) + ); + expect(relation.uncertain).toBe(relation.confidence < 0.6); + expect(relation.synthesized).toBe(false); + }); + + it('groups outgoing edges by the called symbol, ordered by call site', async () => { + const body = await getJson(`/api/node/${await idOf('load', 'method')}`); + const names = body.outgoing.items.map((r: any) => r.node.name); + expect(names).toEqual(expect.arrayContaining(['read', 'write'])); + + const firstLines = body.outgoing.items + .map((r: any) => r.lines[0]) + .filter((l: number | undefined) => l !== undefined); + expect(firstLines).toEqual([...firstLines].sort((a, b) => a - b)); + }); + + it('splits type references out of the callee rail', async () => { + // Type edges attach to the MEMBER that names the type, not to its class: + // `Service`'s constructor is where `Config` and `new Cache(...)` both live, + // which makes it the one place both halves of the split are visible. + const service = await getJson(`/api/node/${await idOf('Service', 'class')}`); + const ctor = service.members.items.find((m: any) => m.name === 'constructor'); + const body = await getJson(`/api/node/${ctor.id}`); + + const typeNames = body.typesUsed.map((t: any) => t.node.name); + expect(typeNames).toContain('Config'); + expect(body.typesUsed.every((t: any) => t.edgeKinds.includes('references'))).toBe(true); + + // A type reference is not also a callee row... + expect(body.outgoing.items.map((r: any) => r.node.name)).not.toContain('Config'); + // ...but a class reached by any other edge kind still is: `new Cache(...)` + // is an `instantiates` edge, and moving it would hide a real dependency. + const instantiated = body.outgoing.items.find((r: any) => r.node.name === 'Cache'); + expect(instantiated).toBeTruthy(); + expect(instantiated.edgeKinds).toContain('instantiates'); + }); + + it('summarizes which tests reach the symbol', async () => { + const reached = await getJson(`/api/node/${await idOf('load', 'method')}`); + expect(reached.tests.reached).toBe(true); + expect(reached.tests.hops).toBe(1); + expect(reached.tests.files).toContain('__tests__/service.test.ts'); + expect(reached.tests.fileCount).toBeGreaterThanOrEqual(1); + expect(reached.tests.files.length).toBeLessThanOrEqual(6); + expect(reached.tests.exhaustive).toBe(true); + + const unreached = await getJson(`/api/node/${await idOf('hot', 'function')}`); + expect(unreached.tests.reached).toBe(false); + expect(unreached.tests.hops).toBeNull(); + expect(unreached.tests.files).toEqual([]); + }); + + it('counts the calls that leave the index instead of hiding them', async () => { + const body = await getJson(`/api/node/${await idOf('load', 'method')}`); + expect(body.outsideIndex.total).toBeGreaterThan(0); + const names = body.outsideIndex.samples.map((s: any) => s.name); + expect(names).toContain('serialize'); + for (const sample of body.outsideIndex.samples) { + expect(typeof sample.line).toBe('number'); + expect(typeof sample.kind).toBe('string'); + } + }); + + it('summarizes the blast radius at three hops', async () => { + const body = await getJson(`/api/node/${await idOf('read', 'method')}`); + expect(body.blast.hops).toBe(3); + expect(body.blast.direct).toBe(body.counts.callers); + // load → handleRequest / the test both sit inside three hops of Cache.read. + expect(body.blast.withinHops).toBeGreaterThan(body.blast.direct); + expect(body.blast.files).toBeGreaterThanOrEqual(2); + expect(body.blast.testFiles).toBeGreaterThanOrEqual(1); + expect(body.blast.routes).toBe(0); + expect(body.blast.topFiles[0].symbols).toBeGreaterThanOrEqual(1); + }); + + it('keeps every count equal to the list it labels', async () => { + const body = await getJson(`/api/node/${await idOf('read', 'method')}`); + expect(body.counts.callers).toBe(body.incoming.total); + expect(body.counts.callees).toBe(body.outgoing.total); + expect(body.counts.typesUsed).toBe(body.typesUsed.length); + expect(body.counts.members).toBe(body.members.total); + expect(body.blast.direct).toBe(body.counts.callers); + }); + + it('reports fan-in, fan-out and the hub flag', async () => { + const quiet = await getJson(`/api/node/${await idOf('write', 'method')}`); + expect(quiet.counts.hub).toBe(false); + expect(quiet.counts.callers).toBeLessThan(40); + expect(quiet.counts.fanIn).toBeGreaterThanOrEqual(quiet.counts.callers); + + const hot = await getJson(`/api/node/${await idOf('hot', 'function')}`); + expect(hot.counts.hub).toBe(true); + expect(hot.counts.callers).toBeGreaterThanOrEqual(500); + }); + + it('flags nothing as drifted while the fixture is untouched', async () => { + const body = await getJson(`/api/node/${await idOf('read', 'method')}`); + expect(body.drift).toBe(false); + }); + + it('404s an id that names nothing, and 400s an empty one', async () => { + const missing = await getStatusAndJson('/api/node/method:notarealid'); + expect(missing.status).toBe(404); + expect(missing.body.code).toBe('not-found'); + expect(missing.body.hint).toBeTruthy(); + + const empty = await getStatusAndJson('/api/node/'); + expect(empty.status).toBe(400); + }); +}); + +describe('GET /api/node/ — the type hierarchy block', () => { + it('is null for a function, so the block costs a plain symbol nothing', async () => { + const body = await getJson(`/api/node/${await idOf('hot', 'function')}`); + expect(body.hierarchy).toBeNull(); + }); + + it('is null for a class with nothing above or below it', async () => { + const body = await getJson(`/api/node/${await idOf('Cache', 'class')}`); + expect(body.hierarchy).toBeNull(); + }); +}); + +describe('GET /api/node/ — the busiest symbol', () => { + it('caps the caller list, keeps the true total, and stays fast', async () => { + const hotId = await idOf('hot', 'function'); + await request(`/api/node/${hotId}`); // warm the connection and the caches + + const started = performance.now(); + const res = await request(`/api/node/${hotId}`); + const elapsed = performance.now() - started; + expect(res.status).toBe(200); + + const body = JSON.parse(res.body); + expect(body.incoming.total).toBeGreaterThanOrEqual(500); + expect(body.incoming.shown).toBe(300); + expect(body.incoming.truncated).toBe(true); + expect(body.incoming.items).toHaveLength(300); + // Grouped: one row per calling symbol, each carrying its own call sites. + expect(new Set(body.incoming.items.map((r: any) => r.node.id)).size).toBe(300); + expect(body.counts.callers).toBeGreaterThanOrEqual(500); + expect(body.blast.direct).toBe(body.counts.callers); + + // 500 callers resolved one query at a time would be nowhere near this. + expect(elapsed).toBeLessThan(100); + }); +}); + +describe('GET /api/source', () => { + it('returns the requested slice with the index line numbering', async () => { + const body = await getJson('/api/source?file=src/cache.ts&from=1&to=3'); + expect(body.drift).toBe(false); + expect(body.file).toBe('src/cache.ts'); + expect(body.language).toBe('typescript'); + expect(body.from).toBe(1); + expect(body.to).toBe(3); + expect(body.lines).toHaveLength(3); + expect(body.lines[0]).toContain("import { Config, CacheKey } from './types'"); + expect(body.totalLines).toBeGreaterThan(3); + expect(body.truncated).toBe(false); + }); + + it('serves the whole file when no range is given', async () => { + const body = await getJson('/api/source?file=src/handler.ts'); + expect(body.from).toBe(1); + expect(body.to).toBe(body.totalLines); + expect(body.lines).toHaveLength(body.totalLines); + }); + + it('slices exactly the lines a symbol claims', async () => { + const node = await getJson(`/api/node/${await idOf('handleRequest', 'function')}`); + const body = await getJson( + `/api/source?file=${node.node.file}&from=${node.node.line}&to=${node.node.endLine}` + ); + expect(body.lines[0]).toContain('handleRequest'); + expect(body.lines).toHaveLength(node.node.lines); + }); + + it('carries the classified source beside the lines, one entry per line', async () => { + const body = await getJson('/api/source?file=src/cache.ts&from=1&to=3'); + // Highlighting rides with the slice rather than behind its own endpoint: + // the two are only ever wanted together, and a second round-trip would let + // the code block paint unhighlighted source and then reflow it. + expect(body.highlight).toBeTruthy(); + expect(body.highlight.classes).toEqual([ + 'other', + 'ident', + 'comment', + 'string', + 'keyword', + 'number', + 'type', + 'def', + ]); + expect(body.highlight.lines).toHaveLength(body.lines.length); + // Every line's tokens reproduce that line exactly — the code block renders + // these, not the raw string. + for (let i = 0; i < body.lines.length; i++) { + const rebuilt = body.highlight.lines[i].map(([, text]: [number, string]) => text).join(''); + expect(rebuilt).toBe(body.lines[i]); + } + }); + + it('refuses to slice a file that changed on disk after the last sync', async () => { + const target = path.join(projectRoot, 'src', 'handler.ts'); + const original = fs.readFileSync(target); + try { + fs.writeFileSync(target, Buffer.concat([Buffer.from('// a new first line\n'), original])); + + const body = await getJson('/api/source?file=src/handler.ts&from=1&to=3'); + expect(body.drift).toBe(true); + // The whole point: no slice, rather than a slice of the wrong lines. + expect(body.lines).toBeUndefined(); + // And nothing to render it with either — a highlight with no source is + // just a second way to draw the wrong lines. + expect(body.highlight).toBeUndefined(); + expect(body.reason).toContain('changed on disk after the last index sync'); + + // And every screen that renders indexed line ranges is told. + const node = await getJson(`/api/node/${await idOf('handleRequest', 'function')}`); + expect(node.drift).toBe(true); + const file = await getJson('/api/file/src/handler.ts'); + expect(file.drift).toBe(true); + } finally { + fs.writeFileSync(target, original); + } + }); + + it('does not call an identical rewrite drift', async () => { + const target = path.join(projectRoot, 'src', 'handler.ts'); + const original = fs.readFileSync(target); + // Same bytes, new mtime — what a checkout or a formatter no-op looks like. + fs.writeFileSync(target, original); + + const body = await getJson('/api/source?file=src/handler.ts&from=1&to=2'); + expect(body.drift).toBe(false); + expect(body.lines).toHaveLength(2); + }); + + it('keeps a CRLF file on the index line numbering, without the stray carriage returns', async () => { + const node = await getJson(`/api/node/${await idOf('windowsStyle', 'function')}`); + expect(node.node.file).toBe('src/crlf.ts'); + + const body = await getJson('/api/source?file=src/crlf.ts'); + expect(body.drift).toBe(false); + expect(body.totalLines).toBe(3); + expect(body.lines).toEqual([ + 'export function windowsStyle(n: number): number {', + ' return n + 1;', + '}', + ]); + expect(body.lines.some((l: string) => l.includes('\r'))).toBe(false); + + // The symbol's indexed range still names its own body. + const slice = await getJson( + `/api/source?file=src/crlf.ts&from=${node.node.line}&to=${node.node.endLine}` + ); + expect(slice.lines[0]).toContain('windowsStyle'); + }); + + it('refuses a path that escapes the project', async () => { + const traversal = await getStatusAndJson( + '/api/source?file=' + encodeURIComponent('../../../etc/passwd') + ); + expect(traversal.status).toBe(403); + expect(traversal.body.code).toBe('refused'); + + const absolute = await getStatusAndJson( + '/api/source?file=' + encodeURIComponent('/etc/passwd') + ); + expect(absolute.status).toBe(403); + expect(absolute.body.code).toBe('refused'); + expect(absolute.body.error).toContain('absolute'); + }); + + it('refuses a NUL byte in the path', async () => { + const { status, body } = await getStatusAndJson( + '/api/source?file=' + encodeURIComponent('src/cache.ts\u0000.png') + ); + expect(status).toBe(403); + expect(body.code).toBe('refused'); + }); + + it('404s a file that exists but is not indexed', async () => { + fs.writeFileSync(path.join(projectRoot, 'notes.md'), '# not indexed\n'); + const { status, body } = await getStatusAndJson('/api/source?file=notes.md'); + expect(status).toBe(404); + expect(body.code).toBe('not-found'); + expect(body.hint).toContain('index'); + }); + + it('rejects a range that names nothing', async () => { + const past = await getStatusAndJson('/api/source?file=src/handler.ts&from=99999'); + expect(past.status).toBe(400); + expect(past.body.error).toContain('past the end'); + + const backwards = await getStatusAndJson('/api/source?file=src/handler.ts&from=10&to=4'); + expect(backwards.status).toBe(400); + + const nonNumeric = await getStatusAndJson('/api/source?file=src/handler.ts&from=abc'); + expect(nonNumeric.status).toBe(400); + }); +}); + +describe('GET /api/file/', () => { + it('returns the file record and its outline in source order', async () => { + const body = await getJson('/api/file/src/cache.ts'); + + expect(body.file.path).toBe('src/cache.ts'); + expect(body.file.language).toBe('typescript'); + expect(body.file.size).toBeGreaterThan(0); + expect(body.file.contentHash).toMatch(/^[0-9a-f]{64}$/); + expect(body.file.generated).toBe(false); + expect(body.file.test).toBe(false); + expect(body.file.id).toMatch(/^file:/); + expect(body.drift).toBe(false); + + const lines = body.outline.items.map((o: any) => o.line); + expect(lines).toEqual([...lines].sort((a, b) => a - b)); + + const cacheRow = body.outline.items.find((o: any) => o.name === 'Cache'); + expect(cacheRow.depth).toBe(0); + expect(cacheRow.parentId).toBeNull(); + + const readRow = body.outline.items.find((o: any) => o.name === 'read'); + expect(readRow.depth).toBe(1); + expect(readRow.parentId).toBe(cacheRow.id); + expect(readRow.fanIn).toBeGreaterThanOrEqual(1); + expect(typeof readRow.fanOut).toBe('number'); + + // The file node is the subject, not a row; imports have their own rail. + expect(body.outline.items.some((o: any) => o.kind === 'file')).toBe(false); + expect(body.outline.items.some((o: any) => o.kind === 'import')).toBe(false); + }); + + it('maps imports and imported-by to files', async () => { + const body = await getJson('/api/file/src/cache.ts'); + + const importedByFiles = body.importedBy.items.map((r: any) => r.file); + expect(importedByFiles).toContain('src/service.ts'); + + const importFiles = body.imports.items.map((r: any) => r.file); + expect(importFiles).toContain('src/types.ts'); + + // Never itself: same-file `imports` edges (the import declarations) are dropped. + expect(importFiles).not.toContain('src/cache.ts'); + expect(importedByFiles).not.toContain('src/cache.ts'); + + const typesRow = body.imports.items.find((r: any) => r.file === 'src/types.ts'); + expect(typesRow.symbolCount).toBeGreaterThanOrEqual(1); + expect(typesRow.symbols[0].name).toBeTruthy(); + expect(typesRow.symbols[0].id).toBeTruthy(); + expect(typesRow.test).toBe(false); + }); + + it('names the imports that never resolved rather than dropping them', async () => { + const body = await getJson('/api/file/src/service.ts'); + const names = body.unresolvedImports.map((u: any) => u.name); + expect(names).toContain('some-external-package'); + }); + + it('reports the wider cross-file relationship too', async () => { + const body = await getJson('/api/file/src/cache.ts'); + expect(body.dependents).toContain('src/service.ts'); + expect(body.dependencies).toContain('src/types.ts'); + }); + + it('says whether the file runs anything at its top level', async () => { + // `src/main.ts` instantiates a Service and calls two functions outside + // every definition — code no outline row can show, because it belongs to + // no symbol. `src/cache.ts` only defines things. + const main = await getJson('/api/file/src/main.ts'); + expect(main.topLevel.calls).toBeGreaterThanOrEqual(2); + + const cache = await getJson('/api/file/src/cache.ts'); + expect(cache.topLevel.calls).toBe(0); + }); + + it('404s a file that is not in the index and refuses one outside the project', async () => { + const missing = await getStatusAndJson('/api/file/src/nope.ts'); + expect(missing.status).toBe(404); + expect(missing.body.code).toBe('not-found'); + + const outside = await getStatusAndJson( + '/api/file/' + encodeURIComponent('/etc/passwd') + ); + expect(outside.status).toBe(403); + expect(outside.body.code).toBe('refused'); + }); +}); + +describe('GET /api/routes', () => { + it('says plainly that this project is not a routed app', async () => { + const body = await getJson('/api/routes'); + expect(body.routed).toBe(false); + expect(body.entries).toEqual([]); + expect(body.routeCount).toBe(0); + expect(body.shown).toBe(0); + expect(body.truncated).toBe(false); + }); + + it('refuses a limit the manifest cannot answer truthfully', async () => { + // Below three, the engine's manifest reports every routed project as + // unrouted — a wrong answer, so the parameter is refused instead. + for (const limit of ['0', '2', '-1', 'abc']) { + const { status, body } = await getStatusAndJson(`/api/routes?limit=${limit}`); + expect(status, `limit=${limit}`).toBe(400); + expect(body.code).toBe('bad-request'); + } + }); + + describe('a project that IS routed', () => { + let routedApi: GraphApi; + let routedServer: UiServerHandle; + + beforeAll(async () => { + const routedRoot = path.join(tempDir, 'routed'); + fs.mkdirSync(path.join(routedRoot, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(routedRoot, 'src', 'routes.ts'), + `import express from 'express'; + +const app = express(); + +export function listUsers(req: any, res: any): void { res.json([]); } +export function getUser(req: any, res: any): void { res.json({}); } +export function createUser(req: any, res: any): void { res.json({}); } +export function deleteUser(req: any, res: any): void { res.json({}); } + +app.get('/users', listUsers); +app.get('/users/:id', getUser); +app.post('/users', createUser); +app.delete('/users/:id', deleteUser); + +export default app; +` + ); + const routedCg = CodeGraph.initSync(routedRoot, { + config: { include: ['src/**/*.ts'], exclude: [] }, + }); + await routedCg.indexAll(); + routedCg.resolveReferences(); + routedCg.close(); + + routedApi = createGraphApi({ projectRoot: routedRoot }); + routedServer = await startUiServer({ + projectRoot: routedRoot, + viewerDir, + port: 0, + api: routedApi.handler, + }); + }, 120_000); + + afterAll(async () => { + routedApi?.close(); + await routedServer?.close(); + }); + + it('maps each URL to its handler, with a node id to navigate to', async () => { + const res = await requestOn(routedServer.port, '/api/routes'); + const body = JSON.parse(res.body); + + expect(body.routed).toBe(true); + expect(body.routeCount).toBe(4); + expect(body.shown).toBe(4); + expect(body.truncated).toBe(false); + expect(body.topHandlerFile).toBe('src/routes.ts'); + expect(body.topHandlerFileCount).toBe(4); + + const urls = body.entries.map((e: any) => e.url); + expect(urls).toEqual( + expect.arrayContaining(['GET /users', 'GET /users/:id', 'POST /users', 'DELETE /users/:id']) + ); + + const listUsers = body.entries.find((e: any) => e.url === 'GET /users'); + expect(listUsers.handler).toBe('listUsers'); + expect(listUsers.handlerKind).toBe('function'); + expect(listUsers.file).toBe('src/routes.ts'); + expect(listUsers.line).toBeGreaterThan(0); + + // The manifest carries no ids of its own; resolving them is what makes a + // route row clickable, so it has to actually resolve. + expect(listUsers.handlerId).toBeTruthy(); + const handler = JSON.parse( + (await requestOn(routedServer.port, `/api/node/${listUsers.handlerId}`)).body + ); + expect(handler.node.name).toBe('listUsers'); + }); + + it('offers its routes as entry points, ahead of anything derived', async () => { + const res = await requestOn(routedServer.port, '/api/entrypoints'); + const body = JSON.parse(res.body); + + expect(body.routes.routed).toBe(true); + expect(body.routes.routeCount).toBe(4); + 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.items.every((e: any) => e.handlerId)).toBe(true); + }); + + it('honours the limit and says when it cut the list', async () => { + const res = await requestOn(routedServer.port, '/api/routes?limit=3'); + const body = JSON.parse(res.body); + expect(body.routed).toBe(true); + expect(body.entries).toHaveLength(3); + expect(body.shown).toBe(3); + expect(body.truncated).toBe(true); + // The headline count is the whole graph's, not the page's. + expect(body.routeCount).toBe(4); + }); + }); +}); + +/** + * The acceptance bar from the issue, against the engine's OWN index rather than + * a fixture: `LRUCache.get` in `src/resolution/lru-cache.ts`, 500+ callers. + * + * `.codegraph/` is gitignored, so this only runs on a machine that has indexed + * this repository. The fixture test above covers the same properties in CI; this + * one is the check against the real, messy graph the number came from. + */ +describe.runIf(CodeGraph.isInitialized(path.resolve(__dirname, '..')))( + "the engine's own busiest symbol", + () => { + const repoRoot = path.resolve(__dirname, '..'); + let repoApi: GraphApi; + let repoServer: UiServerHandle; + + beforeAll(async () => { + repoApi = createGraphApi({ projectRoot: repoRoot }); + repoServer = await startUiServer({ + projectRoot: repoRoot, + viewerDir, + port: 0, + api: repoApi.handler, + }); + }); + + afterAll(async () => { + repoApi?.close(); + await repoServer?.close(); + }); + + const repoGet = (requestPath: string): Promise => + requestOn(repoServer.port, requestPath); + + it('answers in under 100 ms with grouped, capped lists and correct counts', async () => { + const search = JSON.parse( + (await repoGet('/api/search?q=' + encodeURIComponent('LRUCache.get'))).body + ); + const hit = search.results.items.find( + (r: any) => r.name === 'get' && r.file.endsWith('src/resolution/lru-cache.ts') + ); + expect(hit, 'LRUCache.get should be in the engine\'s own index').toBeTruthy(); + + await repoGet(`/api/node/${hit.id}`); // warm + + const started = performance.now(); + const res = await repoGet(`/api/node/${hit.id}`); + const elapsed = performance.now() - started; + + expect(res.status).toBe(200); + const body = JSON.parse(res.body); + + expect(body.counts.fanIn).toBeGreaterThanOrEqual(500); + expect(body.counts.hub).toBe(true); + // Grouped by calling symbol, so the row count is the distinct-caller + // count, never the edge count. + expect(body.incoming.items).toHaveLength(body.incoming.shown); + expect(body.incoming.shown).toBeLessThanOrEqual(300); + expect(body.incoming.shown).toBe(Math.min(300, body.incoming.total)); + expect(body.incoming.truncated).toBe(body.incoming.total > 300); + expect(new Set(body.incoming.items.map((r: any) => r.node.id)).size).toBe( + body.incoming.shown + ); + const edgesInRows = body.incoming.items.reduce( + (sum: number, r: any) => sum + r.edgeCount, + 0 + ); + expect(edgesInRows).toBeLessThanOrEqual(body.counts.fanIn); + expect(body.blast.direct).toBe(body.counts.callers); + expect(body.tests.reached).toBe(true); + + expect(elapsed).toBeLessThan(100); + }); + } +); + +describe('GET /api/entrypoints', () => { + it('finds the file that runs something, and reports what it reaches', async () => { + const body = await getJson('/api/entrypoints'); + + const files = body.files.items.map((f: any) => f.file); + expect(files).toContain('src/main.ts'); + + const main = body.files.items.find((f: any) => f.file === 'src/main.ts'); + expect(main.kind).toBe('file'); + expect(main.id).toMatch(/^file:/); + // `new Service(...)`, `handleRequest(...)` and `service.load(...)` all sit + // at module level. + expect(main.calls).toBeGreaterThanOrEqual(2); + // It imports from service.ts and handler.ts, so it wires files together. + expect(main.reaches).toBeGreaterThanOrEqual(2); + expect(typeof main.dependents).toBe('number'); + }); + + it('leaves test files out — "where do I start" never means a test', async () => { + const body = await getJson('/api/entrypoints'); + + for (const file of body.files.items) expect(file.test).toBe(false); + // The fixture's test file calls its own helper at module level, so it IS a + // candidate by the raw graph signal and is excluded deliberately. + expect(body.files.items.map((f: any) => f.file)).not.toContain( + '__tests__/service.test.ts' + ); + for (const hub of body.hubs.items) expect(hub.test).toBe(false); + }); + + it('ranks the most depended-on symbols as hubs, with their dependent counts', async () => { + const body = await getJson('/api/entrypoints'); + + const hot = body.hubs.items.find((h: any) => h.name === 'hot'); + expect(hot, 'the 500-caller function should top the hubs').toBeTruthy(); + expect(hot.dependents).toBe(500); + expect(body.hubs.items[0].name).toBe('hot'); + + const counts = body.hubs.items.map((h: any) => h.dependents); + expect(counts).toEqual([...counts].sort((a: number, b: number) => b - a)); + // A file or a bare import is structure, not somewhere to start reading. + for (const hub of body.hubs.items) { + expect(['file', 'import', 'export', 'parameter']).not.toContain(hub.kind); + } + }); + + 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.items).toEqual([]); + expect(body.routes.routeCount).toBe(0); + }); + + it('honours limit, and keeps every list within it', async () => { + const body = await getJson('/api/entrypoints?limit=1'); + expect(body.files.items.length).toBeLessThanOrEqual(1); + expect(body.hubs.items.length).toBe(1); + expect(body.hubs.total).toBeGreaterThanOrEqual(body.hubs.items.length); + + const bad = await getStatusAndJson('/api/entrypoints?limit=0'); + expect(bad.status).toBe(400); + expect(bad.body.code).toBe('bad-request'); + }); +}); + +describe('GET /api/nodes', () => { + it('answers a batch of ids in the order asked, and says which are missing', async () => { + const cacheId = await idOf('Cache', 'class'); + const loadId = await idOf('load', 'method'); + const body = await getJson( + `/api/nodes?id=${encodeURIComponent(loadId)}&id=${encodeURIComponent(cacheId)}&id=method%3Anot-a-real-id` + ); + + expect(body.items.map((n: any) => n.id)).toEqual([loadId, cacheId]); + expect(body.items[0].name).toBe('load'); + expect(body.items[1].name).toBe('Cache'); + expect(body.missing).toEqual(['method:not-a-real-id']); + // The REF shape, not the Symbol view payload: a trail redraws six names, + // not six rail sets. + expect(body.items[0].incoming).toBeUndefined(); + expect(body.items[0].file).toBe('src/service.ts'); + }); + + it('de-duplicates ids rather than answering twice', async () => { + const cacheId = await idOf('Cache', 'class'); + const encoded = encodeURIComponent(cacheId); + const body = await getJson(`/api/nodes?id=${encoded}&id=${encoded}`); + expect(body.items).toHaveLength(1); + }); + + it('refuses an empty or oversized request with guidance', async () => { + const none = await getStatusAndJson('/api/nodes'); + expect(none.status).toBe(400); + expect(none.body.hint).toContain('id='); + + const ids = Array.from({ length: 61 }, (_, i) => `id=method%3A${i}`).join('&'); + const many = await getStatusAndJson(`/api/nodes?${ids}`); + expect(many.status).toBe(400); + expect(many.body.error).toContain('Too many ids'); + }); +}); + +describe('an index that is not there', () => { + it('answers with the same guidance the CLI prints, not a stack trace', async () => { + const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-noindex-')); + const detached = createGraphApi({ projectRoot: emptyRoot }); + const detachedServer = await startUiServer({ + projectRoot: emptyRoot, + viewerDir, + port: 0, + api: detached.handler, + }); + try { + const res = await requestOn(detachedServer.port, '/api/stats'); + + expect(res.status).toBe(503); + const body = JSON.parse(res.body); + expect(body.code).toBe('no-index'); + expect(body.error).toContain('No CodeGraph index found'); + expect(body.hint).toContain('codegraph init'); + expect(body.error).not.toContain(' at '); + } finally { + detached.close(); + await detachedServer.close(); + fs.rmSync(emptyRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/ui-server.test.ts b/__tests__/ui-server.test.ts new file mode 100644 index 000000000..df25858a8 --- /dev/null +++ b/__tests__/ui-server.test.ts @@ -0,0 +1,566 @@ +/** + * `codegraph ui` server — the loopback boundary (CG-41). + * + * This process serves the user's source code from a port on their machine, so + * the tests that matter are the refusals: a foreign `Host` (DNS rebinding is + * the only realistic attack on a loopback code viewer), a traversal out of the + * asset root, a write method, a cross-origin read. The happy path — index.html + * and hashed assets — is here mostly so a refusal that accidentally blocks + * everything can't pass. + * + * Requests go through `http.request`, not `fetch`: `Host` is a forbidden header + * name in undici, and forging it is the whole point of half these cases. + */ + +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 { + browserOpenCommand, + cacheControlFor, + contentTypeFor, + isAllowedHost, + isAllowedOrigin, + isSafeRequestPath, + PathRefusalError, + resolveProjectFile, + resolveStaticAsset, + startUiServer, + type UiServerHandle, +} from '../src/ui-server'; + +interface Response { + status: number; + headers: http.IncomingHttpHeaders; + body: string; +} + +/** + * One request with full control over the request line and headers. + * + * `setHost: false` stops node from adding its own `Host`, and `path` is sent + * verbatim — so a traversal case really does put `/../../x` on the wire. + */ +function request( + port: number, + requestPath: string, + options: { method?: string; headers?: Record } = {} +): Promise { + return new Promise((resolve, reject) => { + const headers: Record = { Host: `127.0.0.1:${port}`, ...options.headers }; + const req = http.request( + { host: '127.0.0.1', port, path: requestPath, method: options.method ?? 'GET', headers, setHost: false }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString('utf-8'), + }) + ); + } + ); + req.on('error', reject); + req.end(); + }); +} + +describe('codegraph ui server', () => { + let tempDir: string; + let viewerDir: string; + let projectRoot: string; + let server: UiServerHandle; + + beforeAll(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-server-')); + + // A stand-in for dist/viewer: same shape (index.html + hashed assets/), so + // the tests don't need the Svelte build to have run. + viewerDir = path.join(tempDir, 'viewer'); + fs.mkdirSync(path.join(viewerDir, 'assets'), { recursive: true }); + fs.writeFileSync( + path.join(viewerDir, 'index.html'), + '
' + + '' + ); + fs.writeFileSync(path.join(viewerDir, 'assets', 'index-abc123.js'), 'export const viewer = 1;\n'); + fs.writeFileSync(path.join(viewerDir, 'assets', 'index-abc123.css'), ':root{color:#16150f}\n'); + + projectRoot = path.join(tempDir, 'project'); + fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'src', 'auth.ts'), 'export const token = 1;\n'); + + // A file OUTSIDE both roots that a traversal would be trying to reach. + fs.writeFileSync(path.join(tempDir, 'secret.txt'), 'SUPER-SECRET-VALUE\n'); + + server = await startUiServer({ projectRoot, viewerDir, port: 0 }); + }); + + afterAll(async () => { + await server?.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('serving the viewer', () => { + it('serves index.html at the root', async () => { + const res = await request(server.port, '/'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toBe('text/html; charset=utf-8'); + expect(res.body).toContain('
'); + }); + + it('serves index.html directly too', async () => { + const res = await request(server.port, '/index.html'); + expect(res.status).toBe(200); + expect(res.body).toContain('
'); + }); + + it('serves hashed assets with their real content type', async () => { + const js = await request(server.port, '/assets/index-abc123.js'); + expect(js.status).toBe(200); + expect(js.headers['content-type']).toBe('text/javascript; charset=utf-8'); + expect(js.body).toContain('export const viewer'); + + const css = await request(server.port, '/assets/index-abc123.css'); + expect(css.status).toBe(200); + expect(css.headers['content-type']).toBe('text/css; charset=utf-8'); + }); + + it('caches hashed assets forever and index.html never', async () => { + const asset = await request(server.port, '/assets/index-abc123.js'); + expect(asset.headers['cache-control']).toBe('public, max-age=31536000, immutable'); + const index = await request(server.port, '/'); + expect(index.headers['cache-control']).toBe('no-store'); + }); + + it('falls back to index.html for an unknown route, but not for a missing asset', async () => { + // A hash-routed app only ever asks for `/`, but a hand-typed deep path + // should still open the app. + const route = await request(server.port, '/s/some-symbol-id'); + expect(route.status).toBe(200); + expect(route.body).toContain('
'); + + // A missing FILE must 404 — answering with HTML would hand the browser a + // script that isn't one, and hide a broken build. + const asset = await request(server.port, '/assets/index-doesnotexist.js'); + expect(asset.status).toBe(404); + }); + + it('answers HEAD with the same headers and no body', async () => { + const res = await request(server.port, '/', { method: 'HEAD' }); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toBe('text/html; charset=utf-8'); + expect(res.headers['content-length']).toBeDefined(); + expect(res.body).toBe(''); + }); + }); + + describe('binding', () => { + it('listens on loopback only', () => { + const address = server.server.address(); + expect(address).not.toBeNull(); + expect(typeof address === 'object' ? address?.address : null).toBe('127.0.0.1'); + expect(server.url).toBe(`http://127.0.0.1:${server.port}`); + }); + + it('falls back to the next free port when the preferred one is taken', async () => { + const blocker = http.createServer(() => {}); + await new Promise((resolve) => blocker.listen(0, '127.0.0.1', resolve)); + const taken = (blocker.address() as { port: number }).port; + + const second = await startUiServer({ projectRoot, viewerDir, port: taken }); + try { + expect(second.port).not.toBe(taken); + expect(second.port).toBeGreaterThan(taken); + // …and it actually works on the port it landed on. + const res = await request(second.port, '/'); + expect(res.status).toBe(200); + } finally { + await second.close(); + await new Promise((resolve) => blocker.close(() => resolve())); + } + }); + + it('refuses to move off a port the caller pinned', async () => { + const blocker = http.createServer(() => {}); + await new Promise((resolve) => blocker.listen(0, '127.0.0.1', resolve)); + const taken = (blocker.address() as { port: number }).port; + + try { + await expect( + startUiServer({ projectRoot, viewerDir, port: taken, portFallback: false }) + ).rejects.toThrow(/already in use/i); + } finally { + await new Promise((resolve) => blocker.close(() => resolve())); + } + }); + }); + + describe('Host allowlist (DNS rebinding)', () => { + it('serves the loopback names', async () => { + for (const host of ['127.0.0.1', 'localhost', '[::1]', `localhost:${server.port}`, `[::1]:${server.port}`]) { + const res = await request(server.port, '/', { headers: { Host: host } }); + expect(res.status, `Host: ${host}`).toBe(200); + } + }); + + it('refuses a foreign Host', async () => { + for (const host of ['evil.example', `evil.example:${server.port}`, 'attacker.localhost.evil.com']) { + const res = await request(server.port, '/', { headers: { Host: host } }); + expect(res.status, `Host: ${host}`).toBe(403); + expect(res.body).not.toContain('
'); + } + }); + + it('refuses a loopback Host carrying someone else\u2019s port', async () => { + const res = await request(server.port, '/', { headers: { Host: '127.0.0.1:9' } }); + expect(res.status).toBe(403); + }); + + it('refuses a malformed or missing Host', async () => { + const malformed = await request(server.port, '/', { headers: { Host: '127.0.0.1:notaport' } }); + expect(malformed.status).toBe(403); + // Node's client insists on sending something for Host, so the empty-value + // case is covered by the unit assertions on isAllowedHost below. + }); + + it('refuses before touching the filesystem — even for an asset', async () => { + const res = await request(server.port, '/assets/index-abc123.js', { + headers: { Host: 'evil.example' }, + }); + expect(res.status).toBe(403); + expect(res.body).not.toContain('export const viewer'); + }); + }); + + describe('cross-origin', () => { + it('never sends CORS headers', async () => { + const res = await request(server.port, '/'); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + expect(res.headers['access-control-allow-credentials']).toBeUndefined(); + expect(res.headers['access-control-allow-methods']).toBeUndefined(); + }); + + it('refuses a request carrying a foreign Origin', async () => { + const res = await request(server.port, '/', { headers: { Origin: 'https://evil.example' } }); + expect(res.status).toBe(403); + }); + + it('allows the viewer\u2019s own origin', async () => { + const res = await request(server.port, '/', { + headers: { Origin: `http://127.0.0.1:${server.port}` }, + }); + expect(res.status).toBe(200); + }); + + it('sends the hardening headers on every response', async () => { + const res = await request(server.port, '/'); + expect(res.headers['x-content-type-options']).toBe('nosniff'); + expect(res.headers['x-frame-options']).toBe('DENY'); + expect(res.headers['content-security-policy']).toContain("frame-ancestors 'none'"); + expect(res.headers['content-security-policy']).toContain("connect-src 'self'"); + }); + }); + + describe('methods', () => { + it('refuses every method it has never answered', async () => { + for (const method of ['PUT', 'PATCH', 'OPTIONS', 'TRACE']) { + const res = await request(server.port, '/', { method }); + expect(res.status, method).toBe(405); + expect(res.headers['allow']).toBe('GET, HEAD, POST, DELETE'); + } + }); + + /** + * The static side stayed a pure reader when `/api/trails` gained a write + * (CG-60). A POST at an asset path is 405 with `Allow: GET, HEAD` — the + * narrower answer, since nothing under the viewer bundle will ever take + * one. + */ + it('refuses a write outside /api/, whatever it carries', async () => { + for (const method of ['POST', 'DELETE']) { + const res = await request(server.port, '/', { + method, + headers: { 'X-CodeGraph-UI': '1' }, + }); + expect(res.status, method).toBe(405); + expect(res.headers['allow']).toBe('GET, HEAD'); + } + }); + + /** + * Under `/api/` a write is answered as JSON even when refused — the viewer + * parses these, and a text/plain body surfaces as a parse error rather than + * the refusal it is. No API is mounted on this server, so the refusal is + * the boundary's own and not an endpoint's. + */ + it('refuses an unmarked write under /api/ as JSON', async () => { + const res = await request(server.port, '/api/trails', { method: 'POST' }); + expect(res.status).toBe(403); + expect(res.headers['content-type']).toContain('application/json'); + expect(JSON.parse(res.body).code).toBe('refused'); + }); + }); + + describe('paths outside the asset root', () => { + const traversals = [ + '/../secret.txt', + '/../../secret.txt', + '/assets/../../secret.txt', + '/..%2fsecret.txt', + '/%2e%2e/secret.txt', + '/%2e%2e%2fsecret.txt', + '/....//secret.txt', + ]; + + it('never serves a file outside the viewer directory', async () => { + for (const traversal of traversals) { + const res = await request(server.port, traversal); + expect(res.body, traversal).not.toContain('SUPER-SECRET-VALUE'); + expect(res.status, traversal).not.toBe(200); + } + }); + + it('404s an absolute system path rather than reading it', async () => { + const res = await request(server.port, '/etc/passwd'); + expect(res.body).not.toContain('root:'); + // No such file under the viewer root; an extension-less path is a route. + expect(res.status).toBe(200); + expect(res.body).toContain('
'); + + const shadow = await request(server.port, '/etc/hosts.txt'); + expect(shadow.status).toBe(404); + }); + + it('404s a NUL-truncation attempt', async () => { + const res = await request(server.port, '/index.html%00.png'); + expect(res.status).toBe(404); + }); + }); + + describe('/api is reserved', () => { + it('404s as JSON, never as the app shell', async () => { + const res = await request(server.port, '/api/nodes'); + expect(res.status).toBe(404); + expect(res.headers['content-type']).toBe('application/json; charset=utf-8'); + expect(JSON.parse(res.body)).toHaveProperty('error'); + expect(res.body).not.toContain('
'); + }); + + it('hands requests to a mounted handler with a decoded path and query', async () => { + const seen: Array<{ pathname: string; symbol: string | null; root: string }> = []; + const withApi = await startUiServer({ + projectRoot, + viewerDir, + port: 0, + api: (_req, res, ctx) => { + seen.push({ + pathname: ctx.pathname, + symbol: ctx.query.get('symbol'), + root: ctx.projectRoot, + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"ok":true}'); + return true; + }, + }); + try { + const res = await request(withApi.port, '/api/node?symbol=parse%20Token'); + expect(res.status).toBe(200); + expect(JSON.parse(res.body)).toEqual({ ok: true }); + expect(seen).toEqual([{ pathname: '/api/node', symbol: 'parse Token', root: projectRoot }]); + } finally { + await withApi.close(); + } + }); + + it('turns a throwing handler into a JSON 500, not a crashed server', async () => { + const withApi = await startUiServer({ + projectRoot, + viewerDir, + port: 0, + api: () => { + throw new Error('handler blew up'); + }, + }); + try { + const res = await request(withApi.port, '/api/boom'); + expect(res.status).toBe(500); + expect(JSON.parse(res.body).error).toContain('handler blew up'); + // Still alive afterwards. + expect((await request(withApi.port, '/')).status).toBe(200); + } finally { + await withApi.close(); + } + }); + }); +}); + +describe('resolveProjectFile — the source read chokepoint', () => { + let tempDir: string; + let projectRoot: string; + + beforeAll(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-paths-')); + projectRoot = path.join(tempDir, 'project'); + fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'src', 'auth.ts'), 'export const token = 1;\n'); + fs.writeFileSync(path.join(tempDir, 'secret.txt'), 'SUPER-SECRET-VALUE\n'); + }); + + afterAll(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('resolves a file inside the project', () => { + expect(resolveProjectFile(projectRoot, 'src/auth.ts')).toBe( + fs.realpathSync(path.join(projectRoot, 'src', 'auth.ts')) + ); + }); + + it('refuses traversal out of the project', () => { + for (const escape of ['../secret.txt', 'src/../../secret.txt', '..%2fsecret.txt']) { + expect(() => resolveProjectFile(projectRoot, escape), escape).toThrow(PathRefusalError); + } + }); + + it('refuses an absolute path', () => { + expect(() => resolveProjectFile(projectRoot, path.join(tempDir, 'secret.txt'))).toThrow( + PathRefusalError + ); + }); + + it('refuses an empty path', () => { + expect(() => resolveProjectFile(projectRoot, '')).toThrow(PathRefusalError); + expect(() => resolveProjectFile(projectRoot, ' ')).toThrow(PathRefusalError); + }); + + it('refuses a NUL byte', () => { + expect(() => resolveProjectFile(projectRoot, 'src/auth.ts%00.png')).toThrow(PathRefusalError); + }); + + // `/etc` resolves to a non-existent `C:\etc` on Windows, so the sensitive-path + // list only means anything on POSIX. + it.runIf(process.platform !== 'win32')('refuses a sensitive system directory as the root', () => { + expect(() => resolveProjectFile('/etc', 'passwd')).toThrow(PathRefusalError); + expect(() => resolveProjectFile('/', 'etc/passwd')).toThrow(PathRefusalError); + }); + + it.runIf(process.platform !== 'win32')('refuses a symlink pointing out of the project (#527)', () => { + const link = path.join(projectRoot, 'src', 'escape.ts'); + fs.symlinkSync(path.join(tempDir, 'secret.txt'), link); + try { + expect(() => resolveProjectFile(projectRoot, 'src/escape.ts')).toThrow(PathRefusalError); + } finally { + fs.unlinkSync(link); + } + }); +}); + +describe('security helpers', () => { + it('isAllowedHost accepts only loopback names on our port', () => { + expect(isAllowedHost('127.0.0.1', 4747)).toBe(true); + expect(isAllowedHost('127.0.0.1:4747', 4747)).toBe(true); + expect(isAllowedHost('localhost:4747', 4747)).toBe(true); + expect(isAllowedHost('LOCALHOST', 4747)).toBe(true); + expect(isAllowedHost('[::1]:4747', 4747)).toBe(true); + + expect(isAllowedHost(undefined, 4747)).toBe(false); + expect(isAllowedHost('', 4747)).toBe(false); + expect(isAllowedHost('evil.example', 4747)).toBe(false); + expect(isAllowedHost('127.0.0.1:4748', 4747)).toBe(false); + expect(isAllowedHost('127.0.0.1.evil.example', 4747)).toBe(false); + expect(isAllowedHost('localhost.evil.example:4747', 4747)).toBe(false); + expect(isAllowedHost('127.0.0.1:4747:4747', 4747)).toBe(false); + // Unbracketed IPv6 is malformed per RFC 7230 — rejected, not guessed at. + expect(isAllowedHost('::1', 4747)).toBe(false); + // A non-loopback address that merely resolves here still fails the check. + expect(isAllowedHost('192.168.1.5:4747', 4747)).toBe(false); + }); + + it('isAllowedOrigin allows absent and same-origin, refuses everything else', () => { + expect(isAllowedOrigin(undefined, 4747)).toBe(true); + expect(isAllowedOrigin('http://127.0.0.1:4747', 4747)).toBe(true); + expect(isAllowedOrigin('http://localhost:4747', 4747)).toBe(true); + expect(isAllowedOrigin('http://[::1]:4747', 4747)).toBe(true); + + expect(isAllowedOrigin('null', 4747)).toBe(false); + expect(isAllowedOrigin('https://evil.example', 4747)).toBe(false); + expect(isAllowedOrigin('http://127.0.0.1:4748', 4747)).toBe(false); + expect(isAllowedOrigin('file://', 4747)).toBe(false); + expect(isAllowedOrigin('not a url', 4747)).toBe(false); + }); + + it('isSafeRequestPath rejects a `..` segment however it is spelled', () => { + expect(isSafeRequestPath('/')).toBe(true); + expect(isSafeRequestPath('/assets/index-abc123.js')).toBe(true); + expect(isSafeRequestPath('/s/Some.Symbol')).toBe(true); + + expect(isSafeRequestPath('/../secret')).toBe(false); + expect(isSafeRequestPath('/a/../../secret')).toBe(false); + expect(isSafeRequestPath('/%2e%2e/secret')).toBe(false); + expect(isSafeRequestPath('/..%2Fsecret')).toBe(false); + expect(isSafeRequestPath('/a%00b')).toBe(false); + expect(isSafeRequestPath('/a\\b')).toBe(false); + expect(isSafeRequestPath('/%zz')).toBe(false); + }); + + it('resolveStaticAsset returns null for anything that is not a file in the root', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-static-')); + try { + fs.mkdirSync(path.join(dir, 'assets')); + fs.writeFileSync(path.join(dir, 'index.html'), 'x'); + expect(resolveStaticAsset(dir, '/index.html')).toBe( + fs.realpathSync(path.join(dir, 'index.html')) + ); + expect(resolveStaticAsset(dir, '/assets')).toBeNull(); // a directory + expect(resolveStaticAsset(dir, '/missing.js')).toBeNull(); + expect(resolveStaticAsset(dir, '/../etc/passwd')).toBeNull(); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('contentTypeFor covers the viewer bundle and defaults safely', () => { + expect(contentTypeFor('a/index.html')).toBe('text/html; charset=utf-8'); + expect(contentTypeFor('a/index-abc.js')).toBe('text/javascript; charset=utf-8'); + expect(contentTypeFor('a/archivo.woff2')).toBe('font/woff2'); + expect(contentTypeFor('a/thing.unknownext')).toBe('application/octet-stream'); + }); + + it('cacheControlFor pins hashed assets and never index.html', () => { + expect(cacheControlFor(path.join('assets', 'index-abc.js'))).toContain('immutable'); + expect(cacheControlFor('index.html')).toBe('no-store'); + }); +}); + +describe('browserOpenCommand', () => { + it('uses the platform opener', () => { + expect(browserOpenCommand('http://x', 'darwin')).toEqual({ command: 'open', args: ['http://x'] }); + expect(browserOpenCommand('http://x', 'linux')).toEqual({ command: 'xdg-open', args: ['http://x'] }); + expect(browserOpenCommand('http://x', 'win32')).toEqual({ + command: 'cmd', + args: ['/c', 'start', '', 'http://x'], + }); + }); + + it('honours the CODEGRAPH_BROWSER override', () => { + expect(browserOpenCommand('http://x', 'darwin', 'firefox')).toEqual({ + command: 'firefox', + args: ['http://x'], + }); + // Windows routes the override through cmd so a `.cmd`/`.bat` shim — which + // CreateProcess cannot launch directly — still works. + expect(browserOpenCommand('http://x', 'win32', 'C:\\tools\\open.cmd')).toEqual({ + command: 'cmd', + args: ['/c', 'C:\\tools\\open.cmd', 'http://x'], + }); + for (const off of ['none', 'NONE', '0', 'false', 'off', '', ' ']) { + expect(browserOpenCommand('http://x', 'darwin', off), off).toBeNull(); + } + }); +}); diff --git a/__tests__/ui-symbol-model.test.ts b/__tests__/ui-symbol-model.test.ts new file mode 100644 index 000000000..b97d11118 --- /dev/null +++ b/__tests__/ui-symbol-model.test.ts @@ -0,0 +1,577 @@ +/** + * The Symbol view's decisions, without a browser (CG-44). + * + * Everything the screen does that could be wrong rather than merely ugly lives + * in `ui/src/lib/` as plain functions over the `/api/node` payload: which lines + * survive into a windowed body, which identifier a call-site link lands on, + * which callers fold away, which reference is a guess. Those are the parts + * worth pinning — the geometry that needs a real layout (row placement, + * connector paths) is verified against a running viewer instead. + */ + +import { describe, it, expect } from 'vitest'; +import { + assignRefs, + buildCalleeRail, + buildCallerRail, + buildCodeBlock, + buildOutline, + edgeWord, + graphCallLines, + kindPhrase, + refsByLine, + showsBody, + synthesizedBy, + FULL_BODY_LINES, + HEAD_LINES, + type LineRef, +} from '../ui/src/lib/symbol-model'; +import { decodeLine, plainLine, tokensByLine } from '../ui/src/lib/highlight'; +import type { WireRelation, WireSymbolPayload } from '../ui/src/lib/api'; + +/* ------------------------------------------------------------- fixtures -- */ + +function nodeRef(over: Partial = {}): any { + return { + id: 'method:a', + kind: 'method', + name: 'load', + qualifiedName: 'Service::load', + file: 'src/service.ts', + line: 10, + endLine: 20, + language: 'typescript', + test: false, + ...over, + }; +} + +function relation(over: Partial & { node?: any } = {}): WireRelation { + const { node, ...rest } = over; + const lines = rest.lines ?? [12]; + return { + edgeKinds: ['calls'], + edges: lines.map((line) => ({ kind: 'calls' as const, line, col: 4 })), + edgeCount: lines.length, + lines, + confidence: 0.9, + uncertain: false, + synthesized: false, + ...rest, + node: nodeRef(node), + } as WireRelation; +} + +function payload(over: Partial = {}): WireSymbolPayload { + return { + node: { ...nodeRef(), startColumn: 2, endColumn: 3, lines: 11 }, + ancestors: [], + members: { total: 0, shown: 0, truncated: false, items: [] }, + incoming: { total: 0, shown: 0, truncated: false, items: [] }, + outgoing: { total: 0, shown: 0, truncated: false, items: [] }, + typesUsed: [], + counts: { callers: 0, callees: 0, typesUsed: 0, fanIn: 0, fanOut: 0, members: 0, hub: false }, + tests: { reached: false, hops: null, fileCount: 0, files: [], exhaustive: true, hopsSearched: 3 }, + outsideIndex: { total: 0, byKind: {}, samples: [] }, + blast: null, + drift: false, + ...over, + } as WireSymbolPayload; +} + +const body = (count: number, from = 1): string[] => + Array.from({ length: count }, (_, i) => `line ${from + i}`); + +/* ---------------------------------------------------------------- words -- */ + +describe('edge wording', () => { + it('names the relationships that are not a plain call, and leaves calls unlabelled', () => { + // Labelling every row "calls" is noise that hides the rows where the + // relationship is something else. + expect(edgeWord({ kind: 'calls' })).toBe(''); + expect(edgeWord({ kind: 'instantiates' })).toBe('creates'); + expect(edgeWord({ kind: 'references' })).toBe('uses type'); + expect(edgeWord({ kind: 'references', valueRef: true })).toBe('passes as value'); + expect(edgeWord({ kind: 'implements' })).toBe('implements'); + }); + + it('names the synthesizer behind a heuristic edge, and nothing for a parsed one', () => { + const parsed = relation(); + expect(synthesizedBy(parsed)).toBeNull(); + + const synthesized = { + ...parsed, + synthesized: true, + edges: [{ kind: 'calls', line: 12, provenance: 'heuristic', synthesizedBy: 'react-render' }], + } as WireRelation; + expect(synthesizedBy(synthesized)).toBe('react-render'); + }); + + it('falls back to a truthful placeholder when the synthesizer did not name itself', () => { + const synthesized = { + ...relation(), + synthesized: true, + edges: [{ kind: 'calls', line: 12, provenance: 'heuristic' }], + } as WireRelation; + expect(synthesizedBy(synthesized)).toBe('synthesized'); + }); +}); + +describe('kindPhrase', () => { + it('reads the modifiers a reader acts on, and stays silent about the default ones', () => { + expect(kindPhrase({ kind: 'method', async: true })).toBe('method · async'); + expect(kindPhrase({ kind: 'type_alias' })).toBe('type'); + expect(kindPhrase({ kind: 'method', visibility: 'public' })).toBe('method'); + expect(kindPhrase({ kind: 'method', static: true, visibility: 'private' })).toBe( + 'method · static · private' + ); + }); +}); + +/* -------------------------------------------------------------- windows -- */ + +describe('buildCodeBlock', () => { + it('shows a body of 260 lines or fewer whole, with no gaps', () => { + const block = buildCodeBlock(1, body(FULL_BODY_LINES), [5, 200]); + expect(block.whole).toBe(true); + expect(block.windows).toHaveLength(1); + expect(block.windows[0]?.start).toBe(1); + expect(block.windows[0]?.lines).toHaveLength(FULL_BODY_LINES); + expect(block.gapsAfter).toEqual([]); + expect(block.tailGap).toBe(0); + }); + + it('keeps the head plus a window round every call site once the body is longer', () => { + // One call, far past the head: head + one ±4 window, one gap between them. + const block = buildCodeBlock(1, body(400), [300]); + expect(block.whole).toBe(false); + expect(block.windows).toHaveLength(2); + expect(block.windows[0]).toMatchObject({ start: 1 }); + expect(block.windows[0]?.lines).toHaveLength(HEAD_LINES); + expect(block.windows[1]?.start).toBe(296); + expect(block.windows[1]?.lines).toHaveLength(9); + expect(block.gapsAfter).toEqual([215]); + // 400 − 304 lines never reached the screen, and the block says how many. + expect(block.tailGap).toBe(96); + }); + + it('merges windows that all but touch, rather than drawing a one-line gap', () => { + const block = buildCodeBlock(1, body(400), [300, 310]); + // 296–304 and 306–314 are two apart: one window, no gap row between them. + expect(block.windows).toHaveLength(2); + expect(block.windows[1]).toMatchObject({ start: 296 }); + expect(block.windows[1]?.lines).toHaveLength(19); + expect(block.gapsAfter).toEqual([215]); + }); + + it('ignores call sites already inside the head', () => { + const block = buildCodeBlock(1, body(400), [3, 40]); + expect(block.windows).toHaveLength(1); + expect(block.windows[0]?.lines).toHaveLength(HEAD_LINES); + expect(block.tailGap).toBe(320); + }); + + it("numbers windows from the symbol's real first line, not from one", () => { + const block = buildCodeBlock(778, body(400, 778), [1000]); + expect(block.windows[0]?.start).toBe(778); + expect(block.windows[1]?.start).toBe(996); + expect(block.windows[1]?.lines[0]).toBe('line 996'); + }); + + it('never runs a window past the end of the body', () => { + const block = buildCodeBlock(1, body(400), [399]); + const last = block.windows[block.windows.length - 1]; + expect((last?.start ?? 0) + (last?.lines.length ?? 0) - 1).toBe(400); + expect(block.tailGap).toBe(0); + }); + + it('windows only on edges that reach the graph, not on unresolved references', () => { + // A function calling `console.log` 200 times would otherwise window around + // nearly every line, and the head-plus-windows rule would buy nothing. + const view = payload({ + outgoing: { total: 1, shown: 1, truncated: false, items: [relation({ lines: [300] })] }, + outsideIndex: { + total: 1, + byKind: { calls: 1 }, + samples: [{ name: 'console.log', kind: 'calls', line: 350, col: 4 }], + }, + }); + expect(graphCallLines(view)).toEqual([300]); + expect(refsByLine(view).has(350)).toBe(true); + }); +}); + +/* ----------------------------------------------------------------- refs -- */ + +describe('assignRefs', () => { + const toks = (line: string) => plainLine(line); + const ref = (over: Partial): LineRef => ({ + ident: 'withLock', + col: null, + targetId: 'method:x', + uncertain: false, + outside: false, + title: '', + ...over, + }); + + it('marks the callee, not the receiver the column actually points at', () => { + // The recorded column is the start of the calling EXPRESSION, so an exact + // hit is the exception: `this` sits at column 11, `withLock` at 27. + const line = ' return this.indexMutex.withLock(async () => {'; + const tokens = toks(line); + const claimed = assignRefs(tokens, [ref({ col: 11 })]); + const [index] = [...claimed.keys()]; + expect(tokens[index as number]?.text).toBe('withLock'); + }); + + it('prefers the token the column lands inside when there is one', () => { + const line = 'render(); render();'; + const tokens = toks(line); + const second = line.lastIndexOf('render'); + const claimed = assignRefs(tokens, [ref({ ident: 'render', col: second })]); + const [index] = [...claimed.keys()]; + expect(tokens[index as number]?.col).toBe(second); + }); + + it('gives two refs to the same name two different tokens', () => { + const tokens = toks('render(); render();'); + const claimed = assignRefs(tokens, [ + ref({ ident: 'render', col: null, targetId: 'a' }), + ref({ ident: 'render', col: null, targetId: 'b' }), + ]); + expect(claimed.size).toBe(2); + expect(new Set([...claimed.values()].map((r) => r.targetId))).toEqual(new Set(['a', 'b'])); + }); + + it('claims nothing when the identifier is not on the line', () => { + // Better a missing link than an accent underline on the wrong word. + expect(assignRefs(toks('return 1;'), [ref({ ident: 'nowhere' })]).size).toBe(0); + }); + + it('never marks a word inside a comment or a string as a call site', () => { + // The classification comes from the server's grammar; what this pins is + // that the overlay respects it. Anything else — a keyword, a type name a + // grammar happened to scope as `storage.type` — stays claimable, because a + // grammar's opinion about a scope name must not decide what navigates. + const comment = [ + { cls: 'comment', text: '// call render here', col: 0 }, + ]; + expect(assignRefs(comment, [ref({ ident: 'render' })]).size).toBe(0); + + const string = [ + { cls: 'keyword', text: 'const', col: 0 }, + { cls: 'other', text: ' s = ', col: 5 }, + { cls: 'string', text: '"render"', col: 10 }, + { cls: 'other', text: ';', col: 18 }, + ]; + expect(assignRefs(string, [ref({ ident: 'render' })]).size).toBe(0); + }); + + it('still claims an identifier a grammar classified as something else', () => { + // Go scopes `string` as storage.type; Java does the same to a declared + // type name. A link that disappeared over that would be a highlighting + // change silently breaking navigation. + const tokens = [ + { cls: 'keyword', text: 'Duration', col: 0 }, + { cls: 'other', text: '.Since(t)', col: 8 }, + ]; + const claimed = assignRefs(tokens, [ref({ ident: 'Duration', col: 0 })]); + expect(claimed.size).toBe(1); + expect(tokens[[...claimed.keys()][0] as number]?.text).toBe('Duration'); + }); +}); + +describe('refsByLine', () => { + it('carries type references too, so a line that only names a type gets its port', () => { + const view = payload({ + typesUsed: [relation({ node: { id: 'interface:c', kind: 'interface', name: 'Config' }, lines: [11] })], + }); + const refs = refsByLine(view); + expect(refs.get(11)?.[0]).toMatchObject({ ident: 'Config', outside: false }); + }); + + it('uses the last segment of a qualified name — that is what is in the source', () => { + const view = payload({ + outgoing: { + total: 1, + shown: 1, + truncated: false, + items: [relation({ node: { id: 'm:1', name: 'Cache.read' }, lines: [12] })], + }, + }); + expect(refsByLine(view).get(12)?.[0]?.ident).toBe('read'); + }); + + it('drops an unresolved "name" that is not an identifier at all', () => { + // The resolver's samples are raw bookkeeping; a captured arrow function + // cannot be found in the line, and searching for it would claim the wrong + // token. + const view = payload({ + outsideIndex: { + total: 2, + byKind: { calls: 2 }, + samples: [ + { name: '(() => {\n return t', kind: 'calls', line: 12, col: 0 }, + { name: 'this.db', kind: 'function_ref', line: 13, col: 4 }, + ], + }, + }); + const refs = refsByLine(view); + expect(refs.has(12)).toBe(false); + // `this.db` reduces to `db`, which IS in the line — kept, and marked as + // outside the index so it renders as text rather than a link. + expect(refs.get(13)?.[0]).toMatchObject({ ident: 'db', outside: true, targetId: null }); + }); +}); + +/* ---------------------------------------------------------------- rails -- */ + +describe('buildCallerRail', () => { + const caller = (over: { id: string; file: string; test?: boolean; uncertain?: boolean; edges?: number }) => + ({ + ...relation({ lines: [4657] }), + node: { + ...nodeRef({ id: over.id, file: over.file, name: over.id }), + test: over.test ?? false, + }, + edgeCount: over.edges ?? 1, + uncertain: over.uncertain ?? false, + }) as WireRelation; + + it("puts the symbol's own file first and groups the rest by path", () => { + const view = payload({ + node: { ...nodeRef({ file: 'src/service.ts' }), startColumn: 0, endColumn: 0, lines: 11 }, + incoming: { + total: 3, + shown: 3, + truncated: false, + items: [ + caller({ id: 'z', file: 'src/z.ts' }), + caller({ id: 'a', file: 'src/a.ts' }), + caller({ id: 'own', file: 'src/service.ts' }), + ], + }, + }); + const rail = buildCallerRail(view); + expect(rail.groups.map((g) => g.file)).toEqual(['src/service.ts', 'src/a.ts', 'src/z.ts']); + expect(rail.groups[0]?.same).toBe(true); + expect(rail.groups[1]?.same).toBe(false); + }); + + it('folds test callers away with their call and file counts intact', () => { + const view = payload({ + incoming: { + total: 3, + shown: 3, + truncated: false, + items: [ + caller({ id: 'prod', file: 'src/a.ts' }), + caller({ id: 't1', file: '__tests__/a.test.ts', test: true, edges: 4 }), + caller({ id: 't2', file: '__tests__/b.test.ts', test: true, edges: 2 }), + ], + }, + }); + const rail = buildCallerRail(view); + expect(rail.groups).toHaveLength(1); + expect(rail.tests.rows).toHaveLength(2); + expect(rail.tests.calls).toBe(6); + expect(rail.tests.files).toEqual(['__tests__/a.test.ts', '__tests__/b.test.ts']); + // The header count stays the real one — nothing is silently dropped. + expect(rail.total).toBe(3); + }); + + it('folds an uncertain test caller as uncertain, not as a test', () => { + // Uncertainty is a claim about the EDGE. Filing it under "tests" would + // present a name-only guess as an established call. + const view = payload({ + incoming: { + total: 1, + shown: 1, + truncated: false, + items: [caller({ id: 'g', file: '__tests__/a.test.ts', test: true, uncertain: true })], + }, + }); + const rail = buildCallerRail(view); + expect(rail.uncertain).toHaveLength(1); + expect(rail.tests.rows).toHaveLength(0); + expect(rail.groups).toHaveLength(0); + }); + + it('reports the callers the API had to cap away', () => { + const view = payload({ + incoming: { total: 545, shown: 1, truncated: true, items: [caller({ id: 'a', file: 'src/a.ts' })] }, + }); + expect(buildCallerRail(view).hiddenGroups).toBe(544); + }); +}); + +describe('buildCalleeRail', () => { + it('anchors each row to its first call site and folds the guesses to the bottom', () => { + const view = payload({ + outgoing: { + total: 2, + shown: 2, + truncated: false, + items: [ + relation({ node: { id: 'sure' }, lines: [12, 18] }), + { ...relation({ node: { id: 'guess' }, lines: [15] }), uncertain: true, confidence: 0.4 }, + ], + }, + }); + const rail = buildCalleeRail(view); + expect(rail.rows).toHaveLength(1); + expect(rail.rows[0]?.anchor).toBe(12); + expect(rail.rows[0]?.lines).toEqual([12, 18]); + expect(rail.uncertain).toHaveLength(1); + }); + + it('separates calls that leave the index from type references that do', () => { + const view = payload({ + outsideIndex: { total: 24, byKind: { calls: 21, references: 2, function_ref: 1 }, samples: [] }, + }); + const rail = buildCalleeRail(view); + expect(rail.outsideCalls).toBe(22); + expect(rail.outsideTypeRefs).toBe(2); + }); + + it('leaves a row with no recorded line unanchored rather than guessing a height', () => { + const view = payload({ + outgoing: { + total: 1, + shown: 1, + truncated: false, + items: [{ ...relation({ lines: [] }), lines: [], edges: [] } as WireRelation], + }, + }); + expect(buildCalleeRail(view).rows[0]?.anchor).toBeNull(); + }); +}); + +/* -------------------------------------------------------------- outline -- */ + +describe('members outline', () => { + it('dims data members and indents the ones nested a level deeper', () => { + const view = payload({ + members: { + total: 2, + shown: 2, + truncated: false, + items: [ + { ...nodeRef({ kind: 'property', name: 'store' }), parentId: 'x', depth: 1, fanIn: 1, fanOut: 0 }, + { ...nodeRef({ kind: 'method', name: 'read' }), parentId: 'y', depth: 2, fanIn: 3, fanOut: 5 }, + ] as any, + }, + }); + const rows = buildOutline(view); + expect(rows[0]).toMatchObject({ dimmed: true, nested: false }); + expect(rows[1]).toMatchObject({ dimmed: false, nested: true }); + }); +}); + +describe('showsBody', () => { + it("swaps a large container's body for its outline, and keeps a large function's", () => { + expect(showsBody('class', 700)).toBe(false); + expect(showsBody('file', 2000)).toBe(false); + expect(showsBody('class', 40)).toBe(true); + // A 700-line function IS its body — there is no outline to show instead. + expect(showsBody('function', 700)).toBe(true); + expect(showsBody('method', 259)).toBe(true); + }); +}); + +/* ---------------------------------------------------------------- lexer -- */ + +describe('client-side token decoding', () => { + // The classification itself is the server's job (`src/ui-server/highlight/`, + // the engine's own tree-sitter parse); what is worth pinning here is the decoding — the + // columns the call-site overlay matches against, and the plain fallback that + // has to keep links working when no grammar covers a file. + const CLASSES = ['other', 'ident', 'comment', 'string', 'keyword', 'number']; + + it('resolves class ids through the payload table', () => { + const tokens = decodeLine( + [ + [4, 'const'], + [0, ' '], + [1, 'x'], + [0, ' = '], + [5, '1'], + [0, '; '], + [2, '// note'], + ], + CLASSES + ); + expect(tokens.map((t) => `${t.cls}:${t.text}`)).toEqual([ + 'keyword:const', + 'other: ', + 'ident:x', + 'other: = ', + 'number:1', + 'other:; ', + 'comment:// note', + ]); + }); + + it('derives each column from the running text, which is how a ref finds its identifier', () => { + const tokens = decodeLine( + [ + [0, ' '], + [4, 'return'], + [0, ' '], + [1, 'render'], + [0, '();'], + ], + CLASSES + ); + expect(tokens.find((t) => t.text === 'render')?.col).toBe(' return '.length); + expect(tokens.at(-1)?.col).toBe(' return render'.length); + }); + + it('treats an unknown class id as unstyled rather than throwing', () => { + expect(decodeLine([[99, 'x']], CLASSES)[0]?.cls).toBe('other'); + }); + + it('splits identifiers even with no grammar, so the links still land', () => { + expect(plainLine(' return this.mutex.withLock();').map((t) => `${t.cls}:${t.text}`)).toEqual([ + 'other: ', + 'ident:return', + 'other: ', + 'ident:this', + 'other:.', + 'ident:mutex', + 'other:.', + 'ident:withLock', + 'other:();', + ]); + }); + + it('splits non-ASCII identifiers, because a symbol name can be one', () => { + expect(plainLine('取得データ()').map((t) => t.cls)).toEqual(['ident', 'other']); + }); + + it('keys a slice by real file line, not by offset into the slice', () => { + const byLine = tokensByLine(['a();', 'b();'], 120, { + engine: 'tree-sitter', + grammar: 'typescript', + classes: CLASSES, + lines: [ + [ + [1, 'a'], + [0, '();'], + ], + [ + [1, 'b'], + [0, '();'], + ], + ], + }); + expect([...byLine.keys()]).toEqual([120, 121]); + expect(byLine.get(121)?.[0]?.text).toBe('b'); + }); + + it('falls back per line when the payload carries no highlight block at all', () => { + const byLine = tokensByLine(['render();'], 5, undefined); + expect(byLine.get(5)?.map((t) => t.cls)).toEqual(['ident', 'other']); + }); +}); diff --git a/__tests__/ui-trails-model.test.ts b/__tests__/ui-trails-model.test.ts new file mode 100644 index 000000000..7517a52d0 --- /dev/null +++ b/__tests__/ui-trails-model.test.ts @@ -0,0 +1,179 @@ +/** + * What a saved trail's row says, without a browser (CG-60). + * + * The endpoint's own behaviour is pinned in `ui-trails.test.ts` against a real + * index; this is the wording layer, and the rule it exists to protect is that + * **a trail that has decayed never reads as intact**. A saved trail is somebody's + * explanation of a codebase that has since moved underneath it, and a row that + * prints "6 hops" while two of them are gone is a lie by omission at exactly the + * moment the trail needs fixing. + */ + +import { describe, it, expect } from 'vitest'; +import { + hopStatusWord, + isOpenable, + replacedTrail, + trailDecay, + trailExport, + trailMeta, + trailNameProblem, + trailOpens, + trailTitle, +} from '../ui/src/lib/trails-model'; +import type { WireTrail, WireTrailHop, WireTrailHopStatus } from '../ui/src/lib/wire'; + +function hop( + name: string, + status: WireTrailHopStatus = 'ok', + dir: WireTrailHop['dir'] = 'down' +): WireTrailHop { + const alive = status !== 'missing'; + return { + dir, + name, + qualifiedName: name, + kind: 'function', + savedFile: 'src/a.ts', + savedLine: 10, + status, + id: alive ? `function:${name}` : null, + file: alive ? 'src/a.ts' : null, + line: alive ? 10 : null, + note: status === 'ok' ? null : `${name} ${status}`, + }; +} + +function trail(hops: WireTrailHop[], over: Partial = {}): WireTrail { + const resolved = hops.filter((h) => h.id !== null); + return { + id: 'a-walk', + name: 'A walk', + note: '', + author: 'Ada', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-02T00:00:00.000Z', + hops, + resolved: resolved.length, + intact: hops.every((h) => h.status === 'ok'), + encoded: resolved.length > 0 ? resolved.map((h) => `d${h.id}`).join(',') : null, + openFrom: 1, + openCount: resolved.length, + openId: resolved.length > 0 ? (resolved[resolved.length - 1] as WireTrailHop).id : null, + ...over, + }; +} + +describe('trailMeta', () => { + it('reports the SAVED length, whatever became of the hops', () => { + const decayed = trail([hop('a', 'ok', 'start'), hop('b', 'missing'), hop('c')]); + expect(trailMeta(decayed)).toBe('3 hops · Ada'); + }); + + it('drops the author when there is not one', () => { + expect(trailMeta(trail([hop('a', 'ok', 'start')], { author: '' }))).toBe('1 hop'); + }); +}); + +describe('trailDecay', () => { + it('is null for a trail nothing has happened to', () => { + expect(trailDecay(trail([hop('a', 'ok', 'start'), hop('b')]))).toBeNull(); + }); + + it('warns about hops that are gone, naming them', () => { + const decay = trailDecay(trail([hop('a', 'ok', 'start'), hop('gone', 'missing')])); + expect(decay?.tone).toBe('warn'); + expect(decay?.text).toContain('1 hop moved or renamed'); + expect(decay?.text).toContain('gone'); + }); + + it('caps how many it names', () => { + const hops = ['a', 'b', 'c', 'd', 'e'].map((n) => hop(n, 'missing')); + const decay = trailDecay(trail(hops)); + expect(decay?.text).toContain('and 2 more'); + }); + + it('notes a move without warning about it — a moved hop still opens', () => { + const decay = trailDecay(trail([hop('a', 'ok', 'start'), hop('b', 'moved')])); + expect(decay?.tone).toBe('note'); + expect(decay?.text).toContain('moved to another file'); + }); + + it('puts a missing hop ahead of a merely moved one', () => { + const decay = trailDecay(trail([hop('m', 'moved'), hop('g', 'missing')])); + expect(decay?.text).toContain('moved or renamed'); + }); + + it('warns about an ambiguous hop — the trail may no longer mean what it said', () => { + const decay = trailDecay(trail([hop('a', 'ok', 'start'), hop('b', 'ambiguous')])); + expect(decay?.tone).toBe('warn'); + expect(decay?.text).toContain('more than one symbol'); + }); +}); + +describe('trailOpens', () => { + it('says nothing when the whole trail opens', () => { + expect(trailOpens(trail([hop('a', 'ok', 'start'), hop('b')]))).toBeNull(); + }); + + it('names the range when only part of it does', () => { + const partial = trail([hop('a'), hop('b'), hop('c')], { + openFrom: 2, + openCount: 2, + }); + expect(trailOpens(partial)).toBe('Opens hops 2–3 of 3.'); + }); + + it('says so plainly when nothing resolves', () => { + const dead = trail([hop('a', 'missing')], { encoded: null, openCount: 0, openId: null }); + expect(trailOpens(dead)).toContain('None of this trail resolves'); + expect(isOpenable(dead)).toBe(false); + }); +}); + +describe('trailTitle', () => { + it('draws the whole walk with its arrows, and when it was saved', () => { + const walked = trail([hop('a', 'ok', 'start'), hop('b', 'ok', 'down'), hop('c', 'ok', 'up')]); + expect(trailTitle(walked)).toBe('a → b ← c — saved 2026-08-02'); + }); +}); + +describe('saving', () => { + it('refuses an empty or over-long name before the round-trip', () => { + expect(trailNameProblem(' ', 120)).toContain('name'); + expect(trailNameProblem('x'.repeat(121), 120)).toContain('too long'); + expect(trailNameProblem('ok', 120)).toBeNull(); + }); + + it('spots the trail a name would replace, whitespace and all', () => { + const list = [trail([hop('a', 'ok', 'start')], { name: 'A walk' })]; + expect(replacedTrail(' A walk ', list)?.name).toBe('A walk'); + expect(replacedTrail('Another walk', list)).toBeNull(); + }); +}); + +describe('trailExport', () => { + it('exports the SAVED identity of each hop, not today’s resolution', () => { + const moved = trail([hop('a', 'ok', 'start'), hop('b', 'moved')]); + const raw = JSON.parse(trailExport(moved)); + expect(raw.version).toBe(1); + // `savedFile`, so dropping the file into another checkout re-runs the same + // resolution rather than baking this index's answer in. + expect(raw.hops[1].file).toBe('src/a.ts'); + expect(raw.hops[1].qualifiedName).toBe('b'); + expect(raw.hops.map((h: { dir: string }) => h.dir)).toEqual(['start', 'down']); + }); + + it('survives a hop with no id at all', () => { + const raw = JSON.parse(trailExport(trail([hop('gone', 'missing')]))); + expect(raw.hops[0].id).toBe(''); + }); +}); + +describe('hopStatusWord', () => { + it('has a word for every status', () => { + for (const status of ['ok', 'moved', 'ambiguous', 'missing'] as const) { + expect(hopStatusWord(status)).toBeTruthy(); + } + }); +}); diff --git a/__tests__/ui-trails.test.ts b/__tests__/ui-trails.test.ts new file mode 100644 index 000000000..a669cb1c7 --- /dev/null +++ b/__tests__/ui-trails.test.ts @@ -0,0 +1,562 @@ +/** + * Saved trails (CG-60) — the viewer's only write. + * + * Two things are worth a real end-to-end fixture rather than a unit test, and + * they are the two the feature exists for: + * + * 1. **A trail survives a re-index.** The suite indexes a project, saves a + * trail, then EDITS the files so every node id changes (a symbol shifts down + * a file, another moves to a different file, a third is deleted), re-indexes, + * and asserts the trail still opens and says what became of each hop. That + * cannot be faked: node ids contain a start line, so the ids really do all + * change. + * 2. **The write boundary.** `POST` without the marker header, from a foreign + * `Origin`, or against a `--read-only` server has to be refused — by a real + * loopback server, because the refusals live in the request handler and not + * in the endpoint. + */ + +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 { + encodeResolvedRun, + isTrailId, + parseTrail, + slugify, + TRAILS_RELATIVE_DIR, + type WireTrailHop, +} from '../src/ui-server/api'; + +interface Res { + status: number; + headers: http.IncomingHttpHeaders; + body: any; +} + +let tempDir: string; +let projectRoot: string; +let viewerDir: string; +let api: GraphApi; +let server: UiServerHandle; +let readOnlyApi: GraphApi; +let readOnlyServer: UiServerHandle; + +interface CallOptions { + method?: string; + body?: unknown; + /** Send the write marker header. On by default for a write. */ + marker?: boolean; + contentType?: string | null; + origin?: string; +} + +/** + * One request against a live server. + * + * `http.request` rather than `fetch` so `Host` is ours to set — undici treats + * it as a forbidden header, and the `Host` allowlist is half of what is being + * tested here. + */ +function callOn(port: number, requestPath: string, opts: CallOptions = {}): Promise { + const method = opts.method ?? 'GET'; + const isWrite = method === 'POST' || method === 'DELETE'; + const payload = opts.body === undefined ? null : Buffer.from(JSON.stringify(opts.body), 'utf-8'); + const headers: Record = { Host: `127.0.0.1:${port}` }; + if (isWrite && (opts.marker ?? true)) headers['X-CodeGraph-UI'] = '1'; + if (opts.origin) headers['Origin'] = opts.origin; + if (payload) { + const type = opts.contentType === undefined ? 'application/json' : opts.contentType; + if (type !== null) headers['Content-Type'] = type; + headers['Content-Length'] = String(payload.length); + } + + return new Promise((resolve, reject) => { + const req = http.request( + { host: '127.0.0.1', port, path: requestPath, method, headers, setHost: false }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf-8'); + let parsed: unknown = text; + try { + parsed = JSON.parse(text); + } catch { + /* a text/plain refusal is a legitimate answer on the static side */ + } + resolve({ status: res.statusCode ?? 0, headers: res.headers, body: parsed }); + }); + } + ); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); +} + +function call(requestPath: string, opts: CallOptions = {}): Promise { + return callOn(server.port, requestPath, opts); +} + +/** The id of a fixture symbol, looked up through the API itself. */ +async function idOf(name: string): Promise { + const res = await call(`/api/search?q=${encodeURIComponent(name)}`); + const hit = res.body.results.items.find((r: any) => r.name === name); + expect(hit, `no symbol named ${name}`).toBeTruthy(); + return hit.id as string; +} + +function trailsDir(): string { + return path.join(projectRoot, TRAILS_RELATIVE_DIR); +} + +/** Re-index in place, the way a `codegraph sync` would after an edit. */ +async function reindex(): Promise { + const cg = CodeGraph.openSync(projectRoot); + await cg.sync(); + cg.resolveReferences(); + cg.close(); +} + +const SRC = () => path.join(projectRoot, 'src'); + +beforeAll(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-trails-')); + projectRoot = path.join(tempDir, 'project'); + fs.mkdirSync(SRC(), { recursive: true }); + + fs.writeFileSync( + path.join(SRC(), 'handler.ts'), + `import { load } from './service'; + +export function handleRequest(key: string): string { + return load(key); +} +` + ); + fs.writeFileSync( + path.join(SRC(), 'service.ts'), + `import { read } from './cache'; + +export function load(key: string): string { + return read(key); +} + +export function retired(): string { + return 'nothing calls me after the edit'; +} +` + ); + fs.writeFileSync( + path.join(SRC(), 'cache.ts'), + `export function read(key: string): string { + return key; +} +` + ); + + const cg = CodeGraph.initSync(projectRoot, { + config: { include: ['src/**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + cg.resolveReferences(); + cg.close(); + + viewerDir = path.join(tempDir, 'viewer'); + fs.mkdirSync(viewerDir, { recursive: true }); + fs.writeFileSync(path.join(viewerDir, 'index.html'), '
'); + + api = createGraphApi({ projectRoot }); + server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler }); + + readOnlyApi = createGraphApi({ + projectRoot, + readOnly: true, + readOnlyReason: 'This viewer was started with --read-only, so trails cannot be saved.', + }); + readOnlyServer = await startUiServer({ + projectRoot, + viewerDir, + port: 0, + api: readOnlyApi.handler, + }); +}, 120_000); + +afterAll(async () => { + api?.close(); + readOnlyApi?.close(); + await server?.close(); + await readOnlyServer?.close(); + if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +/* ------------------------------------------------------------- pure bits -- */ + +describe('trail ids', () => { + it('slugs a name into something that is a filename and not a path', () => { + expect(slugify('How a request reaches the handler')).toBe( + 'how-a-request-reaches-the-handler' + ); + expect(slugify(' Spaces and --- dashes ')).toBe('spaces-and-dashes'); + expect(slugify('../../etc/passwd')).toBe('etc-passwd'); + // A name with no ASCII word characters still has to produce a valid id. + expect(slugify('日本語')).toBe('trail'); + expect(isTrailId(slugify('../../etc/passwd'))).toBe(true); + }); + + it('refuses anything that is not a slug', () => { + for (const bad of ['..', 'a/b', 'A', 'has.dot', '-leading', '', 'a b']) { + expect(isTrailId(bad), bad).toBe(false); + } + }); +}); + +describe('parseTrail', () => { + it('rejects a file that is not a trail rather than half-reading it', () => { + expect(parseTrail('x', 'not json')).toBeNull(); + expect(parseTrail('x', '[]')).toBeNull(); + expect(parseTrail('x', '{"name":"a"}')).toBeNull(); + expect(parseTrail('x', '{"name":"a","hops":[]}')).toBeNull(); + expect(parseTrail('x', '{"name":"","hops":[{"qualifiedName":"a"}]}')).toBeNull(); + }); + + it('takes its id from the FILE, not from the field inside it', () => { + const trail = parseTrail('on-disk', '{"name":"a","id":"remembered","hops":[{"name":"f"}]}'); + expect(trail?.id).toBe('on-disk'); + }); +}); + +describe('encodeResolvedRun', () => { + const hop = (id: string | null, dir: 'start' | 'down' | 'up' = 'down'): WireTrailHop => ({ + dir, + name: id ?? 'gone', + qualifiedName: id ?? 'gone', + kind: 'function', + savedFile: 'src/a.ts', + savedLine: 1, + status: id ? 'ok' : 'missing', + id, + file: id ? 'src/a.ts' : null, + line: id ? 1 : null, + note: null, + }); + + it('never stitches across a hole — it takes the longest consecutive run', () => { + const run = encodeResolvedRun([hop('a', 'start'), hop(null), hop('c'), hop('d')]); + expect(run.encoded).toBe('sc,dd'); + expect(run.openFrom).toBe(3); + expect(run.openCount).toBe(2); + expect(run.openId).toBe('d'); + }); + + it('writes the run’s first hop as a start, whatever it was saved as', () => { + const run = encodeResolvedRun([hop(null), hop('b', 'up')]); + expect(run.encoded).toBe('sb'); + }); + + it('answers nothing when nothing resolves', () => { + expect(encodeResolvedRun([hop(null), hop(null)])).toEqual({ + encoded: null, + openFrom: 0, + openCount: 0, + openId: null, + }); + }); +}); + +/* ----------------------------------------------------------- the endpoint -- */ + +describe('GET /api/trails', () => { + it('is an empty list, not an error, before anything is saved', async () => { + const res = await call('/api/trails'); + expect(res.status).toBe(200); + expect(res.body.trails).toEqual([]); + expect(res.body.readOnly).toBe(false); + expect(res.body.directory).toBe(TRAILS_RELATIVE_DIR); + }); + + it('is listed by GET /api', async () => { + const res = await call('/api'); + expect(res.body.endpoints.some((e: any) => e.path === '/api/trails')).toBe(true); + // The old blanket claim is gone: the server writes exactly one thing. + expect(res.body.readOnly).toBe(false); + expect(res.body.writes).toContain('POST /api/trails'); + }); +}); + +describe('POST /api/trails', () => { + it('saves the walk and answers with the whole list', async () => { + const hops = [ + { dir: 'start', id: await idOf('handleRequest') }, + { dir: 'down', id: await idOf('load') }, + { dir: 'down', id: await idOf('read') }, + ]; + const res = await call('/api/trails', { + method: 'POST', + body: { name: 'How a request is served', note: 'the whole path', hops }, + }); + + expect(res.status).toBe(200); + expect(res.body.saved).toBe('how-a-request-is-served'); + expect(res.body.replaced).toBe(false); + expect(res.body.trails).toHaveLength(1); + + const trail = res.body.trails[0]; + expect(trail.name).toBe('How a request is served'); + expect(trail.note).toBe('the whole path'); + expect(trail.intact).toBe(true); + expect(trail.resolved).toBe(3); + expect(trail.openCount).toBe(3); + expect(trail.hops.map((h: any) => h.name)).toEqual(['handleRequest', 'load', 'read']); + // The identity that survives an edit, recorded beside the id hint. + expect(trail.hops[1].qualifiedName).toBe('load'); + expect(trail.hops[1].savedFile).toBe('src/service.ts'); + }); + + it('writes one readable JSON file into .codegraph/ui/trails', () => { + const file = path.join(trailsDir(), 'how-a-request-is-served.json'); + expect(fs.existsSync(file)).toBe(true); + const raw = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(raw.version).toBe(1); + expect(raw.hops).toHaveLength(3); + expect(raw.hops[0].qualifiedName).toBe('handleRequest'); + expect(typeof raw.createdAt).toBe('string'); + // Nothing but trails lands there — no temp file survives the rename. + expect(fs.readdirSync(trailsDir())).toEqual(['how-a-request-is-served.json']); + }); + + it('replaces a trail saved under the same name, keeping its createdAt', async () => { + const before = (await call('/api/trails')).body.trails[0]; + const res = await call('/api/trails', { + method: 'POST', + body: { + name: 'How a request is served', + hops: [{ dir: 'start', id: await idOf('handleRequest') }], + }, + }); + expect(res.body.replaced).toBe(true); + expect(res.body.trails).toHaveLength(1); + expect(res.body.trails[0].createdAt).toBe(before.createdAt); + expect(res.body.trails[0].hops).toHaveLength(1); + expect(res.body.trails[0].note).toBe(''); + }); + + it('gives a different name its own file rather than colliding', async () => { + const res = await call('/api/trails', { + method: 'POST', + body: { name: 'How a request is served!', hops: [{ dir: 'start', id: await idOf('load') }] }, + }); + expect(res.body.saved).toBe('how-a-request-is-served-2'); + expect(res.body.trails).toHaveLength(2); + }); + + it('refuses a hop the index does not hold', async () => { + const res = await call('/api/trails', { + method: 'POST', + body: { name: 'invented', hops: [{ dir: 'start', id: 'function:not-a-real-id' }] }, + }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('Hop 1 is not in the index'); + }); + + it('refuses a nameless or hopless trail', async () => { + const noName = await call('/api/trails', { method: 'POST', body: { name: ' ', hops: [] } }); + expect(noName.status).toBe(400); + const noHops = await call('/api/trails', { method: 'POST', body: { name: 'x', hops: [] } }); + expect(noHops.status).toBe(400); + expect(noHops.body.error).toContain('at least one hop'); + }); +}); + +describe('DELETE /api/trails/', () => { + it('removes the file and answers with the list that is left', async () => { + const res = await call('/api/trails/how-a-request-is-served-2', { method: 'DELETE' }); + expect(res.status).toBe(200); + expect(res.body.deleted).toBe('how-a-request-is-served-2'); + expect(res.body.trails).toHaveLength(1); + expect(fs.existsSync(path.join(trailsDir(), 'how-a-request-is-served-2.json'))).toBe(false); + }); + + it('is a 404 for a trail that is not there', async () => { + const res = await call('/api/trails/never-existed', { method: 'DELETE' }); + expect(res.status).toBe(404); + }); + + it('refuses an id shaped like a path before it is joined to anything', async () => { + const res = await call('/api/trails/..%2f..%2fetc%2fpasswd', { method: 'DELETE' }); + // The `..` segments are caught on the RAW url, before WHATWG parsing folds + // them away — a traversal attempt is a 404, never the app shell. + expect([400, 404]).toContain(res.status); + expect(res.headers['content-type']).toContain('application/json'); + }); +}); + +/* ------------------------------------------------------- the write boundary */ + +describe('the write boundary', () => { + it('refuses a POST without the marker header', async () => { + const res = await call('/api/trails', { + method: 'POST', + marker: false, + body: { name: 'forged', hops: [] }, + }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('refused'); + expect(String(res.body.error)).toContain('x-codegraph-ui'); + }); + + it('refuses a POST whose body claims to be a form', async () => { + const res = await call('/api/trails', { + method: 'POST', + contentType: 'application/x-www-form-urlencoded', + body: { name: 'forged', hops: [] }, + }); + expect(res.status).toBe(403); + expect(String(res.body.error)).toContain('application/json'); + }); + + it('refuses a POST from a foreign origin even with the marker', async () => { + const res = await call('/api/trails', { + method: 'POST', + origin: 'https://evil.example', + body: { name: 'forged', hops: [] }, + }); + expect(res.status).toBe(403); + }); + + it('refuses a write anywhere but /api/, and still serves the asset on GET', async () => { + const post = await call('/index.html', { method: 'POST', body: { a: 1 } }); + expect(post.status).toBe(405); + expect(post.headers.allow).toBe('GET, HEAD'); + const get = await call('/index.html'); + expect(get.status).toBe(200); + }); + + it('still refuses a method it has never answered', async () => { + const res = await call('/api/trails', { method: 'PUT' }); + expect(res.status).toBe(405); + }); + + it('refuses every write under --read-only, but still lists what is there', async () => { + const list = await callOn(readOnlyServer.port, '/api/trails'); + expect(list.status).toBe(200); + expect(list.body.readOnly).toBe(true); + expect(list.body.readOnlyReason).toContain('--read-only'); + expect(list.body.trails.length).toBeGreaterThan(0); + + const save = await callOn(readOnlyServer.port, '/api/trails', { + method: 'POST', + body: { name: 'nope', hops: [{ dir: 'start', id: 'x' }] }, + }); + expect(save.status).toBe(403); + expect(save.body.code).toBe('refused'); + + const remove = await callOn(readOnlyServer.port, '/api/trails/how-a-request-is-served', { + method: 'DELETE', + }); + expect(remove.status).toBe(403); + }); +}); + +/* ------------------------------------------------- surviving a re-index --- */ + +describe('a saved trail survives a re-index', () => { + it('re-resolves hops by qualified name once every node id has changed', async () => { + // Save the three-hop walk again, plus a fourth hop that is about to be + // deleted outright, so one trail exercises every outcome at once. + const saved = await call('/api/trails', { + method: 'POST', + body: { + name: 'The whole walk', + hops: [ + { dir: 'start', id: await idOf('handleRequest') }, + { dir: 'down', id: await idOf('load') }, + { dir: 'down', id: await idOf('read') }, + { dir: 'down', id: await idOf('retired') }, + ], + }, + }); + const before = saved.body.trails.find((t: any) => t.id === 'the-whole-walk'); + expect(before.intact).toBe(true); + const idsBefore = before.hops.map((h: any) => h.id); + + // Now move the world underneath it: + // - `handleRequest` shifts down its file (a node id contains its start + // line, so its id changes while it is the same symbol); + // - `read` moves to a different file entirely; + // - `retired` is deleted. + fs.writeFileSync( + path.join(SRC(), 'handler.ts'), + `import { load } from './service'; + +// A comment inserted above the symbol. This alone renames it. +// Another line. +// And another. + +export function handleRequest(key: string): string { + return load(key); +} +` + ); + fs.writeFileSync( + path.join(SRC(), 'service.ts'), + `import { read } from './store'; + +export function load(key: string): string { + return read(key); +} +` + ); + fs.writeFileSync(path.join(SRC(), 'cache.ts'), `export const unused = 1;\n`); + fs.writeFileSync( + path.join(SRC(), 'store.ts'), + `export function read(key: string): string { + return key; +} +` + ); + await reindex(); + + const after = (await call('/api/trails')).body.trails.find( + (t: any) => t.id === 'the-whole-walk' + ); + + // Every id really did change — otherwise this test proves nothing. + const idsAfter = after.hops.map((h: any) => h.id); + expect(idsAfter[0]).not.toBe(idsBefore[0]); + expect(idsAfter[0]).toBeTruthy(); + + const [handle, load, read, retired] = after.hops; + expect(handle.status).toBe('ok'); + expect(handle.file).toBe('src/handler.ts'); + expect(handle.line).toBeGreaterThan(handle.savedLine); + + expect(load.status).toBe('ok'); + + // Moved to another file: still resolved, and the row says where from. + expect(read.status).toBe('moved'); + expect(read.savedFile).toBe('src/cache.ts'); + expect(read.file).toBe('src/store.ts'); + expect(read.note).toContain('src/cache.ts'); + expect(read.note).toContain('src/store.ts'); + + // Deleted: named honestly, with no invented target. + expect(retired.status).toBe('missing'); + expect(retired.id).toBeNull(); + expect(retired.note).toContain('moved or renamed'); + + // And it still opens — the first three hops, not the fourth. + expect(after.intact).toBe(false); + expect(after.resolved).toBe(3); + expect(after.openFrom).toBe(1); + expect(after.openCount).toBe(3); + expect(after.openId).toBe(idsAfter[2]); + expect(after.encoded?.split(',')).toHaveLength(3); + expect(after.encoded?.startsWith('s')).toBe(true); + }, 120_000); +}); diff --git a/assets/codegraph-ui-symbol-view.png b/assets/codegraph-ui-symbol-view.png new file mode 100644 index 000000000..ce70fa98e Binary files /dev/null and b/assets/codegraph-ui-symbol-view.png differ diff --git a/docs/design/cg57-highlighting-parity.md b/docs/design/cg57-highlighting-parity.md new file mode 100644 index 000000000..a8f41740c --- /dev/null +++ b/docs/design/cg57-highlighting-parity.md @@ -0,0 +1,95 @@ +# Highlighting parity: Shiki → the engine's own tree-sitter parse (CG-57) + +The viewer's code block used to be classified by a second highlighter — Shiki with 56 pruned +TextMate grammars shipped in `dist/textmate/` — over source the engine had already parsed with a +real grammar. CG-57 takes the classification off that tree instead. This file records what the swap +changed, measured rather than asserted, so nobody has to re-derive it from a diff. + +Screenshots, one per language, before on the left and after on the right, same stylesheet: +[`cg57-highlighting-parity/`](./cg57-highlighting-parity/) — `typescript.png`, `go.png`, +`python.png`, `rust.png`, `swift.png`, `csharp.png`, `ruby.png`, `php.png`. + +## What it costs + +3 000 lines, cold, dev Mac (M-series), parse + classify + wire: + +| | TypeScript | Go | Python | Rust | Swift | C# | Ruby | PHP | +|---|---|---|---|---|---|---|---|---| +| Shiki + TextMate | ~700 ms | 43–57 ms | 35–47 ms | — | — | — | — | — | +| Engine tree-sitter | 24–41 ms | ~30 ms | 25–29 ms | 18–19 ms | 25–27 ms | 20–25 ms | 14–16 ms | 20–22 ms | + +The task's budget was **< 100 ms per 3 000-line file warm**; every language clears it *cold*. +TypeScript is the number that mattered: its TextMate grammar was 5–7× every other one and the cost +was regex *execution*, not compilation, so nothing about the old module could have fixed it. The +slice cache still exists — a re-render (resize, theme flip, stepping back through the trail) should +cost nothing at all, and the whole-file view pages the same file repeatedly. + +## What it changes on screen + +Per-character comparison over ~40 lines of realistic source per language, counting only +non-whitespace characters, and treating `ident` / `other` / `type` as one bucket because all three +paint at plain ink: + +| language | painted identically | what moved | +|---|---|---| +| TypeScript | 91.3% | 33 `def`, 40 interpolation chars now code, 2 punctuation | +| Go | 91.5% | 37 built-in type words, 13 `def` | +| Python | 93.2% | 26 `def`, 15 keyword (`is not`, `__future__`) | +| Rust | 96.3% | 21 `def`, 3 keyword | +| Swift | 93.3% | 18 `def`, 14 keyword (`throws`/`rethrows`) | +| C# | 88.3% | 30 built-in type words, 23 `def`, 31 interpolation chars now code | +| Ruby | 83.8% | 23 `def`, 35 interpolation chars now code, 14 symbol literals, 3 keyword | +| PHP | 85.9% | 33 built-in type words, 29 `def`, 15 phpdoc tag chars, 12 keyword | + +Every remaining difference is one of five deliberate categories: + +1. **`ident` → `def`.** The definition's own name now carries weight 600, everywhere rather than + only on the line the Symbol view opened at. It comes from the extractors' own definition tables + (`functionTypes`, `classTypes`, `methodTypes`, …) plus each language's `nameField`, so it cannot + drift from what indexing considers a definition. +2. **`string` → code, inside an interpolation.** A template literal's `${…}`, an f-string's `{…}`, + Ruby's `#{…}` and C#'s `$"{…}"` are classified as code. This is the one difference that is not + cosmetic: the call-site overlay deliberately refuses to claim a token classed `string`, so + **calls inside interpolated strings now link and did not before.** +3. **`keyword` → `type`, on built-in type words.** `string`, `int`, `u32`, `void`. The grammars + disagree with each other about what a built-in type is — tree-sitter-go calls `string` a + `type_identifier`, tree-sitter-typescript wraps it in a `predefined_type` whose child is an + anonymous token spelled `string` — and TextMate scoped them inconsistently too (plain in + TypeScript, `storage.type` in Go). They now all paint at plain ink, like a user-defined type + name, in every language. +4. **Keyword-set corrections.** Python's `is not`, Rust's and Swift's modifiers, and Ruby's `new` + (which is a method, not a keyword — TextMate's `keyword.operator.new` matched it anyway). +5. **`keyword` → `comment`, on phpdoc tags.** `@var` and friends recede with the comment they are + in, which is what the near-monochrome ramp asks for. + +## What is no longer highlighted + +Nine formats have extraction but no tree-sitter grammar. Three of them — `.svelte`, `.vue`, +`.astro` — are classified through their `'.length; + regions.push({ + start, + end: start + body.length, + language: TS_LANG_ATTR.test(match[1] ?? '') ? 'typescript' : 'javascript', + }); + } + return regions; +} + +/* --------------------------------------------------------------- the API -- */ + +export interface TokenizeResult { + spans: SyntaxSpan[]; + /** The grammar(s) that produced them, for the payload's `grammar` field. */ + grammars: string[]; +} + +/** + * Parse `source` and classify it. + * + * Returns null when nothing in the file has a grammar — a plain answer, which + * every caller here already knows how to serve. Never throws: a grammar that + * fails to load or a parse that comes back empty is the same outcome as not + * having one. + */ +export async function tokenizeSource( + source: string, + language: Language +): Promise { + const regions = syntaxRegionsFor(source, language); + if (regions === null) { + const spans = await tokenizeRegion(source, language, 0); + return spans ? { spans, grammars: [language] } : null; + } + if (regions.length === 0) return null; + + const spans: SyntaxSpan[] = []; + const grammars = new Set(); + for (const region of regions) { + const part = await tokenizeRegion( + source.slice(region.start, region.end), + region.language, + region.start + ); + if (!part) continue; + grammars.add(region.language); + spans.push(...part); + } + if (spans.length === 0) return null; + spans.sort((a, b) => a.start - b.start); + return { spans, grammars: [...grammars] }; +} + +async function tokenizeRegion( + source: string, + language: Language, + offset: number +): Promise { + try { + await loadGrammarsForLanguages([language]); + const parser = getParser(language); + if (!parser) return null; + const tree = parser.parse(source); + if (!tree?.rootNode) return null; + try { + return classifyTree(tree.rootNode, source, language, offset); + } finally { + tree.delete(); + } + } catch { + // A grammar that will not load, or a parse that threw: the caller serves + // the source unclassified, which is the whole point of the plain path. + return null; + } +} diff --git a/src/graph/dead-code.ts b/src/graph/dead-code.ts new file mode 100644 index 000000000..1d724c5d7 --- /dev/null +++ b/src/graph/dead-code.ts @@ -0,0 +1,886 @@ +/** + * Dead code and islands — one derivation of "nothing in this repository + * reaches here". + * + * The graph can answer that question exactly, and that is the problem: the + * exact answer is *no incoming edge*, and a symbol with no incoming edge is not + * the same thing as a symbol nobody uses. Reflection calls it. A framework + * registers it by name. A test file that was never indexed imports it. The + * resolver saw the name and could not follow it. So the honest product of this + * module is two things at once — a list, and everything the list could not see. + * + * ## The shape of the claim + * + * `unreferenced` is a fact: no edge in the index, other than the `contains` + * edge from whatever holds it, points at this symbol. `dead` is an inference on + * top of that fact, and every step of the inference is subtractive — a + * candidate is dropped from the list the moment there is any reason to believe + * something outside the graph reaches it: + * + * - it is **exported** (something outside this repository may import it); + * - it lives in a **test** or a **generated** file (not code anyone deletes by + * hand); + * - it is **abstract** or declared on an interface (a declaration is dispatched + * to, never called); + * - it is **decorated** (`@app.route`, `@Component`, `@EventHandler`) — a + * decorator is a registration, and the framework that reads it is not in the + * graph. Seen as the symbol's own outgoing `decorates` edge, which is where + * the engine records it; `node.decorators` is only populated by a couple of + * languages and is checked as well rather than instead; + * - it **overrides** a member an ancestor declares (calls land on the ancestor; + * see {@link overrideCandidates} for why an ancestor we cannot read counts + * the same way); + * - it has a name the language calls by itself (`constructor`, `__enter__`, + * `main`); + * - it sits in a **vendored** directory (`vendor/`, `third_party/`, + * `node_modules/` — code the repository carries but does not own); + * - it is in a **test scope** the path does not reveal — a Rust + * `#[cfg(test)] mod tests`, a nested `Tests` namespace; + * - it is in a **component file** whose markup the index reads for calls but + * not for references, so a handler passed as `{onkeydown}` is invisible; + * - it is in a file **nothing in the index reaches**. Then "nothing references + * this symbol" is a restatement of "we cannot see how this file is wired", + * not a finding about the symbol — and it is the map, not this list, that + * says so: a file no one reaches is an island, and islands are drawn there; + * - the index holds an **unresolved reference** to its name. A `failed` row in + * `unresolved_refs` is the resolver's own record of a reference it could not + * follow, and a symbol whose name we failed to follow cannot be called + * unreferenced. + * - **another symbol of the same name IS referenced.** This is the one that + * matters most and the one nothing else would catch. `CodeGraph.getTopRouteFile` + * calls `this.queries.getTopRouteFile()`; the resolver prefers a same-name + * definition in the call site's own file, so the edge lands on the caller + * itself and the real target is left with nothing. From the edge table, + * "nobody calls this" and "the resolver picked the twin" are the same + * picture — so the claim is not made about either; + * - it is declared in a **header** (`.h`, `.hpp`, `.d.ts`, `.pyi`): a header IS + * the export surface, and the reference to it is an `#include` the resolver + * does not follow to the declaration; + * - it is in a language this index records **no export marker** for. The + * exported filter is the strongest one here, and for Rust (`pub` is not + * recorded) or Python and C (no such concept at all) it silently does + * nothing — so the index is asked, per language, whether it ran; + * - **its own file writes the name more than once.** The last rule, and the + * only one that is not a graph query. Everything above assumes the edge + * table is complete; it is not, and the gaps do not announce themselves — + * `this.handleMessage.bind(this)` is a value reference the extractor does not + * record, and a call inside an object-literal initialiser is another. Both + * leave the name written twice in one file and no edge at all. So before the + * claim is made, the identifier is counted in the file itself AND in every + * file the index says depends on it — written once, in its own declaration, + * nothing that can reach it writes it down; written twice, we simply did not + * see the second one. + * + * Every subtraction is counted. {@link DeadCodeReport.excluded} is not + * diagnostics — it is the sentence under the list ("47 exported, 12 overriding + * an ancestor…"), because a list of eight rows drawn from four thousand + * candidates means something different from a list of eight drawn from nine. + * + * ## Islands + * + * The other half of the task, a *module* nothing depends on, is not computed + * here: it falls straight out of the map's own link set (a module with no + * incoming link), and the map's layout is already a pure function in the + * viewer. Computing it a second time on this side would be a second answer to + * a question the map has already answered. See `ui/src/lib/map-model.ts`. + * + * Everything here is query-time and read-only. + */ + +import fs from 'fs'; +import path from 'path'; +import type CodeGraph from '../index'; +import type { Node, NodeKind } from '../types'; +import { isTestFile } from '../search/query-utils'; + +// ============================================================================= +// Caps and defaults +// ============================================================================= + +/** + * Kinds asked about by default. + * + * Callables and types, and nothing else. `variable`/`constant`/`field` are out + * deliberately: a value's uses are recorded as `references` edges, and that + * coverage is the most language-dependent thing in the resolver — a default + * that included them would produce a list whose truthfulness varied by which + * language the reader happened to be looking at. + */ +export const DEAD_CODE_KINDS: readonly NodeKind[] = [ + 'function', + 'method', + 'class', + 'component', + 'interface', + 'struct', + 'trait', + 'protocol', + 'enum', + 'union', + 'type_alias', +]; + +/** Kinds a `kinds=` request may ask for. Anything else is a caller bug. */ +export const DEAD_CODE_ALLOWED_KINDS: ReadonlySet = new Set([ + ...DEAD_CODE_KINDS, + 'variable', + 'constant', + 'property', + 'field', + 'enum_member', + 'namespace', + 'module', +]); + +/** + * Candidates pulled out of SQL before any exclusion runs. + * + * High enough that no real repository reaches it with the default kinds (this + * index produces ~1 400), and bounded so that a half-indexed monorepo cannot + * turn one screen into a scan of a million rows. When it bites, + * {@link DeadCodeReport.bounded} says so. + */ +export const MAX_DEAD_CODE_CANDIDATES = 20000; + +/** Levels walked up looking for an ancestor that declares the same member. */ +export const MAX_OVERRIDE_ANCESTOR_DEPTH = 8; + +/** + * Files read for the corroboration pass, and the biggest one read. + * + * The pass runs over the survivors only — everything cheap has already fired — + * so on this index it reads a few dozen files. The caps are a backstop against + * a repository whose survivors span a thousand files or include a generated + * megabyte. A file skipped for either reason counts as NOT corroborated, which + * drops the row: the safe direction is always the one that says less. + */ +export const MAX_CORROBORATION_FILES = 600; +export const MAX_CORROBORATION_BYTES = 2_000_000; + +/** Kinds that can carry members, i.e. whose ancestors are worth walking. */ +const CONTAINER_KINDS: ReadonlySet = new Set([ + 'class', + 'interface', + 'struct', + 'trait', + 'protocol', + 'enum', + 'union', + 'type_alias', +]); + +/** Container kinds whose members are declarations, never call targets. */ +const DECLARATION_CONTAINER_KINDS: ReadonlySet = new Set([ + 'interface', + 'trait', + 'protocol', +]); + +/** Member kinds an override can be declared on. */ +const OVERRIDABLE_KINDS: ReadonlySet = new Set([ + 'method', + 'function', + 'property', + 'field', +]); + +/** + * Names a language or a runtime calls without anything in the source naming + * them. + * + * Kept short on purpose. The temptation is a per-language table of every + * lifecycle hook ever written, which would be wrong twice over — it would go + * stale, and it would hide real dead code behind a name coincidence. The + * entries below are the ones where the *language itself* does the calling, so + * no source file could name them even in principle. Framework hooks are caught + * by the decorator and override rules instead, which are structural. + */ +const IMPLICIT_ENTRY_NAMES: ReadonlySet = new Set([ + 'constructor', + 'main', + 'init', + 'deinit', + 'finalize', + 'destructor', + 'dispose', + 'drop', + 'default', + 'tostring', + 'equals', + 'gethashcode', + 'hashcode', +]); + +/** + * Qualified-name segments that mean "inside a test scope the file path does not + * reveal" — a Rust `#[cfg(test)] mod tests`, a nested `Tests` class in C#, a + * Go `TestMain` helper block. `isTestFile` only reads paths, and an in-file test + * module is invisible to it. + */ +const TEST_SCOPE_SEGMENTS: ReadonlySet = new Set([ + 'test', + 'tests', + '__tests__', + 'spec', + 'specs', + 'testing', +]); + +/** + * Languages whose files are markup with a script block inside them. + * + * The extractors for these read the ` + + + + +``` + +Exports: `SymbolView`, `FlowStrip`, `ArchitectureMap`, `FileView`, +`FileSourceView`, `EntryPointsView`, `DeadCodeView`, `TypeHierarchy`, `TrailBar`, +`SavedTrails`, `SearchPalette`, `PalettePanel`, `PaletteRows`, `DriftBanner`, +`KindGlyph`, `ExportButtons`, `CodegraphUi` — plus every pure model function the screens are +built from (`buildCalleeRail`, `buildFlowLayout`, `buildMapLayout`, +`buildHierarchyModel`, `tokensByLine`, …) and the `Wire*` types an adapter +answers in. + +`TypeHierarchy` is the one screen that takes its data as a prop rather than +asking the adapter: it is part of `SymbolView`'s payload (`/api/node`'s +`hierarchy`), so a host that already holds a `WireSymbolPayload` can render the +tree on its own without a second read. + +### The adapter is the only way data arrives + +```ts +interface GraphAdapter { + stats(signal?): Promise; + search(query, opts?, signal?): Promise; + node(id, signal?): Promise; + nodes(ids, signal?): Promise; + source(request, signal?): Promise; + file(path, signal?): Promise; + fileCode(path, signal?): Promise; + flow(request, signal?): Promise; + map(request?, signal?): Promise; + routes(request?, signal?): Promise; + entryPoints(request?, signal?): Promise; + deadCode(request?, signal?): Promise; + trails(signal?): Promise; + + // The only mutating pair, and the only optional methods besides `events`. + saveTrail?(request, signal?): Promise; + deleteTrail?(id, signal?): Promise; + events?(handlers): () => void; // optional: the live channel +} +``` + +The shapes are exactly what `src/ui-server/api/` serialises, and they live in +`src/lib/wire.ts` — no imports, no runtime — so a host can depend on the +vocabulary without depending on the viewer. The default implementation, +`createHttpAdapter()`, is the loopback JSON API; a host that already holds the +index implements the same thirteen required methods against its own reads and +never makes an HTTP request. `scripts/check-ui-package.mjs` asserts that no module in the +built package but `lib/adapter.js` touches the network, because a screen that +reached past the adapter would be a screen that ignored the host. + +`events` is optional. Omit it and nothing connects and nothing polls; a host +that learns about a sync some other way calls `live.signal('index')` instead, +which is the same code path the stream uses. + +`saveTrail` / `deleteTrail` are optional for a different reason: they are the +only methods in the interface that CHANGE anything, and a host must be able to +render the reader without inheriting a write it never asked for. Omit them and +`TrailBar` grows no Save button and `SavedTrails` says the host does not store +them — the same thing it does when `trails()` answers `readOnly: true`, which is +how a host that *can* store them declines a particular project. `trails()` itself +is required: a host with nowhere to keep them answers an empty read-only list, so +the screen is explained rather than silently missing. + +### Three things that will bite + +1. **Import `theme.css` once.** Every component paints from the design tokens. + Override any variable on a narrower selector — including on a container, + since custom properties inherit; `` uses exactly + that to put a light reader inside a dark application. +2. **The adapter and the navigation driver are module-level, not context.** The + pure model modules are plain TypeScript and cannot read a component's + context, so one page reads one project. `` installs them during + initialisation, once — swapping projects means re-mounting the subtree + (`{#key project}`), not swapping the prop. +3. **Geometry is not themable.** 34px rail rows, the 300/320px rails, the 20px + code line: the Symbol view measures these against each other to put a callee + row beside the line that calls it. Colour and type are yours. + +### Navigation + +Every link the components build goes through a `NavigationDriver` +(`src/lib/navigation.ts`). The default is the viewer's own hash space +(`#/s/`); a host installs one that addresses its app instead, and the rails, +breadcrumbs, chips and cards follow. They are hrefs rather than click handlers +because middle-click, cmd-click and "copy link address" are how people read +code. + +The app's half — parsing the hash, holding the live route — is +`src/lib/router.svelte.ts`, which attaches `hashchange`/`popstate` listeners at +module scope and is therefore **pruned out of the published package**. Nothing a +host imports may drag a hash router into its application. + +### Versioning and publishing + +The package is versioned with the engine (`scripts/sync-ui-version.mjs` runs on +every `build:lib`): `@colbymchenry/codegraph-ui@X.Y.Z` is the reader for +`codegraph@X.Y.Z`, because the payload shapes are versioned with the binary that +serves them. + +It is **prepared, not published.** `"private": true` in `package.json` is the +guard — npm refuses to publish it — and `scripts/pack-npm.sh` only builds the +tarball when `CODEGRAPH_PACK_UI=1`, into `release/npm-ui/` (never +`release/npm/`, whose `codegraph-*` glob the release workflow publishes). +Publishing is the maintainer's call and takes two deliberate edits. + +## Layout + +``` +src/ + index.ts the LIBRARY's entry — everything the package exports + main.ts fonts + tokens, mounts App into index.html's #app + app.css the app's reset, shell grid and primitives + lib/theme.css the design tokens (light/dark) + the Svelte Flow map + lib/adapter.ts GraphAdapter, createHttpAdapter, the registry + lib/wire.ts every Wire* payload shape — types only, no runtime + lib/api.ts the screens' calls, one line each, over the adapter + lib/navigation.ts href builders + navigate, behind a driver + App.svelte top bar / trail bar / main, global keys + lib/router.svelte.ts hash router: #/s/, #/file/, #/map, #/flow, #/entry + lib/trail.svelte.ts the walked path; mirrored into the `t` query param + lib/trails.svelte.ts saved trails: one shared fetch, and the two writes + lib/trails-model.ts what a saved trail's row says, incl. its decay (pure) + 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/export-svg.ts the Flow strip and the Map as a standalone SVG (pure) + lib/export-image.ts rasterising that SVG to PNG, clipboard and download + 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, SavedTrails, KindGlyph, DriftBanner, Toast, ExportButtons, map/, flow/, symbol/, file/, entry/ + views/ one component per route +``` + +Fonts (Archivo Variable, IBM Plex Mono) are vendored through `@fontsource*` and +emitted into `dist/viewer/assets`: a local reader must work offline and must not +announce the project to a font CDN. + +## Export + +The Flow strip's header and the Map's side panel carry **Copy image** (a PNG on +the clipboard) and **Download SVG** (a file for a README). Both render the +**light** theme whatever the viewer is set to — an image is read on somebody +else's screen — with 24px of paper around the drawing, a caption naming the path +or the root, and a "CodeGraph" mark in the corner. + +`export-svg.ts` **serialises the layout object**; it does not scrape the DOM. +`buildFlowLayout` and `buildMapLayout` already compute every rectangle, port and +curve before a component renders, so the image and the screen come from one +piece of arithmetic and cannot disagree — and the exporter is a pure function +that a test can run with no browser at all. The output is presentation-only SVG +(no script, no `foreignObject`, no external reference), which is what GitHub +will render in a README. + +Fonts travel as `font-family` stacks rather than embedded bytes. An SVG loaded +as an image may not fetch a webfont, so a raster falls back to the platform's +own monospace; every fallback in the stack advances at ~0.6em like IBM Plex +Mono, so the code grid survives and only the letterforms change. + +## Routes + +| hash | view | +|---|---| +| `#/` | nothing selected | +| `#/s/?hl=&t=` | symbol view | +| `#/file/?hl=` | file view — outline in source order | +| `#/file/?src=1` | file view — the whole file's source, with ports and call arcs | +| `#/map?root=&depth=&tests=1` | module map | +| `#/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=` | 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 + +A flow that does not reach everything it was asked about carries a +`boundary` on the wire, and `buildFlowLayout` turns it into an extra 240px node +one column past the symbol the path stopped at, joined by a dotted `2 4` link +labelled "end of static path" that deliberately has **no arrowhead** — an arrow +would point at a continuation, and the absence of one is the finding. + +Two rules hold it together: + +- **The cap's height is arithmetic, like a card's.** `endCapText()` builds every + sentence the cap shows and `endCapHeight()` measures them; the component then + renders exactly what was measured. Change the wording in one and the other + moves with it — they are the same function read twice. +- **One cap per stopping symbol, not per flow.** Two paths that run out at the + same place ran out for the same reason, and two caps side by side would read + as two different findings. + +The verdict itself is not computed here or in the server: it is +`findDynamicBoundaries` in `src/graph/dynamic-boundary-report.ts`, the same +detector `codegraph_explore` announces boundaries with. + +## The type hierarchy + +A class, interface, struct, trait or enum carries a `hierarchy` on its +`/api/node` payload: ancestors up, subtypes down, and the fan an interface call +dispatches into. `buildHierarchyModel` turns it into a tree whose geometry is +arithmetic — 24px rows, 22px of indent per descendant level, orthogonal 1px +connectors computed from those two numbers. Nothing is measured; the same +payload always draws the same picture. + +The details worth knowing before changing it: + +- **`extends` is solid, `implements` dashed `4 3`, a synthesized edge dashed + `6 3`** with a `via ` pill. In Go, `System` satisfies `Clock` + without either file naming the other and the edge exists only because the + resolver made it — the block says so rather than drawing it like a parse. +- **Overrides on the members outline are a NAME match**, not an `overrides` + edge (nothing in the engine emits one). They are matched against the nearest + ancestor that declares the name and are blind to signatures, and the tooltip + says which claim was actually checked. +- **The fold trims the deepest end**, because the walk is breadth-first: a + reader looking at an interface gets every direct implementation before any + subclass of one appears at all. + +The walk is not computed here or in the server: it is `buildTypeHierarchy` in +`src/graph/type-hierarchy.ts`, whose `countImplementers` is also the number +`codegraph_explore` prints when it announces an interface dispatch — so "N types +implement X" is the same N wherever you read it. + +## Live updates + +The viewer never polls. `lib/live.svelte.ts` holds one `EventSource` on +`/api/events` for the life of the page and exposes two counters: + +- **`indexTick`** — the graph moved (somebody synced). Every screen refetches: + a rail is an answer about the whole graph, and a symbol gains a caller when + some *other* file is edited, so filtering by the focused file would leave the + rails quietly wrong. One request per sync. +- **`diskTick`** — source files changed on disk and the index has not caught up. + Only the screen showing one of those files reacts, and what it does is draw a + drift banner. + +`liveRefresh(file, refresh)` is the three lines of bookkeeping that turns a +counter into a single call; the Map and the Flow strip instead read +`live.indexTick` straight inside the effect that already fetches them. + +Reconnection is ours, not `EventSource`'s: each failure closes the stream and +schedules ONE retry on a backoff that ends after eight attempts (~90 s), at +which point the top bar says "Not live" and nothing more is requested until the +tab is focused again. A `degraded` event — the server's watcher gave up — is +shown the same way and never answered with a poll. + +Node ids and file paths are encoded per slash-separated segment, so +`#/file/src/mcp/tools.ts` stays readable and still round-trips a segment +containing a reserved character. Build hashes with `symbolHref()` / +`fileHref()` / `mapHref()` / `flowHref()` rather than by hand. diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 000000000..a080bb4fb --- /dev/null +++ b/ui/index.html @@ -0,0 +1,15 @@ + + + + + + + CodeGraph + + + + +
+ + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 000000000..2105538f1 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,60 @@ +{ + "name": "@colbymchenry/codegraph-ui", + "private": true, + "version": "1.6.0", + "type": "module", + "description": "The CodeGraph reader as Svelte components: Symbol view, Flow strip and architecture Map behind one data adapter.", + "keywords": [ + "codegraph", + "svelte", + "code-intelligence", + "knowledge-graph" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/colbymchenry/codegraph.git", + "directory": "ui" + }, + "license": "MIT", + "files": [ + "dist", + "README.md" + ], + "svelte": "./dist/index.js", + "types": "./dist/index.d.ts", + "sideEffects": [ + "**/*.css" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "svelte": "./dist/index.js", + "default": "./dist/index.js" + }, + "./theme.css": "./dist/lib/theme.css", + "./package.json": "./package.json" + }, + "scripts": { + "build": "vite build", + "build:lib": "node ../scripts/sync-ui-version.mjs && svelte-package -i src -o dist && node ../scripts/check-ui-package.mjs", + "dev": "vite", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "peerDependencies": { + "svelte": "^5.25.0" + }, + "dependencies": { + "@xyflow/svelte": "^1.6.5" + }, + "devDependencies": { + "@fontsource-variable/archivo": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", + "@sveltejs/package": "^2.5.8", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "svelte": "^5.56.10", + "svelte-check": "^4.7.6", + "typescript": "^5.0.0", + "vite": "^7.3.6" + } +} diff --git a/ui/src/App.svelte b/ui/src/App.svelte new file mode 100644 index 000000000..05921fe7a --- /dev/null +++ b/ui/src/App.svelte @@ -0,0 +1,184 @@ + + + + + + +
+ {#if route.view === 'symbol'} + + {:else if route.view === 'file' && route.source} + + {:else if route.view === 'file'} + + {:else if route.view === 'map'} + + {:else if route.view === 'flow'} + + {:else if route.view === 'entry'} + + {:else if route.view === 'dead'} + + {:else if route.view === 'unknown'} + + {:else} + + {/if} +
+ + + diff --git a/ui/src/app.css b/ui/src/app.css new file mode 100644 index 000000000..f67140a99 --- /dev/null +++ b/ui/src/app.css @@ -0,0 +1,129 @@ +/* ===================================================================== + codegraph ui — the app's global primitives + + The design tokens themselves live in `lib/theme.css`, which is also + what `@colbymchenry/codegraph-ui` exports for a host to import and + override. This file is everything ON TOP of them that only the + standalone viewer needs: the reset, the shell grid, and the handful of + primitives shared across views. + + Component-specific rules live in each .svelte file's scoped diff --git a/ui/src/components/DriftBanner.svelte b/ui/src/components/DriftBanner.svelte new file mode 100644 index 000000000..5a44021b8 --- /dev/null +++ b/ui/src/components/DriftBanner.svelte @@ -0,0 +1,76 @@ + + + +
+ + {file} changed on disk after the last index sync — {@render children()} +
+ + diff --git a/ui/src/components/ExportButtons.svelte b/ui/src/components/ExportButtons.svelte new file mode 100644 index 000000000..ca698c666 --- /dev/null +++ b/ui/src/components/ExportButtons.svelte @@ -0,0 +1,101 @@ + + + +
+ + +
+ + diff --git a/ui/src/components/KindGlyph.svelte b/ui/src/components/KindGlyph.svelte new file mode 100644 index 000000000..e5e9c1b7f --- /dev/null +++ b/ui/src/components/KindGlyph.svelte @@ -0,0 +1,57 @@ + + + 1} + title={titled ? kindWord(kind) : undefined} + aria-hidden={titled ? undefined : 'true'} +>{letter} + + diff --git a/ui/src/components/PalettePanel.svelte b/ui/src/components/PalettePanel.svelte new file mode 100644 index 000000000..2d23aabfe --- /dev/null +++ b/ui/src/components/PalettePanel.svelte @@ -0,0 +1,82 @@ + + +
+ {#if view.hint} +

{view.hint}

+ {/if} + + palette.select(index)} + /> + + {#if palette.failure} +

{palette.failure}

+ {:else if palette.pending && view.items.length === 0} +

Searching…

+ {:else if view.empty} +

{view.empty}

+ {/if} +
+ + diff --git a/ui/src/components/PaletteRows.svelte b/ui/src/components/PaletteRows.svelte new file mode 100644 index 000000000..a872ffec2 --- /dev/null +++ b/ui/src/components/PaletteRows.svelte @@ -0,0 +1,167 @@ + + +{#each palette.sections as section, s (section.title)} +
+ {section.title} + {#if section.note}{section.note}{/if} +
+ {#each section.items as item, r (item.id)} + {@const index = flatIndex(s, r)} + + {/each} +{/each} + + diff --git a/ui/src/components/SavedTrails.svelte b/ui/src/components/SavedTrails.svelte new file mode 100644 index 000000000..cf36ac25d --- /dev/null +++ b/ui/src/components/SavedTrails.svelte @@ -0,0 +1,305 @@ + + +{#if !(hideWhenEmpty && list.length === 0 && trails.failure === null)} +
+
+

{title}

+ {#if trails.directory} + {trails.directory} + {/if} +
+ + {#if trails.failure} +

{trails.failure}

+ {:else if !trails.settled} +

Reading saved trails…

+ {:else if list.length === 0} +

+ No saved trails yet. Walk a path through the code, then press + Save trail on the trail bar to keep it. + {#if trails.readOnlyReason} +
{trails.readOnlyReason} + {/if} +

+ {:else} +
+ {#each list as saved (saved.id)} + {@const decay = trailDecay(saved)} + {@const opens = trailOpens(saved)} +
+ + +
+ + {#if trails.canSave} + + {/if} +
+ + + {#if decay || opens} +

+ {[decay?.text, opens].filter(Boolean).join(' ')} +

+ {/if} +
+ {/each} +
+ {#if trails.payload?.bounded} +

Only the first trails in the directory are listed.

+ {/if} + {/if} +
+{/if} + + diff --git a/ui/src/components/SearchPalette.svelte b/ui/src/components/SearchPalette.svelte new file mode 100644 index 000000000..61fa459c2 --- /dev/null +++ b/ui/src/components/SearchPalette.svelte @@ -0,0 +1,182 @@ + + + + + + + diff --git a/ui/src/components/Toast.svelte b/ui/src/components/Toast.svelte new file mode 100644 index 000000000..66bce706d --- /dev/null +++ b/ui/src/components/Toast.svelte @@ -0,0 +1,49 @@ + + + +
+ {#if toast.message} +
{toast.message}
+ {/if} +
+ + diff --git a/ui/src/components/TopBar.svelte b/ui/src/components/TopBar.svelte new file mode 100644 index 000000000..27315bfbc --- /dev/null +++ b/ui/src/components/TopBar.svelte @@ -0,0 +1,166 @@ + + +
+ + + CodeGraph + ui + + + + + + +
+ {#if liveNote}{liveNote.text}{/if} + {#if project}{project}{/if} + {#if stats}{stats}{/if} +
+
+ + diff --git a/ui/src/components/TrailBar.svelte b/ui/src/components/TrailBar.svelte new file mode 100644 index 000000000..c0ec462df --- /dev/null +++ b/ui/src/components/TrailBar.svelte @@ -0,0 +1,335 @@ + + + +
+
+ Trail + + {#if hops.length === 0} + Step into a call on the right, or up to a caller on the left — the trail records the + path. + {:else} + {#each hops as hop, i (hop.id)} + {#if i > 0} + + {/if} + + {/each} + {/if} + + + + {#if hops.length > 1} + + {/if} + {#if hops.length > 0 && trails.canSave && !naming} + + {/if} + {#if hops.length > 0} + + {/if} +
+ +{#if naming} +
+ + + + + + + {#if replaces} + Replaces the saved trail of the same name. + {:else if trails.directory} + {hops.length} hop{hops.length === 1 ? '' : 's'} · saved to {trails.directory} + {:else} + {hops.length} hop{hops.length === 1 ? '' : 's'} + {/if} + + {#if trails.failure} + {trails.failure} + {/if} +
+{/if} +
+ + diff --git a/ui/src/components/entry/EntrySection.svelte b/ui/src/components/entry/EntrySection.svelte new file mode 100644 index 000000000..49cbc7894 --- /dev/null +++ b/ui/src/components/entry/EntrySection.svelte @@ -0,0 +1,239 @@ + + + +
+
+

{section.title}

+ {section.meta} +
+

{section.note}

+ + {#each section.groups as group (group.path)} +
+
+ {#if group.file} + {group.path} + {:else} + {group.path} + {/if} + {group.rows.length} +
+ {#each group.rows as row (row.id)} +
+ +
+
+ {#if row.target} + + {:else} + + {#if row.method}{row.method}{/if}{row.name} + + {/if} + {#if row.flowFrom} + {@const label = + armed === null ? 'Flow ›' : armed === row.id ? 'Cancel' : '→ here'} + + {/if} +
+
{row.meta}
+
+
+ {/each} +
+ {/each} + + {#if section.shown < section.total} +

+ Showing {section.shown} of {section.floor ? 'at least ' : ''}{section.total} — the rest are in + the index, not on this list. +

+ {/if} +
+ + diff --git a/ui/src/components/file/CodeArcs.svelte b/ui/src/components/file/CodeArcs.svelte new file mode 100644 index 000000000..af0029ba2 --- /dev/null +++ b/ui/src/components/file/CodeArcs.svelte @@ -0,0 +1,126 @@ + + + + + + diff --git a/ui/src/components/file/FileCodeBlock.svelte b/ui/src/components/file/FileCodeBlock.svelte new file mode 100644 index 000000000..c3fe8aa0f --- /dev/null +++ b/ui/src/components/file/FileCodeBlock.svelte @@ -0,0 +1,273 @@ + + + +
+ {#each lines as line (line.n)} +
onhoverline(line.n)} + role="presentation" + > + {line.n} + {#if line.parts}{#each line.parts as part, i (i)}{#if part.ref && !part.ref.outside}{@const ref = + part.ref} onfollow(ref)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onfollow(ref); + } + }} + onmouseenter={() => hot.set(ref.targetId)} + onmouseleave={() => hot.clear(ref.targetId)}>{part.text}{:else if part.ref}{part.text}{:else if part.def}{part.text}{:else if part.cls}{part.text}{:else}{part.text}{/if}{/each}{:else}{/if} + + {#if line.port}{/if} + +
+ {/each} +
+ + diff --git a/ui/src/components/file/FileCodeOutline.svelte b/ui/src/components/file/FileCodeOutline.svelte new file mode 100644 index 000000000..04c71ec2e --- /dev/null +++ b/ui/src/components/file/FileCodeOutline.svelte @@ -0,0 +1,181 @@ + + + + + + diff --git a/ui/src/components/file/FileCodeRail.svelte b/ui/src/components/file/FileCodeRail.svelte new file mode 100644 index 000000000..7534ed7b0 --- /dev/null +++ b/ui/src/components/file/FileCodeRail.svelte @@ -0,0 +1,152 @@ + + + +{#each rows as row (row.key)} + {@const node = row.call.relation.node} +
onopen(node)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onopen(node); + } + }} + onmouseenter={() => { + hot.set(node.id); + onhover(row); + }} + onmouseleave={() => { + hot.clear(node.id); + onhover(null); + }} + > + +
+
+ {node.name}{#if row.lines.length > 1} ×{row.lines.length}{/if} +
+
+ {node.file === focalFile ? 'same file' : node.file} + {#if row.words.length > 0}{row.words.join(', ')}{/if} + {#if row.via}via {row.via}{/if} +
+
+
+{/each} + + diff --git a/ui/src/components/file/FileModeTabs.svelte b/ui/src/components/file/FileModeTabs.svelte new file mode 100644 index 000000000..5738a0d01 --- /dev/null +++ b/ui/src/components/file/FileModeTabs.svelte @@ -0,0 +1,58 @@ + + + + + + diff --git a/ui/src/components/file/FileOutline.svelte b/ui/src/components/file/FileOutline.svelte new file mode 100644 index 000000000..46c32e63e --- /dev/null +++ b/ui/src/components/file/FileOutline.svelte @@ -0,0 +1,224 @@ + + + +
+ Outline + in source order + {total} +
+ +
+ {#if window_.before > 0}
{/if} + {#each rows.slice(window_.start, window_.end) as row, offset (row.entry.id)} + {@const index = window_.start + offset} + + {/each} + {#if window_.after > 0}
{/if} +
+ +{#if rows.length === 0} +
+ Nothing was extracted from this file — it holds no symbols the graph + recognises, only top-level code, or a language without an extractor. +
+{/if} + +{#if truncated} +
+ Showing {rows.length} of {total} symbols. The rest are in the index; this + screen caps what it draws. +
+{/if} + + diff --git a/ui/src/components/file/FileRail.svelte b/ui/src/components/file/FileRail.svelte new file mode 100644 index 000000000..002e29cef --- /dev/null +++ b/ui/src/components/file/FileRail.svelte @@ -0,0 +1,209 @@ + + + +
+
+ {title} {model.total} +
+ + {#if model.rows.length === 0} +
{emptyNote}
+ {/if} + + {#each model.rows as row, index (row.path)} + onhover?.(index)} + > + {dirOf(row.path)}{baseOf(row.path)} + {#if row.symbolCount > 0} + {row.symbolCount} + {/if} + + {/each} + + {#if model.testCount > 0 && model.testCount < model.rows.length} +
+ {plural(model.testCount, 'test file')} at the end of the list. +
+ {/if} + + {#if model.outside.length > 0} +
+ Outside the index {model.outside.length} +
+ {#each model.outside as row (row.name)} +
+ {row.name} + {#if row.lines.length > 1}×{row.lines.length}{/if} +
+ {/each} +
+ Packages and runtime modules — nothing was indexed for them, so this + viewer cannot open them. +
+ {/if} +
+ + diff --git a/ui/src/components/flow/FlowCard.svelte b/ui/src/components/flow/FlowCard.svelte new file mode 100644 index 000000000..bc6d2df71 --- /dev/null +++ b/ui/src/components/flow/FlowCard.svelte @@ -0,0 +1,251 @@ + + + +
+ + + + + + {#if rows.length > 0} +
+ {#each rows as row (row.n)} +
+ {row.n} + {#each row.parts as part, i (i)}{#if part.ref}{:else if part.cls}{part.text}{:else}{part.text}{/if}{/each} +
+ {/each} +
+ {:else} +

+ {source?.drift + ? 'Changed on disk after the last index sync — source is not shown.' + : (source?.reason ?? 'Source outside this slice or this index.')} +

+ {/if} +
+ + diff --git a/ui/src/components/flow/FlowEndCap.svelte b/ui/src/components/flow/FlowEndCap.svelte new file mode 100644 index 000000000..5256bb9d5 --- /dev/null +++ b/ui/src/components/flow/FlowEndCap.svelte @@ -0,0 +1,179 @@ + + + +
+ + +

Where the graph stops. {text.intro}

+ + {#each text.sites as site, i (i)} +
+

{site.headline}

+ {#if site.key !== null} +

key {site.key}

+ {/if} + {#each site.notes as note (note)} +

{note}

+ {/each} + {#if site.candidateHeading !== null} +

{site.candidateHeading}

+ {#each site.candidates as candidate (candidate.node.id)} + + {/each} + {:else if site.candidateNote !== null} +

{site.candidateNote}

+ {/if} +
+ {/each} + + {#if text.quiet !== null} +

{text.quiet}

+ {/if} + + {#if text.uncertainHeading !== null} +
+

{text.uncertainHeading}

+ {#each text.uncertain as next (next.node.id)} + + {/each} +
+ {/if} + + {#if text.further !== null} +

{text.further}

+ {/if} + {#if text.missed !== null} +

{text.missed}

+ {/if} +
+ + diff --git a/ui/src/components/flow/FlowLink.svelte b/ui/src/components/flow/FlowLink.svelte new file mode 100644 index 000000000..a6d9f318e --- /dev/null +++ b/ui/src/components/flow/FlowLink.svelte @@ -0,0 +1,81 @@ + + + + +{#if !d.link.cap} + +{/if} + + {d.link.label}{d.link.lineLabel ? ` (${d.link.lineLabel})` : ''} + {#each above as line, i (i)} + {line} + {/each} + {#if d.link.lineLabel} + {d.link.lineLabel} + {/if} + + + diff --git a/ui/src/components/map/MapSidePanel.svelte b/ui/src/components/map/MapSidePanel.svelte new file mode 100644 index 000000000..f78274249 --- /dev/null +++ b/ui/src/components/map/MapSidePanel.svelte @@ -0,0 +1,416 @@ + + + + + +{#snippet linkList(label: string, links: WireMapLink[], side: 'source' | 'target')} +
{label}
+ {#if links.length > 0} + {#each links as link (link.source + link.target)} +
+ {side === 'target' ? link.target : link.source} + {link.count} +
+ {/each} + {:else} +
nothing
+ {/if} +{/snippet} + + diff --git a/ui/src/components/map/ModuleEdge.svelte b/ui/src/components/map/ModuleEdge.svelte new file mode 100644 index 000000000..e372c9b7f --- /dev/null +++ b/ui/src/components/map/ModuleEdge.svelte @@ -0,0 +1,75 @@ + + + + d.onHover(d.edge, event)} + onmouseleave={() => d.onHover(null, null)} +/> + + diff --git a/ui/src/components/map/ModuleNode.svelte b/ui/src/components/map/ModuleNode.svelte new file mode 100644 index 000000000..9b78b6198 --- /dev/null +++ b/ui/src/components/map/ModuleNode.svelte @@ -0,0 +1,143 @@ + + +{#each layout.targetHandles as handle, i (handle)} + +{/each} + + + +{#each layout.sourceHandles as handle, i (handle)} + +{/each} + + diff --git a/ui/src/components/symbol/BlastStrip.svelte b/ui/src/components/symbol/BlastStrip.svelte new file mode 100644 index 000000000..f9923b469 --- /dev/null +++ b/ui/src/components/symbol/BlastStrip.svelte @@ -0,0 +1,202 @@ + + + +
+
+ Blast radius + {blast.direct} direct dependent{blast.direct === 1 ? '' : 's'} + {blast.withinHops} within {blast.hops} hops + {blast.files} file{blast.files === 1 ? '' : 's'} + {blast.testFiles} test file{blast.testFiles === 1 ? '' : 's'} + {#if blast.routes > 0} + {blast.routes} route{blast.routes === 1 ? '' : 's'} + {/if} +
+ +
+ + +
+ +
+ dark: direct dependents · light: within {blast.hops} hops — scaled to the widest radius in the + index{#if scale?.estimated}{' '}(measured across its {scale.sampled} most-depended-on symbols){/if} +
+ + {#if blast.topFiles.length > 0} +
+ What would need re-checking if this changed +
+ {#each blast.topFiles as entry (entry.file)} +
+ {entry.file} + {entry.symbols} +
+ {/each} + {#if blast.files > blast.topFiles.length} +
+{blast.files - blast.topFiles.length} more files
+ {/if} + {#if testCalls > 0} +
+ plus {plural(testCalls, 'call')} from {plural(testFiles, 'test file')} — the tests that + would catch a regression. +
+ {/if} +
+
+ {/if} +
+ + diff --git a/ui/src/components/symbol/CalleeRail.svelte b/ui/src/components/symbol/CalleeRail.svelte new file mode 100644 index 000000000..b25f9131a --- /dev/null +++ b/ui/src/components/symbol/CalleeRail.svelte @@ -0,0 +1,288 @@ + + + +
+ Calls {model.rows.length} + step down → +
+ +{#each model.rows as row, i (row.relation.node.id)} + {@const node = row.relation.node} +
onstepDown(node)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onstepDown(node); + } + }} + onmouseenter={() => hot.set(node.id)} + onmouseleave={() => hot.clear(node.id)} + > + +
+
+ {node.name}{#if row.lines.length > 1} ×{row.lines.length}{/if} +
+
+ {node.file === focalFile ? 'same file' : node.file} + {#if row.words.length > 0}{row.words.join(', ')}{/if} + {#if row.relation.hub}hub · {row.relation.fanIn}{/if} + {#if row.via}via {row.via}{/if} +
+
+
+{/each} + +{#if model.uncertain.length > 0} +
+ + Uncertain · {model.uncertain.length} name-only match{model.uncertain.length === 1 ? '' : 'es'}, + confidence < 0.6 + +
+ {#each model.uncertain as row (row.relation.node.id)} + {@const node = row.relation.node} +
onstepDown(node)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onstepDown(node); + } + }} + onmouseenter={() => hot.set(node.id)} + onmouseleave={() => hot.clear(node.id)} + > + +
+
{node.name}
+
+ {node.file} + {#if row.relation.confidence !== null}{row.relation.confidence}{/if} +
+
+
+ {/each} +
+
+{/if} + +{#if model.rows.length === 0 && model.uncertain.length === 0} +
{emptyReason}
+{:else if model.outsideCalls > 0 || model.outsideTypeRefs > 0 || model.hiddenGroups > 0} +
+ {#if model.outsideCalls > 0} + +{plural(model.outsideCalls, 'more call')} into symbols outside the index{#if model.outsideTypeRefs > 0}{' '}· + {plural(model.outsideTypeRefs, 'type reference')}{/if}. + {:else if model.outsideTypeRefs > 0} + {plural(model.outsideTypeRefs, 'type reference')} into symbols outside the index. + {/if} + {#if model.hiddenGroups > 0} +
+{model.hiddenGroups} more callee{model.hiddenGroups === 1 ? '' : 's'} not shown. + {/if} +
+{/if} + + diff --git a/ui/src/components/symbol/CallersRail.svelte b/ui/src/components/symbol/CallersRail.svelte new file mode 100644 index 000000000..59d146c68 --- /dev/null +++ b/ui/src/components/symbol/CallersRail.svelte @@ -0,0 +1,358 @@ + + + +
+ Called by {model.total} + ← step up +
+ +{#if model.total === 0} +
+ Nothing in the graph calls or references this symbol{exported + ? ' — it is exported, so callers may live outside the index (or it is an entry point).' + : '.'} +
+{/if} + +{#each model.groups as group, groupIndex (group.file)} +
+
+ {group.same ? 'same file' : group.file} + {group.rows.length} +
+ {#each group.rows as row, rowIndex (row.relation.node.id)} + {@const node = row.relation.node} + {@const isOrigin = node.id === originId} +
onstepUp(node)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onstepUp(node); + } + }} + onmouseenter={() => hot.set(node.id)} + onmouseleave={() => hot.clear(node.id)} + > + +
+
{rowName(node)}
+
+ {#if row.words.length > 0}{row.words.join(', ')}{/if} + {#each row.lines as line (line)} + + {/each} + {#if row.via}via {row.via}{/if} + {#if isOrigin}you came from here{/if} +
+
+
+ {/each} +
+{/each} + +{#if model.tests.rows.length > 0} +
+ + Tests · {plural(model.tests.calls, 'call')} from {plural(model.tests.files.length, 'file')} + +
+ {#each model.tests.rows as row (row.relation.node.id)} + {@const node = row.relation.node} +
onstepUp(node)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onstepUp(node); + } + }} + onmouseenter={() => hot.set(node.id)} + onmouseleave={() => hot.clear(node.id)} + > + +
+
{rowName(node)}
+
{node.file}
+
+
+ {/each} +
+
+{/if} + +{#if model.uncertain.length > 0} +
+ + Uncertain · {model.uncertain.length} name-only match{model.uncertain.length === 1 ? '' : 'es'}, + confidence < 0.6 + +
+ {#each model.uncertain as row (row.relation.node.id)} + {@const node = row.relation.node} +
onstepUp(node)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onstepUp(node); + } + }} + onmouseenter={() => hot.set(node.id)} + onmouseleave={() => hot.clear(node.id)} + > + +
+
{rowName(node)}
+
+ {basename(node.file)} + {#if row.relation.confidence !== null} + {row.relation.confidence} + {/if} +
+
+
+ {/each} +
+
+{/if} + +{#if model.hiddenGroups > 0} +
+ +{model.hiddenGroups} more caller{model.hiddenGroups === 1 ? '' : 's'} not shown — this symbol + has more than the rail lists. +
+{/if} + + diff --git a/ui/src/components/symbol/Connectors.svelte b/ui/src/components/symbol/Connectors.svelte new file mode 100644 index 000000000..d0ef9f781 --- /dev/null +++ b/ui/src/components/symbol/Connectors.svelte @@ -0,0 +1,76 @@ + + + + + + diff --git a/ui/src/components/symbol/MembersOutline.svelte b/ui/src/components/symbol/MembersOutline.svelte new file mode 100644 index 000000000..b72f0cbf1 --- /dev/null +++ b/ui/src/components/symbol/MembersOutline.svelte @@ -0,0 +1,151 @@ + + + +
+ Members + {total} +
+ +
+ {#each rows as row (row.member.id)} + + {/each} +
+ +{#if truncated} +
+ Showing {rows.length} of {total} members — open the file to see the rest. +
+{/if} + + diff --git a/ui/src/components/symbol/SourceBlock.svelte b/ui/src/components/symbol/SourceBlock.svelte new file mode 100644 index 000000000..e888794f5 --- /dev/null +++ b/ui/src/components/symbol/SourceBlock.svelte @@ -0,0 +1,282 @@ + + + +
+ {#each chunks as chunk (chunk.lines[0]?.n ?? -1)} + {#if chunk.gapBefore > 0} +
⋯ {chunk.gapBefore} lines without calls
+ {/if} + {#each chunk.lines as line (line.n)} +
+ {line.n} + {#each line.parts as part, i (i)}{#if part.ref && !part.ref.outside}{@const ref = part.ref} onfollow(ref)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onfollow(ref); + } + }} + onmouseenter={() => hot.set(ref.targetId)} + onmouseleave={() => hot.clear(ref.targetId)}>{part.text}{:else if part.ref}{part.text}{:else if part.def}{part.text}{:else if part.cls}{part.text}{:else}{part.text}{/if}{/each} + + {#if line.port}{/if} + +
+ {/each} + {/each} + + {#if block.tailGap > 0} +
⋯ {block.tailGap} more lines
+ {/if} +
+ + diff --git a/ui/src/components/symbol/SymbolHeader.svelte b/ui/src/components/symbol/SymbolHeader.svelte new file mode 100644 index 000000000..bc0658e9e --- /dev/null +++ b/ui/src/components/symbol/SymbolHeader.svelte @@ -0,0 +1,298 @@ + + + +
+ +

{node.name}

+ {kindPhrase(node)} + + {node.file}:{node.line}–{node.endLine} + · {plural(node.lines, 'line')} + +
+ +{#if payload.ancestors.length > 0} +
+ in {#each payload.ancestors as ancestor, i (ancestor.id)}{#if i > 0} › {/if}{/each} +
+{/if} + +
+ {#if node.exported}exported{/if} + {#if payload.counts.hub} + + hub · {plural(payload.counts.callers, 'caller')} + + {/if} + + {testBadge.text} + +
+ +{#if node.signature} +
{node.name}{node.signature}
+{/if} + +{#if node.docstring} +
{node.docstring}
+{/if} + +{#if supertypes.length > 0 || subtypes.length > 0 || typeChips.length > 0} +
+ {#if supertypes.length > 0} + + {#each supertypes as relation (relation.node.id)} + {relationWord(relation)} + + {/each} + + {/if} + {#if subtypes.length > 0} + + {subtypes[0]?.edgeKinds.includes('implements') ? 'implemented by' : 'extended by'} + {#each subtypes as relation (relation.node.id)} + + {/each} + + {/if} + {#if typeChips.length > 0} + + uses types + {#each typeChips as relation (relation.node.id)} + + {/each} + {#if payload.typesUsed.length > TYPE_CHIP_LIMIT} + +{payload.typesUsed.length - TYPE_CHIP_LIMIT} + {/if} + + {/if} +
+{/if} + + diff --git a/ui/src/components/symbol/TypeHierarchy.svelte b/ui/src/components/symbol/TypeHierarchy.svelte new file mode 100644 index 000000000..8ba659db9 --- /dev/null +++ b/ui/src/components/symbol/TypeHierarchy.svelte @@ -0,0 +1,271 @@ + + + +
+ Type hierarchy + {counts} + supertypes above · subtypes below +
+ +{#if model.headline} +

{model.headline}

+{/if} + +
+
+ + + {#each view.rows as row (row.node.id + row.side)} + {#if row.side === 'focus'} +
+ + {row.node.name} +
+ {:else} + + {/if} + {/each} +
+
+ +{#if model.foldFrom !== null} + +{/if} + +{#if model.note} +
{model.note}
+{/if} + + diff --git a/ui/src/index.ts b/ui/src/index.ts new file mode 100644 index 000000000..df9930f7b --- /dev/null +++ b/ui/src/index.ts @@ -0,0 +1,271 @@ +/** + * `@colbymchenry/codegraph-ui` — the CodeGraph reader as Svelte components. + * + * The same Symbol view, Flow strip and Map that `codegraph ui` serves, behind + * one seam: a {@link GraphAdapter}. The CLI's viewer runs them on + * {@link createHttpAdapter} (the read-only JSON API over loopback); a host that + * already holds the index — CodeGraph Pro, which opens it in-process — installs + * its own adapter and renders the identical components over its own reads. + * Nothing is forked, so the two can never draw different answers from the same + * graph. + * + * ```svelte + * + * + * + * + * + * ``` + * + * Three things a host has to know, all of them in the docs and repeated here + * because they are the ones that bite: + * + * 1. **Import `theme.css` once.** Every component paints from the design + * tokens; without them the screens render as unstyled ink on white. Override + * any variable on a narrower selector. + * 2. **The adapter is module-level, not context.** The pure model modules are + * plain TypeScript and cannot read a component's context, so one page reads + * one project. `` installs it during initialisation. + * 3. **Geometry is not themable.** 34px rail rows, 300/320px rails, the 20px + * code line: the Symbol view measures these against each other to put a + * callee row beside the line that calls it. Colour and type are yours. + */ + +/* ------------------------------------------------------------ the seams -- */ + +export { default as CodegraphUi } from './components/CodegraphUi.svelte'; + +export { + ApiFailure, + createHttpAdapter, + getGraphAdapter, + setGraphAdapter, +} from './lib/adapter'; +export type { + DeadCodeRequest, + EntryPointsRequest, + FlowRequest, + GraphAdapter, + HttpAdapterOptions, + LiveHandlers, + MapRequest, + RoutesRequest, + SaveTrailRequest, + SearchRequest, + SourceRequest, +} from './lib/adapter'; + +export { + back, + deadHref, + entryHref, + fileHref, + flowHref, + getNavigationDriver, + hashNavigation, + mapHref, + navigate, + setNavigationDriver, + symbolHref, +} from './lib/navigation'; +export type { + DeadCodeHrefOptions, + FileHrefOptions, + FlowHrefOptions, + MapHrefOptions, + NavigationDriver, + SymbolHrefOptions, +} from './lib/navigation'; + +/** The wire vocabulary an adapter answers in. Types only — no runtime. */ +export * from './lib/wire'; + +/* ----------------------------------------------------------- the screens -- */ + +/** Callers | verbatim source with gutter ports | line-anchored callee rail. */ +export { default as SymbolView } from './views/SymbolView.svelte'; +/** How one symbol reaches another, one card per hop, opened at the call line. */ +export { default as FlowStrip } from './views/FlowView.svelte'; +/** The repository at module granularity, layered so dependencies point down. */ +export { default as ArchitectureMap } from './views/MapView.svelte'; +/** One file: the outline in source order between two dependency rails. */ +export { default as FileView } from './views/FileView.svelte'; +/** One file's whole source, with gutter ports and intra-file call arcs. */ +export { default as FileSourceView } from './views/FileCodeView.svelte'; +/** Where a reader starts: routes, files that run something, tests, hubs. */ +export { default as EntryPointsView } from './views/EntryView.svelte'; +/** Symbols nothing reaches, grouped by file, with every exclusion printed. */ +export { default as DeadCodeView } from './views/DeadCodeView.svelte'; + +/* -------------------------------------------------------- the furniture -- */ + +/** The path walked, with its arrows, its "read as flow" and its Save. */ +export { default as TrailBar } from './components/TrailBar.svelte'; +/** The trails somebody kept, each hop re-resolved against the current graph. */ +export { default as SavedTrails } from './components/SavedTrails.svelte'; +/** The search box, its keyboard and its results panel — one component. */ +export { default as SearchPalette } from './components/SearchPalette.svelte'; +/** The results panel alone, for a host that owns the input. */ +export { default as PalettePanel } from './components/PalettePanel.svelte'; +/** The rows inside the panel, for a host that owns the whole shell. */ +export { default as PaletteRows } from './components/PaletteRows.svelte'; +/** "This file changed on disk since it was indexed." */ +export { default as DriftBanner } from './components/DriftBanner.svelte'; +/** The one-letter square that stands for a symbol's kind. */ +export { default as KindGlyph } from './components/KindGlyph.svelte'; +/** Copy image / download SVG for a Flow strip or a Map layout. */ +export { default as ExportButtons } from './components/ExportButtons.svelte'; +/** Ancestors up, subtypes down, and the fan an interface call dispatches into. */ +export { default as TypeHierarchy } from './components/symbol/TypeHierarchy.svelte'; + +/* ------------------------------------------------------------- the state -- */ + +export { trail, resolveTrailNames } from './lib/trail.svelte'; +export { encodeTrail, decodeTrail, hopLabel } from './lib/trail-codec'; +export type { HopDirection, TrailHop } from './lib/trail-codec'; +export { trails } from './lib/trails.svelte'; +export { live, liveRefresh, touchesFile } from './lib/live.svelte'; +export type { LiveChanged, LiveHello, LiveIndexEvent, LiveIndexRevision } from './lib/live.svelte'; +export { project } from './lib/project.svelte'; +export { hot, railFocus } from './lib/focus.svelte'; +export type { RailSide } from './lib/focus.svelte'; +export { palette } from './lib/palette.svelte'; +export { toast } from './lib/toast.svelte'; +export { walkTo, arrivedFrom, openEntryTarget } from './lib/walk'; +export type { WalkTarget } from './lib/walk'; + +/* ------------------------------------------------------------ the models -- + Pure functions: no DOM, no fetch, no state. A host that wants a different + screen over the same answers builds it out of these rather than out of the + payloads, so its arithmetic is the arithmetic the shipped screens use. */ + +export { decodeLine, plainLine, tokenClass, tokensByLine } from './lib/highlight'; +export type { Token, TokenClass, WireHighlight, WireToken } from './lib/highlight'; + +export { + assignRefs, + basename, + buildCalleeRail, + buildCallerRail, + buildCodeBlock, + buildOutline, + edgeWord, + graphCallLines, + kindPhrase, + lastSegment, + refsByLine, + relationWords, + showsBody, + synthesizedBy, +} from './lib/symbol-model'; +export type { + CalleeRailModel, + CalleeRow, + CallerFileGroup, + CallerRailModel, + CallerRow, + CodeBlock, + Connector, + LineRef, + OutlineRow, + SourceWindow, +} from './lib/symbol-model'; + +export { buildFlowLayout, cardHeight, endCapHeight, endCapText } from './lib/flow-model'; +export type { + EndCapSite, + EndCapText, + FlowCardLayout, + FlowEndCapLayout, + FlowLayout, + FlowLinkLayout, +} from './lib/flow-model'; + +export { + buildHierarchyModel, + connectorPath, + visibleHierarchy, + HIER_FOLD_AT, + HIER_INDENT, + HIER_PORT_X, + HIER_ROW_H, +} from './lib/hierarchy-model'; +export type { + HierarchyConnector, + HierarchyModel, + HierarchyRow, +} from './lib/hierarchy-model'; + +export { buildMapLayout, isEdgeVisible, moduleMetaLabel } from './lib/map-model'; +export type { + MapEdgeLayout, + MapLayerLayout, + MapLayout, + MapLayoutOptions, + MapNodeLayout, +} from './lib/map-model'; + +export { buildFileOutline, buildFileRail, fileMetaLine, fileTitle } from './lib/file-model'; +export type { FileRailModel, FileRailRow, OutlineEntryRow } from './lib/file-model'; + +export { + buildFileArcs, + buildFileCallRows, + buildFileRefs, + documentHeight, + lineCentre, + lineTop, + pageFor, + visibleLines, +} from './lib/filecode-model'; +export type { FileArc, FileCallRow, SourcePage } from './lib/filecode-model'; + +export { + deadCodeHeadline, + deadCodeRowMeta, + deadCodeScale, + emptyMessage as deadCodeEmptyMessage, + exclusionPhrases, + groupMeta as deadCodeGroupMeta, + DEAD_CODE_CAVEAT, +} from './lib/deadcode-model'; + +export { + hopStatusWord, + isOpenable as isTrailOpenable, + replacedTrail, + trailDecay, + trailExport, + trailMeta, + trailNameProblem, + trailOpens, + trailTitle, + MAX_NAMED_DECAYED, +} from './lib/trails-model'; +export type { TrailDecay } from './lib/trails-model'; + +export { buildEntryPanel, flowPair, matchEntries } from './lib/entry-model'; +export type { + EntryGroup, + EntryPanel, + EntryRow, + EntrySection, + EntryTarget, +} from './lib/entry-model'; + +export { + buildEntryPalette, + buildSearchPalette, + moveSelection, + parseFlowQuery, +} from './lib/search-model'; +export type { FlowQuery, Palette, PaletteItem, PaletteSection } from './lib/search-model'; + +export { exportFilename, flowSvg, mapSvg } from './lib/export-svg'; +export type { ExportOptions, FlowExportOptions, MapExportOptions } from './lib/export-svg'; +export { copyPngToClipboard, downloadSvg, svgToPng } from './lib/export-image'; +export { kindLetter, kindWord } from './lib/kinds'; diff --git a/ui/src/lib/adapter.ts b/ui/src/lib/adapter.ts new file mode 100644 index 000000000..b9a932d4f --- /dev/null +++ b/ui/src/lib/adapter.ts @@ -0,0 +1,463 @@ +/** + * The data seam: everything these components know about a project arrives + * through one {@link GraphAdapter} (task CG-61). + * + * The viewer shipped by `codegraph ui` uses {@link createHttpAdapter}, which is + * the JSON API over loopback. A host that already holds the graph — CodeGraph + * Pro, which opens the index in-process — implements the same thirteen required + * methods against its own reads and never makes an HTTP request. The components + * cannot tell the difference, which is the whole point: one implementation of + * the Symbol view, the Flow strip and the Map, drawn from whichever side of the + * wire the caller happens to be on. + * + * ## The shapes are the contract, not the transport + * + * Every method answers a `Wire*` type from `./wire`, verbatim — the same object + * `src/ui-server/api/` serialises. An adapter is therefore allowed to be a + * `fetch`, a function call, a cache, or a fixture in a test; what it is not + * allowed to do is invent a shape. `./wire` has no imports and no runtime, so a + * host can depend on the vocabulary without depending on the viewer. + * + * ## One adapter per page + * + * The current adapter is module-level state, not Svelte context. Two reasons: + * the pure model modules (`symbol-model`, `flow-model`, the palette store) are + * plain TypeScript and cannot read a component's context, and a reader is + * looking at one project at a time — the screens are a reading of *a* graph. + * A host calls {@link setGraphAdapter} once before it renders, or wraps its + * tree in ``, which does it during initialisation. + */ + +import type { + WireDeadCode, + WireEntryPoints, + WireFilePayload, + WireFileCodePayload, + WireFlowPayload, + WireMapPayload, + WireNodeRefs, + WireRoutes, + WireSearch, + WireSource, + WireStats, + WireSymbolPayload, + WireTrails, +} from './wire'; + +/* ---------------------------------------------------------------- errors -- */ + +/** + * An error the answering side described. + * + * The JSON API answers JSON for *every* outcome, including refusals, so a + * non-2xx still carries a sentence worth showing — this is what puts the + * server's own words on the screen instead of "Failed to fetch". An in-process + * adapter should throw the same thing for the same reason: the screens read + * `code` (`'not-found'` has its own empty state) and print `guidance`. + */ +export class ApiFailure extends Error { + readonly status: number; + readonly code: string; + readonly guidance: string | null; + + constructor(status: number, code: string, message: string, guidance: string | null) { + super(message); + this.name = 'ApiFailure'; + this.status = status; + this.code = code; + this.guidance = guidance; + } +} + +/** What `fail()` in `src/ui-server/api/respond.ts` sends. */ +interface ApiErrorBody { + error?: string; + code?: string; + hint?: string; +} + +/* -------------------------------------------------------------- requests -- */ + +export interface SourceRequest { + file: string; + /** 1-based, inclusive. */ + from: number; + /** 1-based, inclusive. Omitted means "to the end of the file". */ + to?: number; + /** + * What to answer when the file has changed since it was indexed. The default + * omits the slice — an indexed range over rewritten bytes can show a + * different symbol's code under the right name. `'current'` asks for the + * file's current lines instead, and the answer says `showing: 'current'` so + * the caller can switch every line-anchored marking off over it. + */ + ondrift?: 'current'; +} + +/** + * A flow question. Exactly one of the three shapes is asked at a time: + * `{ from, to }` ("how does X reach Y"), `{ symbols }` (`codegraph_explore`'s + * own question, verbatim) or `{ trail }` (the hops the reader walked, as + * `` strings). + */ +export interface FlowRequest { + from?: string; + to?: string; + symbols?: string; + trail?: readonly string[]; +} + +export interface MapRequest { + /** The subtree to aggregate — a monorepo's package. Null lets the graph pick. */ + root?: string | null; + /** How many path segments under the root name a module. */ + depth?: number; +} + +export interface SearchRequest { + limit?: number; +} + +export interface EntryPointsRequest { + limit?: number; + routes?: number; +} + +export interface RoutesRequest { + limit?: number; +} + +/** + * What the dead code list should be allowed to claim. + * + * Every flag widens the list by switching one honesty rule off, so each one is + * a thing the screen then has to say out loud. `exported` is the big one: a + * symbol something outside the repository could import is not dead in any sense + * the index can check, and turning it on also turns off the "this language + * records no exports at all" guard. + */ +export interface DeadCodeRequest { + limit?: number; + /** Node kinds to consider. Omitted means callables and types. */ + kinds?: readonly string[]; + includeExported?: boolean; + includeTests?: boolean; + includeGenerated?: boolean; +} + +/** + * A trail to save: a name, an optional note, and the walk as ids. + * + * Ids and directions only. Everything else a saved hop records — the name, the + * kind, the file, the line — is read out of the graph by the answering side, so + * a saved trail is always a claim the index itself made and can therefore + * re-check when it next changes. + */ +export interface SaveTrailRequest { + name: string; + note?: string; + hops: ReadonlyArray<{ dir: 'start' | 'down' | 'up'; id: string }>; +} + +/* ----------------------------------------------------------------- live -- */ + +/** + * The live channel's events, as the viewer's `live.svelte.ts` consumes them. + * + * An adapter that has no way to know the graph moved simply omits + * {@link GraphAdapter.events}; the screens then render once and stay put, which + * is the correct behaviour for a host that re-mounts them itself. + */ +export interface LiveHandlers { + hello(event: unknown): void; + changed(event: unknown): void; + index(event: unknown): void; + degraded(event: unknown): void; + /** The connection dropped. The caller owns the backoff — never retry here. */ + error(): void; +} + +/* -------------------------------------------------------------- adapter -- */ + +/** + * Everything the components ask of a project. + * + * Seven of these are the reading surface named in the task — `search`, `node`, + * `source`, `file`, `flow`, `map`, `routes` — and the rest are what the screens + * around them need: `stats` (the blast bar's denominator and the top bar's + * counts), `nodes` (a trail arrives from a URL as bare ids), `fileCode` (the + * whole-file view), `entryPoints` (where a reader starts), `deadCode` (where + * nobody goes) and `trails` (the walks the reader kept). + * + * Everything here answers a question except `saveTrail`/`deleteTrail`, which + * are optional for exactly that reason. + */ +export interface GraphAdapter { + /** The index's own facts: counts, thresholds, the blast scale. */ + stats(signal?: AbortSignal): Promise; + search(query: string, opts?: SearchRequest, signal?: AbortSignal): Promise; + /** One symbol with its rails, outline, tests, blast radius and drift verdict. */ + node(id: string, signal?: AbortSignal): Promise; + /** Names and locations for ids the caller already holds (the trail). */ + nodes(ids: readonly string[], signal?: AbortSignal): Promise; + /** A slice of an indexed file, classified for highlighting. */ + source(request: SourceRequest, signal?: AbortSignal): Promise; + /** One file: outline, import rails, dependencies, drift. */ + file(path: string, signal?: AbortSignal): Promise; + /** Everything the graph says about the LINES of one file (ports and arcs). */ + fileCode(path: string, signal?: AbortSignal): Promise; + /** The call path between symbols — `resolveNamedSymbolFlow`'s own answer. */ + flow(request: FlowRequest, signal?: AbortSignal): Promise; + /** The repository at module granularity, layered. */ + map(request?: MapRequest, signal?: AbortSignal): Promise; + /** The URL → handler map. */ + routes(request?: RoutesRequest, signal?: AbortSignal): Promise; + /** Where a reader starts: routes, files that run something, tests, hubs. */ + entryPoints(request?: EntryPointsRequest, signal?: AbortSignal): Promise; + /** Symbols nothing reaches, grouped by file, with every exclusion counted. */ + deadCode(request?: DeadCodeRequest, signal?: AbortSignal): Promise; + /** + * The reader's saved trails, each hop re-resolved against the current graph. + * + * A host with nowhere to keep them answers `{ trails: [], readOnly: true, … }` + * rather than omitting the method: the screens then show the section as + * empty-and-explained instead of showing a Save button that does nothing. + */ + trails(signal?: AbortSignal): Promise; + /** + * Save a trail, answering the full list as it now stands. + * + * OPTIONAL, and the only mutating pair in this interface. An adapter that + * refuses to write simply omits {@link saveTrail} and {@link deleteTrail} — + * a host must be able to render the reader without inheriting a filesystem + * write it never asked for, and the viewer hides Save when they are absent + * exactly as it does when the server answers `readOnly`. + */ + saveTrail?(request: SaveTrailRequest, signal?: AbortSignal): Promise; + /** Remove a saved trail by id, answering the list as it now stands. */ + deleteTrail?(id: string, signal?: AbortSignal): Promise; + /** + * Subscribe to index/disk changes. Optional — a host without a live channel + * omits it and nothing polls. Returns a function that closes the stream. + */ + events?(handlers: LiveHandlers): () => void; +} + +/* ------------------------------------------------------------ http impl -- */ + +export interface HttpAdapterOptions { + /** + * Where the API lives, ending in a slash. The default is relative (`''`), + * which is what the CLI serves: the viewer is mounted at `/` and asks for + * `api/stats`, so it survives being mounted under a sub-path. + */ + baseUrl?: string; + /** Injectable for tests and for a host that wraps `fetch` with auth. */ + fetch?: typeof globalThis.fetch; +} + +/** Ids and paths carry ':' and '/', so encode per segment and rejoin. */ +function encodePath(value: string): string { + return value.split('/').map(encodeURIComponent).join('/'); +} + +function query(params: URLSearchParams): string { + const text = params.toString(); + return text ? `?${text}` : ''; +} + +/** + * The header every write carries. + * + * The server refuses a `POST`/`DELETE` without it. It is not a secret and is + * not trying to be: a custom request header cannot be sent cross-origin without + * a CORS preflight, and the viewer's server answers none — so its presence is + * proof the request came from a page the server itself served. Must match + * `WRITE_HEADER` in `src/ui-server/security.ts`. + */ +export const WRITE_HEADER = 'X-CodeGraph-UI'; + +/** + * The default adapter: the JSON API `codegraph ui` serves. + * + * Every failure it can describe comes back as an {@link ApiFailure} carrying + * the server's own sentence. The one it cannot describe — the server was + * stopped while the tab stayed open — is given a sentence here, because a + * network-level `TypeError` says nothing a reader can act on. + */ +export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapter { + const base = options.baseUrl ?? ''; + const doFetch = options.fetch ?? ((...args: Parameters) => + globalThis.fetch(...args)); + + async function call(path: string, init: RequestInit, signal?: AbortSignal): Promise { + let response: Response; + try { + response = await doFetch(`${base}${path}`, { ...init, signal }); + } catch (cause) { + if (signal?.aborted) throw cause; + throw new ApiFailure( + 0, + 'unreachable', + 'The codegraph ui server is not answering.', + 'It may have been stopped — restart it with `codegraph ui` and reload this page.' + ); + } + + const body = (await response.json().catch(() => null)) as unknown; + if (!response.ok) { + const failure = (body as ApiErrorBody | null) ?? {}; + throw new ApiFailure( + response.status, + failure.code ?? 'error', + failure.error ?? `The server answered ${response.status}.`, + failure.hint ?? null + ); + } + return body as T; + } + + function getJson(path: string, signal?: AbortSignal): Promise { + return call(path, { headers: { accept: 'application/json' } }, signal); + } + + /** A write: the marker header, and a JSON body when there is one to send. */ + function write(path: string, method: string, body?: unknown, signal?: AbortSignal): Promise { + const headers: Record = { + accept: 'application/json', + [WRITE_HEADER]: '1', + }; + if (body !== undefined) headers['content-type'] = 'application/json'; + return call( + path, + { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }, + signal + ); + } + + return { + stats: (signal) => getJson('api/stats', signal), + + search(text, opts = {}, signal) { + const params = new URLSearchParams({ q: text }); + if (opts.limit) params.set('limit', String(opts.limit)); + return getJson(`api/search${query(params)}`, signal); + }, + + node: (id, signal) => getJson(`api/node/${encodePath(id)}`, signal), + + nodes(ids, signal) { + const params = new URLSearchParams(); + // Repeated `id` params, never one comma-joined list: a node id can be a + // file path and a file path can contain a comma. + for (const id of ids) params.append('id', id); + return getJson(`api/nodes${query(params)}`, signal); + }, + + source(request, signal) { + const params = new URLSearchParams({ + file: request.file, + from: String(request.from), + }); + // Absent `to` means "to the end of the file"; sending 0 for that would be + // out of range, not a synonym. + if (request.to !== undefined && request.to > 0) params.set('to', String(request.to)); + if (request.ondrift) params.set('ondrift', request.ondrift); + return getJson(`api/source${query(params)}`, signal); + }, + + file: (path, signal) => getJson(`api/file/${encodePath(path)}`, signal), + + fileCode: (path, signal) => + getJson(`api/filecode/${encodePath(path)}`, signal), + + flow(request, signal) { + const params = new URLSearchParams(); + if (request.from) params.set('from', request.from); + if (request.to) params.set('to', request.to); + if (request.symbols) params.set('symbols', request.symbols); + for (const hop of request.trail ?? []) params.append('hop', hop); + return getJson(`api/flow${query(params)}`, signal); + }, + + map(request = {}, signal) { + const params = new URLSearchParams(); + if (request.root !== undefined && request.root !== null) params.set('root', request.root); + if (request.depth) params.set('depth', String(request.depth)); + return getJson(`api/map${query(params)}`, signal); + }, + + routes(request = {}, signal) { + const params = new URLSearchParams(); + if (request.limit) params.set('limit', String(request.limit)); + return getJson(`api/routes${query(params)}`, signal); + }, + + entryPoints(request = {}, signal) { + const params = new URLSearchParams(); + if (request.limit) params.set('limit', String(request.limit)); + if (request.routes) params.set('routes', String(request.routes)); + return getJson(`api/entrypoints${query(params)}`, signal); + }, + + deadCode(request = {}, signal) { + const params = new URLSearchParams(); + if (request.limit) params.set('limit', String(request.limit)); + if (request.kinds?.length) params.set('kinds', request.kinds.join(',')); + if (request.includeExported) params.set('exported', '1'); + if (request.includeTests) params.set('tests', '1'); + if (request.includeGenerated) params.set('generated', '1'); + return getJson(`api/deadcode${query(params)}`, signal); + }, + + trails: (signal) => getJson('api/trails', signal), + + saveTrail: (request, signal) => write('api/trails', 'POST', request, signal), + + deleteTrail: (id, signal) => + write(`api/trails/${encodeURIComponent(id)}`, 'DELETE', undefined, signal), + + events(handlers) { + if (typeof EventSource === 'undefined') return () => {}; + const stream = new EventSource(`${base}api/events`); + stream.addEventListener('hello', (event) => handlers.hello(parse(event))); + stream.addEventListener('changed', (event) => handlers.changed(parse(event))); + stream.addEventListener('index', (event) => handlers.index(parse(event))); + stream.addEventListener('degraded', (event) => handlers.degraded(parse(event))); + // The backoff belongs to the caller, not here: an adapter that retried on + // its own would race the one that already does and double the requests. + stream.addEventListener('error', () => handlers.error()); + return () => stream.close(); + }, + }; +} + +function parse(event: Event): unknown { + const data = (event as MessageEvent).data; + if (typeof data !== 'string') return null; + try { + return JSON.parse(data) as unknown; + } catch { + return null; + } +} + +/* ------------------------------------------------------------- registry -- */ + +let current: GraphAdapter | null = null; + +/** + * Install the adapter every screen reads through. + * + * Call once, before anything renders. Passing `null` restores the default HTTP + * adapter, which is what the standalone viewer runs on. + */ +export function setGraphAdapter(adapter: GraphAdapter | null): void { + current = adapter; +} + +/** The installed adapter, defaulting to the HTTP one on first use. */ +export function getGraphAdapter(): GraphAdapter { + if (current === null) current = createHttpAdapter(); + return current; +} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts new file mode 100644 index 000000000..3950f4926 --- /dev/null +++ b/ui/src/lib/api.ts @@ -0,0 +1,209 @@ +/** + * The screens' side of the graph API. + * + * Every function here is one call on the installed {@link GraphAdapter} + * (`adapter.ts`). Nothing in this file knows about HTTP: the standalone viewer + * runs on `createHttpAdapter`, and a host that already holds the index — the + * Pro app — installs its own and these same functions read from it. + * + * The wire types live in `./wire` (types only, no runtime) and are re-exported + * here so that a screen can keep asking one module for both the call and the + * shape it answers with. + */ + +import { ApiFailure, getGraphAdapter } from './adapter'; +import type { + WireDeadCode, + WireEntryPoints, + WireFilePayload, + WireFileCodePayload, + WireFlowPayload, + WireMapPayload, + WireNodeRefs, + WireRoutes, + WireSearch, + WireSource, + WireStats, + WireSymbolPayload, + WireTrails, +} from './wire'; +import type { SaveTrailRequest } from './adapter'; + +export * from './wire'; +export { ApiFailure } from './adapter'; +export type { + GraphAdapter, + DeadCodeRequest, + EntryPointsRequest, + FlowRequest, + HttpAdapterOptions, + LiveHandlers, + MapRequest, + RoutesRequest, + SaveTrailRequest, + SearchRequest, + SourceRequest, +} from './adapter'; + +export function fetchStats(signal?: AbortSignal): Promise { + return getGraphAdapter().stats(signal); +} + +export function fetchSymbol(id: string, signal?: AbortSignal): Promise { + return getGraphAdapter().node(id, signal); +} + +export function fetchSearch( + query: string, + opts: { limit?: number } = {}, + signal?: AbortSignal +): Promise { + return getGraphAdapter().search(query, opts, signal); +} + +/** Names and locations for ids you already have — what the trail redraws with. */ +export function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Promise { + return getGraphAdapter().nodes(ids, signal); +} + +export function fetchEntryPoints( + opts: { limit?: number; routes?: number } = {}, + signal?: AbortSignal +): Promise { + return getGraphAdapter().entryPoints(opts, signal); +} + +/** + * Symbols nothing in the index reaches, grouped by file — and, just as + * importantly, every reason a candidate was left off. The screen prints both. + */ +export function fetchDeadCode( + opts: { + limit?: number; + kinds?: readonly string[]; + includeExported?: boolean; + includeTests?: boolean; + includeGenerated?: boolean; + } = {}, + signal?: AbortSignal +): Promise { + return getGraphAdapter().deadCode(opts, signal); +} + +/** The URL → handler map. The palette reads routes through `fetchEntryPoints`. */ +export function fetchRoutes( + opts: { limit?: number } = {}, + signal?: AbortSignal +): Promise { + return getGraphAdapter().routes(opts, signal); +} + +export function fetchFile(path: string, signal?: AbortSignal): Promise { + return getGraphAdapter().file(path, signal); +} + +/** + * Everything the graph says about the lines of one file: the outline, one row + * per (caller, callee) pair with its call-site lines, and the references that + * resolved to nothing. The SOURCE is not in here — it pages through + * `fetchSource`, so the ports and arcs are complete before any text arrives. + */ +export function fetchFileCode( + path: string, + signal?: AbortSignal +): Promise { + return getGraphAdapter().fileCode(path, signal); +} + +/** + * A slice of an indexed file. + * + * `ondrift` decides what happens when the file has changed since it was + * indexed. The default omits the slice — an indexed range over rewritten bytes + * can show a different symbol's code under the right name. `'current'` asks for + * the file's current lines instead, which is only correct for a caller that is + * also going to SAY so: the response comes back `showing: 'current'`, and every + * line-anchored thing the graph knows (ports, arcs, call sites, rail rows) has + * to be switched off over it. + */ +export function fetchSource( + file: string, + from: number, + to: number, + signal?: AbortSignal, + ondrift?: 'current' +): Promise { + return getGraphAdapter().source({ file, from, to, ondrift }, signal); +} + +/** + * The module map. `root` selects the subtree (a monorepo's package); `depth` + * is how many path segments under it name a module. Omitting `root` lets the + * adapter pick the repository's source directory. + */ +export function fetchMap( + opts: { root?: string | null; depth?: number } = {}, + signal?: AbortSignal +): Promise { + return getGraphAdapter().map(opts, signal); +} + +/** + * A flow. Exactly one of the three shapes is sent: + * + * - `{ from, to }` — "how does X reach Y", from the search box. + * - `{ symbols }` — `codegraph_explore`'s own question, verbatim. + * - `{ trail }` — the hops the reader walked, as `` strings. + */ +export function fetchFlow( + spec: { from?: string; to?: string; symbols?: string; trail?: readonly string[] }, + signal?: AbortSignal +): Promise { + return getGraphAdapter().flow(spec, signal); +} + +/* ---------------------------------------------------------- saved trails -- */ + +/** + * The reader's saved trails, every hop re-resolved against the current index. + * + * A trail is stored by qualified name rather than by node id, so this is where + * the graph gets to say what became of each hop since it was written: still + * there, moved, now ambiguous, or gone. + */ +export function fetchTrails(signal?: AbortSignal): Promise { + return getGraphAdapter().trails(signal); +} + +/** + * Whether trails can be written at all through the installed adapter. + * + * Separate from the `readOnly` flag on the payload: that one is the *answering + * side* declining, this one is an adapter that never offered. Both hide Save, + * and the screens say which it was. + */ +export function canWriteTrails(): boolean { + const adapter = getGraphAdapter(); + return typeof adapter.saveTrail === 'function' && typeof adapter.deleteTrail === 'function'; +} + +/** Save a trail, answering the whole list as it now stands. */ +export function saveTrail( + request: SaveTrailRequest, + signal?: AbortSignal +): Promise { + const adapter = getGraphAdapter(); + if (!adapter.saveTrail) { + return Promise.reject(new ApiFailure(0, 'refused', 'This viewer cannot save trails.', null)); + } + return adapter.saveTrail(request, signal); +} + +/** Remove a saved trail, answering the whole list as it now stands. */ +export function deleteTrail(id: string, signal?: AbortSignal): Promise { + const adapter = getGraphAdapter(); + if (!adapter.deleteTrail) { + return Promise.reject(new ApiFailure(0, 'refused', 'This viewer cannot delete trails.', null)); + } + return adapter.deleteTrail(id, signal); +} diff --git a/ui/src/lib/deadcode-model.ts b/ui/src/lib/deadcode-model.ts new file mode 100644 index 000000000..cc00c020c --- /dev/null +++ b/ui/src/lib/deadcode-model.ts @@ -0,0 +1,95 @@ +/** + * The dead code list's arithmetic and its sentences (design spec §3.11). + * + * Pure functions over the `/api/deadcode` payload: no DOM, no fetch. The + * screen's whole job is to be believed, and everything that decides whether it + * should be lives here — the caveat that never goes away, the headline that + * says how much of the index is behind the list, and the sentence that names + * every reason a candidate was left off. + * + * The rule this file exists to enforce: **the list and its caveats are one + * thing.** A screen that draws the rows and leaves the exclusions to a + * collapsed panel is a screen that gets somebody to delete a route handler. + */ + +import { plural } from './symbol-model'; +import { kindWord } from './kinds'; +import type { WireDeadCode, WireDeadCodeGroup, WireDeadCodeRow } from './wire'; + +/** + * The line that is always on screen, whatever the list says. + * + * Not a dismissible note and not a tooltip: the claim this screen makes is + * "no static reference in the index", which is a strictly weaker claim than + * "unused", and the difference is the whole risk of acting on it. + */ +export const DEAD_CODE_CAVEAT = + 'No static reference in the index — dynamic use is possible.'; + +/** "symbol" / "symbols" — the noun without its count, for sentences that count twice. */ +function noun(count: number, one: string): string { + return count === 1 ? one : `${one}s`; +} + +/** The one-line summary above the list. */ +export function deadCodeHeadline(payload: WireDeadCode | null): string { + if (!payload) return ''; + const { rows } = payload; + if (rows.total === 0) return 'Nothing on this list.'; + const lines = payload.groups.reduce((sum, group) => sum + group.lines, 0); + const shown = rows.truncated + ? `${rows.shown} of ${rows.total} ${noun(rows.total, 'symbol')}` + : plural(rows.total, 'symbol'); + return `${shown} in ${plural(payload.groups.length, 'file')} · ${plural(lines, 'line')}`; +} + +/** + * "2 478 of 2 498 candidates were left off" — the number that gives the list + * its scale. + * + * Twenty rows drawn from twenty candidates and twenty drawn from two and a half + * thousand are different screens, and only this sentence tells them apart. + */ +export function deadCodeScale(payload: WireDeadCode | null): string { + if (!payload || payload.candidates === 0) return ''; + return `${payload.candidates.toLocaleString()} ${noun(payload.candidates, 'symbol')} in this index carry no incoming reference at all; ${payload.excludedTotal.toLocaleString()} of them were left off this list.`; +} + +/** Each exclusion as "N