diff --git a/.gitignore b/.gitignore index 47d16c0..b85152c 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 4b7b4f6..63a127d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,11 +62,14 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). This also takes about 3 MB of grammar files and two dependencies out of the install. +- **The viewer's screens are now a component library other tools can render.** The Symbol view, the Flow strip and the Map are packaged as `@colbymchenry/codegraph-ui` — the same components `codegraph ui` draws, not a copy of them — so another application can show you a symbol's callers, a call path or your architecture over its own copy of the graph. Everything a screen knows arrives through one small interface it is handed, so the tool doing the rendering decides where the data comes from and where a click goes; a design-token stylesheet ships with it so the screens can be themed to match whatever they are embedded in. It is versioned with the engine, so the reader and the graph it reads always match. + + Nothing changes for `codegraph ui` itself — it is the same viewer, now the library's first user. The package is prepared, not yet on npm. + ### Fixes - Fixed a long-running `codegraph ui` session serving a symbol that a sync had already deleted. The viewer keeps one connection to your index open, and its in-memory lookup didn't notice when another process — your agent's sync, or `codegraph sync` — rewrote the file underneath it, so a symbol screen could keep showing a body with no callers while search correctly reported it had moved. Because a symbol's identity includes the line it starts on, this happened after almost any edit above it. - ## [1.6.0] - 2026-08-26 ### Highlights diff --git a/CLAUDE.md b/CLAUDE.md index b3add4d..9450879 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ Distributed as `@colbymchenry/codegraph` on npm; same binary serves as installer ```bash npm run build # tsc + copy schema.sql and *.wasm + build the viewer into dist/; chmods dist/bin/codegraph.js +npm run build:lib # the viewer's components as @colbymchenry/codegraph-ui (ui/dist) — NOT part of `build` npm run dev # tsc --watch npm run clean # rm -rf dist @@ -36,6 +37,22 @@ browser viewer into `dist/viewer/` (never `dist/ui/` — that's the terminal ui) highlighting reads a file with the same grammar the engine indexed it with, so a missing wasm is an unhighlighted screen as well as an extraction gap. +`npm run build:lib` is separate and does NOT run as part of `npm run build`: it compiles the same +`ui/src` tree a second way, with `svelte-package`, into `ui/dist` — the `@colbymchenry/codegraph-ui` +component library the Pro app imports (task CG-61). `scripts/check-ui-package.mjs` then prunes the +standalone app's shell out of it, resolves the extensionless import specifiers `svelte-package` +leaves behind, and asserts the seam: nothing outside `lib/adapter.js` may reach the network. The +package is **prepared, not published** — `ui/package.json` carries `"private": true` deliberately, +and `scripts/pack-npm.sh` only packs a tarball when `CODEGRAPH_PACK_UI=1`. + +Tests run as **two vitest projects** (`vitest.workspace.mts`): `engine` (node) and `ui` (jsdom, the +Svelte plugin, `resolve.conditions: ['browser']`) for the single `__tests__/ui-package.test.ts`. +`npm test` still runs both. The split is not cosmetic — `browser` is a package-resolution +condition, and applied globally it hands the engine's suites the browser builds of +`web-tree-sitter` and friends. The root config (`vitest.config.mts`, `.mts` because the plugin is +ESM-only and the repo is CJS) is the shared base; note that a workspace project **concatenates** +the base's `include` with its own, which is why the `ui` project does not `extends` it. + Node engines: `>=20.0.0 <25.0.0`. There is a hard exit on Node 25.x and below 20 (see `src/bin/node-version-check.ts`). ## Architecture diff --git a/__tests__/ui-package.test.ts b/__tests__/ui-package.test.ts new file mode 100644 index 0000000..a5dd7fa --- /dev/null +++ b/__tests__/ui-package.test.ts @@ -0,0 +1,643 @@ +/** + * `@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, + TrailBar, + 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 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: [], + 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 }, + }), + // Deliberately no `events`: a host without a live channel is the normal + // case, and nothing may poll in its 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('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('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/docs/design/codegraph-ui-design-spec.md b/docs/design/codegraph-ui-design-spec.md index 83e42ad..0c6581f 100644 --- a/docs/design/codegraph-ui-design-spec.md +++ b/docs/design/codegraph-ui-design-spec.md @@ -334,7 +334,7 @@ with `src/index.ts` selected, 15 links and 4 dimmed boxes, matching the canvas). roughly double the token count on a dense line. - The classification is a class NAME, never a colour, and the viewer paints it from the CSS custom properties above — so **one token stream serves light and dark** with no refetch when `prefers-color-scheme` flips, and the ramp lives only in - `ui/src/app.css`. `type` is a distinct class painted at plain ink: the colouring is near-monochrome and a type name is not one + `ui/src/lib/theme.css`. `type` is a distinct class painted at plain ink: the colouring is near-monochrome and a type name is not one of the four things it moves off plain ink. - Every code token is split into identifier runs before it goes on the wire, so the graph's call-site overlay claims a token the classifier produced rather than re-cutting a line — which is what keeps a link landing on the callee's own name whatever @@ -350,6 +350,26 @@ with `src/index.ts` selected, 15 links and 4 dimmed boxes, matching the canvas). - No native modules; no runtime dependency for the UI itself; the CLI serves **`dist/viewer/`** over `node:http`, loopback only. (Not `dist/ui/` — `src/ui/` is the engine's *terminal* ui and tsc already compiles it there; see `ui/README.md`.) +### 4.1 The component library (`@colbymchenry/codegraph-ui`, CG-61) +The same `ui/src` tree builds a second way — `svelte-package` into `ui/dist` — so CodeGraph Pro renders the Symbol view, the Flow +strip and the Map over its own in-process engine reads without forking a component. One tree, because a fork is a second answer to +the same question about the same graph. +- **One seam: `GraphAdapter`** (`ui/src/lib/adapter.ts`) — eleven methods answering the `Wire*` shapes verbatim. `createHttpAdapter()` + is the loopback JSON API and is what the CLI's viewer runs on; a host implements the same methods and never makes a request. + The shapes live in `ui/src/lib/wire.ts`, which has no imports and no runtime, so a host can depend on the vocabulary alone. + `scripts/check-ui-package.mjs` asserts that nothing in the built package but `lib/adapter.js` reaches the network. +- **`events` is optional.** No live channel means nothing connects and nothing polls; a host that learns of a sync some other way + calls `live.signal('index')`, the same code path the stream uses. +- **Navigation is a driver, not a callback** (`ui/src/lib/navigation.ts`): the components build hrefs, because middle-click and + "copy link address" are how people read code. The default is the viewer's hash space; a host installs its own URL space. The + app's half — the hash parser and the live route — attaches window listeners at module scope and is **pruned out of the package**. +- **Theming is colour and type only.** `theme.css` carries the §2.1 tokens and maps Svelte Flow's `--xy-*` variables onto them, so a + host never sees library defaults in the pane, controls or minimap. Geometry (34px rail rows, the 300/320px rails, the 20px code + line) is not themable: the Symbol view measures those against each other to put a callee row beside the line that calls it. +- Versioned with the engine (`scripts/sync-ui-version.mjs`), because the payload shapes are versioned with the binary that serves + them. **Prepared, not published**: `"private": true` is the guard and `scripts/pack-npm.sh` only packs it under + `CODEGRAPH_PACK_UI=1`. + ## 5. Copy rules Sentence case; controls say what happens ("Read as flow", "Clear"); counts always visible next to folds; honesty phrases fixed: "No test reaches this within 3 caller hops", "Reached by tests · N files within 3 hops", "Uncertain · N name-only matches, confidence < 0.6", diff --git a/package-lock.json b/package-lock.json index 67c16a6..e5a55c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,9 +27,11 @@ "codegraph": "dist/bin/codegraph.js" }, "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.4", "@types/better-sqlite3": "^7.6.0", "@types/node": "^20.19.30", "@types/picomatch": "^4.0.2", + "jsdom": "^25.0.1", "typescript": "^5.0.0", "vitest": "^2.1.9" }, @@ -37,6 +39,20 @@ "node": ">=20.0.0 <25.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, "node_modules/@clack/core": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.0.tgz", @@ -65,6 +81,127 @@ "node": ">= 20.12.0" } }, + "node_modules/@colbymchenry/codegraph-ui": { + "resolved": "ui", + "link": true + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -531,7 +668,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -542,7 +678,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -553,7 +688,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -563,14 +697,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -931,7 +1063,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@svelte-put/shortcut/-/shortcut-4.2.0.tgz", "integrity": "sha512-hqNLo4yEc++SLgAkZUvuwMxIAsii9qjQtTuzfcYVf3xRxa+0HFcfaWFK7LdU3l+15s9SYVNbPB0qQj9CHFqSuw==", - "dev": true, "license": "MIT", "peerDependencies": { "svelte": "^5.1.0" @@ -941,7 +1072,6 @@ "version": "1.0.13", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -957,6 +1087,100 @@ "node": ">= 18.0.0" } }, + "node_modules/@sveltejs/package": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/@sveltejs/package/-/package-2.5.8.tgz", + "integrity": "sha512-zeBbsXYvHiBu56v4gJaGQoEHzg96w0E1j3dOMX8vo56s6vI5eQ57ZEZhudjwjnegnVitRRu5MrmhO0eNvaonIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "kleur": "^4.1.5", + "sade": "^1.8.1", + "semver": "^7.5.4", + "svelte2tsx": "~0.7.55" + }, + "bin": { + "svelte-package": "svelte-package.js" + }, + "engines": { + "node": "^16.14 || >=18" + }, + "peerDependencies": { + "svelte": "^3.44.0 || ^4.0.0 || ^5.0.0-next.1" + } + }, + "node_modules/@sveltejs/package/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@sveltejs/package/node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz", + "integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0", + "debug": "^4.3.7", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.12", + "vitefu": "^1.0.3" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz", + "integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0", + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -971,14 +1195,12 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-drag": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -988,7 +1210,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-color": "*" @@ -998,14 +1219,12 @@ "version": "3.0.11", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-transition": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -1015,7 +1234,6 @@ "version": "3.0.8", "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-interpolate": "*", @@ -1026,7 +1244,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/node": { @@ -1051,7 +1268,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, "license": "MIT" }, "node_modules/@vitest/expect": { @@ -1171,7 +1387,6 @@ "version": "1.6.5", "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-1.6.5.tgz", "integrity": "sha512-bSPLuFlaa5mVWNg4FIZEt0vY2x+8eImnq57p4G6TlfkFPVBVVOyiBynhn4IN76oVOm7wWyFLHaCloV+4biLYhw==", - "dev": true, "license": "MIT", "dependencies": { "@svelte-put/shortcut": "^4.1.0", @@ -1185,7 +1400,6 @@ "version": "0.0.81", "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.81.tgz", "integrity": "sha512-hfbafW4i7uLq7ILok8QWFFm4KMFw22lbZNJHKfHOMSOOoCk5e5m8yfr84UV9NaJajmogWaLVnp2XFU9JQejlqg==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-drag": "^3.0.7", @@ -1203,7 +1417,6 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", - "dev": true, "license": "MIT", "peer": true, "bin": { @@ -1213,11 +1426,20 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/aria-query": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1233,11 +1455,17 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1253,6 +1481,20 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1300,15 +1542,23 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/codegraph-ui": { - "resolved": "ui", - "link": true + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } }, "node_modules/commander": { "version": "14.0.3", @@ -1319,11 +1569,31 @@ "node": ">=20" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -1333,7 +1603,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -1343,7 +1612,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -1357,7 +1625,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=12" @@ -1367,7 +1634,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "dev": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3" @@ -1380,7 +1646,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "dev": true, "license": "ISC", "peer": true, "engines": { @@ -1391,7 +1656,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -1401,7 +1665,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "dev": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3", @@ -1421,7 +1684,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -1434,6 +1696,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1452,6 +1728,20 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dedent-js": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz", + "integrity": "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1472,13 +1762,70 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/devalue": { "version": "5.9.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.1.tgz", "integrity": "sha512-+17vil3EVQRzvtDJSFuTWEb8XJRvXqAiV3qZyQWD398QeXUa6CxsUyMdD1fxzEhUrd4FojitFz7lhIHBTlV4fw==", - "dev": true, "license": "MIT" }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1486,6 +1833,35 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1529,14 +1905,12 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, "license": "MIT" }, "node_modules/esrap": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.6.tgz", "integrity": "sha512-yc0OC12UjPqLoc+fe+v5GNs4TOjAigUw3sTikfC+xeBPGUw7gDRz3DtYaqEhxyMVJojcSWJw7jT0QWR+CbuE/A==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -1612,6 +1986,23 @@ } } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1627,6 +2018,164 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -1636,27 +2185,84 @@ "node": ">= 4" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" } }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "license": "MIT" }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, "license": "MIT" }, "node_modules/loupe": { @@ -1666,16 +2272,55 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -1712,6 +2357,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -1726,6 +2378,19 @@ "node": ">=12.20.0" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -1792,6 +2457,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1851,6 +2526,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -1864,6 +2546,46 @@ "node": ">=6" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1905,7 +2627,6 @@ "version": "5.56.10", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.10.tgz", "integrity": "sha512-Lcxbj8I/KAbpY+VjtY4ENQBV0dDCipfGAhqb51XQZ67CIQqXgsv/8dPkbILaj4Fb6/b6JAEM/PIVbILXgDQy2g==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -1955,6 +2676,28 @@ "typescript": "^5.0.0 || ^6.0.0" } }, + "node_modules/svelte2tsx": { + "version": "0.7.61", + "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.61.tgz", + "integrity": "sha512-EpQ/+UHITBULeUojx/LLD3uTYasZXcX1BrqlrzfLUnT9vdUhaOWK59a3ryWcFU8L0sY1SP+gRhJ2Beiis98+qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dedent-js": "^1.0.1", + "scule": "^1.3.0" + }, + "peerDependencies": { + "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", + "typescript": "^4.9.4 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2016,6 +2759,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tree-sitter-wasms": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/tree-sitter-wasms/-/tree-sitter-wasms-0.1.13.tgz", @@ -2217,6 +3006,19 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-tree-sitter": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz", @@ -2231,6 +3033,54 @@ } } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2248,26 +3098,70 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, "license": "MIT" }, "ui": { - "name": "codegraph-ui", - "version": "0.0.0", + "name": "@colbymchenry/codegraph-ui", + "version": "1.6.0", "license": "MIT", + "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", - "@xyflow/svelte": "^1.6.5", "svelte": "^5.56.10", "svelte-check": "^4.7.6", "typescript": "^5.0.0", "vite": "^7.3.6" + }, + "peerDependencies": { + "svelte": "^5.25.0" } }, "ui/node_modules/@esbuild/aix-ppc64": { diff --git a/package.json b/package.json index 5bb2d81..d5a7424 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "scripts": { "build": "tsc && npm run copy-assets && npm run build:ui && node -e \"require('fs').chmodSync('dist/bin/codegraph.js', 0o755)\"", "build:ui": "npm run build --workspace ui && node scripts/check-ui-build.mjs", + "build:lib": "npm run build:lib --workspace ui", "preuninstall": "node dist/bin/uninstall.js", "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"", "dev": "tsc --watch", @@ -53,9 +54,11 @@ "web-tree-sitter": "^0.25.3" }, "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.4", "@types/better-sqlite3": "^7.6.0", "@types/node": "^20.19.30", "@types/picomatch": "^4.0.2", + "jsdom": "^25.0.1", "typescript": "^5.0.0", "vitest": "^2.1.9" }, diff --git a/scripts/check-ui-package.mjs b/scripts/check-ui-package.mjs new file mode 100644 index 0000000..477b712 --- /dev/null +++ b/scripts/check-ui-package.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node +/** + * Finish and verify the `@colbymchenry/codegraph-ui` build (task CG-61). + * + * `svelte-package` compiles the whole of `ui/src`, which is the right input — + * the components a host imports and the ones `codegraph ui` renders are the + * same files, and splitting them into two trees is how the two screens start + * to drift. But it means the emitted `dist/` also carries the standalone app's + * shell, and one of those files is a hazard rather than dead weight: + * `lib/router.svelte.js` attaches `hashchange`/`popstate` listeners at module + * scope. A host must never inherit a hash router just by rendering a Symbol + * view. So this script does three jobs, in order: + * + * 1. PRUNE the app-only files from the package. + * 2. RESOLVE the extensionless relative specifiers `svelte-package` leaves + * behind, so the package works under Node's own ESM resolution and under + * a consumer on `moduleResolution: node16`, not only inside a bundler. + * 3. ASSERT the result: the entry, the theme, every path in `exports`, the + * five named components, and — the one that matters most — that nothing + * outside `lib/adapter.js` talks to the network. The whole point of the + * package is that a host's own adapter is the only way data arrives; a + * stray `fetch` anywhere else is a screen that ignores it. + * + * Run by `npm run build:lib -w ui`. Exits non-zero on any failure. + */ + +import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const UI = fileURLToPath(new URL('../ui', import.meta.url)); +const DIST = join(UI, 'dist'); + +/** + * The standalone viewer's shell — everything that is only reachable from + * `main.ts`. Listed by hand rather than derived, because getting it wrong in + * the derived direction (pruning something a component needs) is silent until + * a host imports it. + */ +const APP_ONLY = [ + 'main.js', + 'main.d.ts', + 'App.svelte', + 'App.svelte.d.ts', + 'app.css', + 'components/TopBar.svelte', + 'components/TopBar.svelte.d.ts', + 'lib/router.svelte.js', + 'lib/router.svelte.d.ts', +]; + +/** Extensions that already resolve; anything else is rewritten to `.js`. */ +const RESOLVES = ['.js', '.mjs', '.cjs', '.json', '.css', '.svg', '.png']; + +const fail = (message) => { + console.error(`[check-ui-package] ${message}`); + process.exitCode = 1; +}; + +if (!existsSync(DIST)) { + fail(`no ${relative(UI, DIST)} — run \`npm run build:lib -w ui\``); + process.exit(1); +} + +/* ------------------------------------------------------------------ 1. prune */ + +for (const entry of APP_ONLY) { + const path = join(DIST, entry); + if (existsSync(path)) rmSync(path, { recursive: true }); +} + +/* ------------------------------------------------------------------ walk it */ + +function* files(dir) { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) yield* files(path); + else yield path; + } +} + +const all = [...files(DIST)]; + +/* ---------------------------------------------------------------- 2. resolve */ + +/** + * `from './lib/adapter'` -> `from './lib/adapter.js'`, and + * `from './lib/trail.svelte'` -> `from './lib/trail.svelte.js'` (the emitted + * file for a `.svelte.ts` rune module). + * + * Driven by the filesystem rather than by the extension alone: `.svelte` is a + * real file for a component and a compiled `.js` for a rune module, and only + * looking is right for both. + */ +function resolveSpecifiers(source, fromFile) { + return source.replace( + /(\bfrom\s*|\bimport\s*\(\s*)(['"])(\.[^'"]*)\2/g, + (match, head, quote, spec) => { + if (RESOLVES.some((ext) => spec.endsWith(ext))) return match; + const target = resolve(dirname(fromFile), spec); + if (existsSync(target) && statSync(target).isFile()) return match; + if (!existsSync(`${target}.js`)) return match; + return `${head}${quote}${spec}.js${quote}`; + } + ); +} + +let rewritten = 0; +for (const path of all) { + if (!/\.(js|d\.ts|svelte)$/.test(path)) continue; + const before = readFileSync(path, 'utf8'); + const after = resolveSpecifiers(before, path); + if (after !== before) { + writeFileSync(path, after); + rewritten += 1; + } +} + +/* ----------------------------------------------------------------- 3. assert */ + +const manifest = JSON.parse(readFileSync(join(UI, 'package.json'), 'utf8')); + +// Every path the exports map promises has to be there. A missing one is a +// package that installs cleanly and then fails at the consumer's first import. +for (const [name, entry] of Object.entries(manifest.exports ?? {})) { + const targets = typeof entry === 'string' ? [entry] : Object.values(entry); + for (const target of targets) { + if (!target.startsWith('./')) continue; + if (!existsSync(join(UI, target))) fail(`exports["${name}"] -> ${target} is missing`); + } +} + +// The five components the task names, plus the two seams they are useless +// without. Checked in the emitted JS, so a rename in index.ts that misses a +// component fails here rather than in the Pro app. +const entry = existsSync(join(DIST, 'index.js')) + ? readFileSync(join(DIST, 'index.js'), 'utf8') + : ''; +for (const name of [ + 'SymbolView', + 'FlowStrip', + 'ArchitectureMap', + 'TrailBar', + 'SearchPalette', + 'CodegraphUi', + 'setGraphAdapter', + 'createHttpAdapter', + 'setNavigationDriver', +]) { + if (!new RegExp(`\\b${name}\\b`).test(entry)) fail(`dist/index.js does not export ${name}`); +} + +// Nothing the app dragged in survives. A component still importing one of the +// pruned modules would resolve to nothing in a host. +for (const path of all) { + if (!existsSync(path)) continue; + const text = readFileSync(path, 'utf8'); + for (const pruned of ['router.svelte', 'TopBar.svelte', 'app.css']) { + const importing = new RegExp(`(from|import\\()\\s*['"][^'"]*${pruned}`); + if (importing.test(text)) { + fail(`${relative(DIST, path)} still imports ${pruned}, which is app-only`); + } + } +} + +// The data seam. `lib/adapter.js` is the ONE place that may reach the network; +// anywhere else means a screen that ignores the host's adapter. +for (const path of all) { + if (!existsSync(path) || !path.endsWith('.js')) continue; + if (path.endsWith(join('lib', 'adapter.js'))) continue; + const text = readFileSync(path, 'utf8') + // Comments talk about `fetch` and `EventSource` on purpose; only code counts. + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|\s)\/\/[^\n]*/g, ''); + if (/\bnew EventSource\b|\bfetch\s*\(/.test(text)) { + fail(`${relative(DIST, path)} reaches the network directly — it must go through the adapter`); + } +} + +if (process.exitCode) { + console.error('[check-ui-package] FAILED'); + process.exit(1); +} + +const count = [...files(DIST)].length; +console.log( + `[check-ui-package] ok — ${count} files, ${rewritten} rewritten, ` + + `${APP_ONLY.length} app-only pruned (v${manifest.version})` +); diff --git a/scripts/pack-npm.sh b/scripts/pack-npm.sh index 58173b8..d2e1768 100755 --- a/scripts/pack-npm.sh +++ b/scripts/pack-npm.sh @@ -125,3 +125,29 @@ VERSION="$VERSION" SCOPE="$SCOPE" TARGETS="${targets[*]}" \ echo "[pack-npm] ${SCOPE}/codegraph@${VERSION} (${#targets[@]} platform packages in optionalDependencies)" echo "[pack-npm] output: $NPM" + +# --------------------------------------------------------------------------- +# @colbymchenry/codegraph-ui — the viewer's components as a Svelte library. +# +# Staged into release/npm-ui/, NOT release/npm/: the workflow publishes +# `release/npm/codegraph-*` by glob, and a directory named codegraph-ui in +# there would be swept into that loop the moment it existed. +# +# OFF by default. The package is prepared, versioned with the engine and +# tested (CG-61), but publishing it is a decision the maintainer has not +# made — and `ui/package.json` still carries `"private": true`, which is what +# actually stops an accidental `npm publish`. Set CODEGRAPH_PACK_UI=1 to build +# the tarball; publishing it additionally means removing that flag. +# --------------------------------------------------------------------------- +if [ "${CODEGRAPH_PACK_UI:-0}" = "1" ]; then + UIREL="$REL/npm-ui" + rm -rf "$UIREL" + mkdir -p "$UIREL" + ( cd "$ROOT" && npm run build:lib --workspace ui ) + # `npm pack` honours "files" and works on a private package; `npm publish` + # does not, which is exactly the guard we want to keep for now. + ( cd "$ROOT/ui" && npm pack --pack-destination "$UIREL" >/dev/null ) + echo "[pack-npm] ${SCOPE}/codegraph-ui@${VERSION} packed (not published) -> $UIREL" +else + echo "[pack-npm] skipping ${SCOPE}/codegraph-ui (set CODEGRAPH_PACK_UI=1 to pack it)" +fi diff --git a/scripts/sync-ui-version.mjs b/scripts/sync-ui-version.mjs new file mode 100644 index 0000000..8e0fa0e --- /dev/null +++ b/scripts/sync-ui-version.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * Keep `@colbymchenry/codegraph-ui` on the engine's version number. + * + * The component package draws its screens from the engine's own JSON API, and + * that API is versioned with the binary that serves it — a payload field can + * appear or change shape in any engine release. So the two ship as one number: + * `@colbymchenry/codegraph-ui@1.6.0` is the reader for `codegraph@1.6.0`, and a + * host can pin them together without a compatibility table. + * + * This SYNCS rather than asserts, deliberately. The documented release flow is + * "edit the version in package.json, run the Release workflow" — often as a + * single-file edit in the GitHub web UI — and a check that failed the build + * because a second file had not been edited would turn that into a two-step + * dance for no gain. The same reasoning the workflow's package-lock sync step + * already runs on. + * + * Idempotent: a re-run with the versions already equal writes nothing. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../package.json', import.meta.url)); +const ui = fileURLToPath(new URL('../ui/package.json', import.meta.url)); + +const engineVersion = JSON.parse(readFileSync(root, 'utf8')).version; +const raw = readFileSync(ui, 'utf8'); +const manifest = JSON.parse(raw); + +if (manifest.version === engineVersion) { + console.log(`[sync-ui-version] ui already at ${engineVersion}`); + process.exit(0); +} + +// A targeted replacement, not a re-serialise: rewriting the whole file would +// reformat a manifest a human maintains and bury the one-line change in noise. +const next = raw.replace( + /("version"\s*:\s*)"[^"]*"/, + (_match, prefix) => `${prefix}"${engineVersion}"` +); +if (next === raw) { + console.error('[sync-ui-version] could not find a "version" field in ui/package.json'); + process.exit(1); +} +writeFileSync(ui, next); +console.log(`[sync-ui-version] ui ${manifest.version} -> ${engineVersion}`); diff --git a/ui/README.md b/ui/README.md index 19859fa..420bc5c 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1,9 +1,20 @@ -# ui/ — the `codegraph ui` viewer +# ui/ — the `codegraph ui` viewer, and `@colbymchenry/codegraph-ui` -The browser reader for an indexed project: Svelte 5 + Vite, built as static -files and served by the CLI over loopback. An npm workspace of the engine, so -`npm ci` at the repo root installs its toolchain; nothing here is a runtime -dependency of the engine and nothing here is published to npm on its own. +One source tree, two builds. + +- **The app** — the browser reader for an indexed project: Svelte 5 + Vite, + built as static files into `../dist/viewer` and served by the CLI over + loopback. +- **The library** — the same components, packaged with `svelte-package` into + `dist/` as `@colbymchenry/codegraph-ui`, so a host (CodeGraph Pro) renders + the Symbol view, the Flow strip and the Map over its **own** graph reads. + +They are one tree on purpose. A forked component is a second answer to the same +question about the same graph, and sooner or later the two get quoted against +each other in a review. + +An npm workspace of the engine, so `npm ci` at the repo root installs the +toolchain for both. Design spec (every token, size and measurement): `../docs/design/codegraph-ui-design-spec.md`. @@ -12,11 +23,16 @@ Design spec (every token, size and measurement): ```bash npm run build # from the repo root: tsc -> copy-assets -> this app -npm run build:ui # just this app, plus the dist assertion +npm run build:ui # just the app, plus the dist assertion +npm run build:lib # the LIBRARY: svelte-package -> ui/dist, plus its checks npm run dev -w ui # Vite dev server on 127.0.0.1:5174 npm run check -w ui # svelte-check ``` +`build:lib` is deliberately not part of `npm run build`: the CLI does not need +it, and a release that fails because a component library would not compile is a +release that failed for the wrong reason. + `npm run build` emits **`dist/viewer/`** (`index.html` + hashed assets). `scripts/check-ui-build.mjs` then asserts the tree is complete, so a broken UI build fails the release instead of shipping a CLI that serves a 404. The same @@ -32,12 +48,113 @@ and would also leave the static server handing out compiled engine internals. `check-ui-build.mjs` re-asserts the compiled engine is intact after every UI build so that mistake cannot land twice. +## `@colbymchenry/codegraph-ui` + +```svelte + + + + + +``` + +Exports: `SymbolView`, `FlowStrip`, `ArchitectureMap`, `FileView`, +`FileSourceView`, `EntryPointsView`, `TrailBar`, `SearchPalette`, +`PalettePanel`, `PaletteRows`, `DriftBanner`, `KindGlyph`, `ExportButtons`, +`CodegraphUi` — plus every pure model function the screens are built from +(`buildCalleeRail`, `buildFlowLayout`, `buildMapLayout`, `tokensByLine`, …) and +the `Wire*` types an adapter answers in. + +### 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; + 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 eleven 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. + +### 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 design tokens (light/dark), reset, shell grid + 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 diff --git a/ui/package.json b/ui/package.json index b604644..2105538 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,21 +1,57 @@ { - "name": "codegraph-ui", + "name": "@colbymchenry/codegraph-ui", "private": true, - "version": "0.0.0", + "version": "1.6.0", "type": "module", - "description": "Browser viewer for an indexed CodeGraph project (served by `codegraph ui`).", + "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", - "@xyflow/svelte": "^1.6.5", "svelte": "^5.56.10", "svelte-check": "^4.7.6", "typescript": "^5.0.0", diff --git a/ui/src/app.css b/ui/src/app.css index 14561af..d23c7ac 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -1,107 +1,16 @@ /* ===================================================================== - codegraph ui — design tokens + global primitives + codegraph ui — the app's global primitives - The engine's paper/ink editorial system (site/src/styles/theme.css), - as specified in docs/design/codegraph-ui-design-spec.md §2: flat, - hairline rules, square corners everywhere, no shadows, no gradients, - sentence case, one oxblood accent, one amber (the "untested" badge). + 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/PalettePanel.svelte b/ui/src/components/PalettePanel.svelte new file mode 100644 index 0000000..2d23aab --- /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/SearchPalette.svelte b/ui/src/components/SearchPalette.svelte index 2d23aab..61fa459 100644 --- a/ui/src/components/SearchPalette.svelte +++ b/ui/src/components/SearchPalette.svelte @@ -1,82 +1,182 @@ -
- {#if view.hint} -

{view.hint}

- {/if} + - palette.select(index)} + diff --git a/ui/src/components/TopBar.svelte b/ui/src/components/TopBar.svelte index e24cbd6..c8d241c 100644 --- a/ui/src/components/TopBar.svelte +++ b/ui/src/components/TopBar.svelte @@ -1,18 +1,7 @@ - -
@@ -172,28 +71,7 @@ Flow - +
{#if liveNote}{liveNote.text}{/if} @@ -261,30 +139,6 @@ border-bottom-color: var(--ink); } - .search { - position: relative; - max-width: 720px; - } - - #q { - width: 100%; - height: 30px; - padding: 0 10px; - border: 1px solid var(--rule-soft); - background: var(--paper-2); - color: var(--ink); - font: 13px var(--sans); - } - - #q:focus { - border-color: var(--ink); - outline: none; - } - - #q::placeholder { - color: var(--ink-3); - } - .project { color: var(--ink-2); font-size: 12px; diff --git a/ui/src/components/TrailBar.svelte b/ui/src/components/TrailBar.svelte index 0e2c5ef..d03ce4a 100644 --- a/ui/src/components/TrailBar.svelte +++ b/ui/src/components/TrailBar.svelte @@ -1,7 +1,7 @@ + * + * + * + * + * ``` + * + * 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 { + EntryPointsRequest, + FlowRequest, + GraphAdapter, + HttpAdapterOptions, + LiveHandlers, + MapRequest, + RoutesRequest, + SearchRequest, + SourceRequest, +} from './lib/adapter'; + +export { + back, + entryHref, + fileHref, + flowHref, + getNavigationDriver, + hashNavigation, + mapHref, + navigate, + setNavigationDriver, + symbolHref, +} from './lib/navigation'; +export type { + 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'; + +/* -------------------------------------------------------- the furniture -- */ + +/** The path walked, with its arrows and its "read as flow". */ +export { default as TrailBar } from './components/TrailBar.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'; + +/* ------------------------------------------------------------- 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 { 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 { 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 { 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 0000000..9f22cb9 --- /dev/null +++ b/ui/src/lib/adapter.ts @@ -0,0 +1,360 @@ +/** + * 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 read-only JSON API over loopback. A host that already holds the graph — + * CodeGraph Pro, which opens the index in-process — implements the same eleven + * 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 { + WireEntryPoints, + WireFilePayload, + WireFileCodePayload, + WireFlowPayload, + WireMapPayload, + WireNodeRefs, + WireRoutes, + WireSearch, + WireSource, + WireStats, + WireSymbolPayload, +} 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; +} + +/* ----------------------------------------------------------------- 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 other four 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) and `entryPoints` (where a reader starts). + */ +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; + /** + * 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 default adapter: the read-only 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 getJson(path: string, signal?: AbortSignal): Promise { + let response: Response; + try { + response = await doFetch(`${base}${path}`, { + signal, + headers: { accept: 'application/json' }, + }); + } 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; + } + + 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); + }, + + 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 index 5cfea89..53b8d2b 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -1,619 +1,51 @@ /** - * The viewer's side of the read-only JSON API (`src/ui-server/api/`, CG-42). + * The screens' side of the graph API. * - * The types below mirror the server's wire shapes rather than re-deriving - * them: the API is versioned with the binary that serves it, so a field the - * server stopped sending should break the type-check here, not surface as - * `undefined` in a rail three screens later. + * 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. * - * One rule for every call: the API answers JSON for *every* outcome, including - * refusals. So a non-2xx still has a body worth reading, and `ApiFailure` - * carries the server's own sentence instead of "Failed to fetch". + * 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 type { WireHighlight } from './highlight'; +import { getGraphAdapter } from './adapter'; +import type { + WireEntryPoints, + WireFilePayload, + WireFileCodePayload, + WireFlowPayload, + WireMapPayload, + WireNodeRefs, + WireRoutes, + WireSearch, + WireSource, + WireStats, + WireSymbolPayload, +} from './wire'; -/* ---------------------------------------------------------------- shapes -- */ - -export type NodeKind = string; -export type EdgeKind = string; - -export interface WireNodeRef { - id: string; - kind: NodeKind; - name: string; - qualifiedName: string; - /** Project-relative, forward slashes on every platform. */ - file: string; - line: number; - endLine: number; - language: string; - signature?: string; - exported?: boolean; - /** Lives in a file that looks like test or fixture code. */ - test: boolean; -} - -export interface WireNodeDetail extends WireNodeRef { - startColumn: number; - endColumn: number; - docstring?: string; - visibility?: string; - async?: boolean; - static?: boolean; - abstract?: boolean; - decorators?: string[]; - typeParameters?: string[]; - returnType?: string; - lines: number; -} - -export interface WireMember extends WireNodeRef { - parentId: string; - /** 1 = a direct member; 2 = a member of a member (a method inside a file's class). */ - depth: number; - fanIn: number; - fanOut: number; -} - -export interface WireEdge { - kind: EdgeKind; - line?: number; - col?: number; - confidence?: number; - resolvedBy?: string; - provenance?: string; - synthesizedBy?: string; - via?: string; - registeredAt?: string; - valueRef?: boolean; -} - -/** Every edge between the focal symbol and ONE other symbol, as a single row. */ -export interface WireRelation { - node: WireNodeRef; - edgeKinds: EdgeKind[]; - edges: WireEdge[]; - edgeCount: number; - /** Distinct call-site lines, ascending — what the gutter ports anchor to. */ - lines: number[]; - confidence: number | null; - uncertain: boolean; - synthesized: boolean; - fanIn?: number; - hub?: boolean; -} - -export interface WireList { - total: number; - shown: number; - truncated: boolean; - items: T[]; -} - -export interface WireTestSummary { - reached: boolean; - hops: number | null; - fileCount: number; - files: string[]; - /** False weakens the claim to "no test calls this directly" — see the server. */ - exhaustive: boolean; - hopsSearched: number; -} - -export interface WireOutsideIndex { - total: number; - byKind: Record; - samples: Array<{ name: string; kind: string; line?: number; col?: number }>; -} - -export interface WireBlastSummary { - direct: number; - withinHops: number; - hops: number; - files: number; - testFiles: number; - routes: number; - topFiles: Array<{ file: string; symbols: number; test: boolean }>; -} - -export interface WireSymbolPayload { - node: WireNodeDetail; - /** Outermost first: file, then module/class, then the symbol's own parent. */ - ancestors: WireNodeRef[]; - members: WireList; - incoming: WireList; - outgoing: WireList; - typesUsed: WireRelation[]; - counts: { - callers: number; - callees: number; - typesUsed: number; - fanIn: number; - fanOut: number; - members: number; - hub: boolean; - }; - tests: WireTestSummary; - outsideIndex: WireOutsideIndex; - blast: WireBlastSummary | null; - /** The file changed on disk since the index — line ranges may be shifted. */ - drift: boolean; -} - -export interface WireSource { - file: string; - language: string; - drift: boolean; - /** - * Which numbering `lines` belong to. `'indexed'` — the file matches the - * index. `'current'` — it drifted and we asked for the bytes anyway - * (`ondrift: 'current'`), so nothing the graph holds about this file lines up - * with them. `'none'` — it drifted and no slice came back. - */ - showing: 'indexed' | 'current' | 'none'; - contentHash: string; - indexedAt: number; - generated: boolean; - totalLines: number | null; - from?: number; - to?: number; - /** Absent when the file drifted and `ondrift` was left at its default. */ - lines?: string[]; - truncated?: boolean; - reason?: string; - /** - * The same lines, classified by the server's tree-sitter parse — one entry - * per line, each a list of `[classId, text]` pairs indexed into `classes`. - * Absent whenever `lines` is, and `engine: 'plain'` whenever no grammar - * covers the file. See `lib/highlight.ts`. - */ - highlight?: WireHighlight; -} - -/* ------------------------------------------------------------- file view -- */ - -/** A row in the file outline — a symbol, its nesting and its edge counts. */ -export interface WireOutlineEntry extends WireNodeRef { - /** Containing symbol within this file, or null for a top-level one. */ - parentId: string | null; - /** Nesting depth from the top level of the file, starting at 0. */ - depth: number; - fanIn: number; - fanOut: number; -} - -/** One file at the far end of an import rail, with the symbols the edges name. */ -export interface WireImportRow { - file: string; - test: boolean; - symbols: Array<{ id: string; name: string; kind: string; line: number }>; - symbolCount: number; -} - -export interface WireFilePayload { - file: { - path: string; - language: string; - size: number; - modifiedAt: number; - indexedAt: number; - contentHash: string; - nodeCount: number; - generated: boolean; - test: boolean; - errors: string[]; - /** The file node's own id, so the viewer can open the file AS a symbol. */ - id: string | null; - }; - /** Calls made outside every definition — module-level code. */ - topLevel: { calls: number }; - /** The file changed on disk since it was indexed; the outline's lines shifted. */ - drift: boolean; - outline: WireList; - /** `imports` edges only — a subset of `dependencies`, with symbol names. */ - imports: WireList; - importedBy: WireList; - /** Import statements that resolved to nothing indexed: packages, builtins. */ - unresolvedImports: Array<{ name: string; line: number }>; - /** Every file this one reaches by any cross-file edge — `getFileDependencies`. */ - dependencies: string[]; - /** Every file that reaches into this one — `getFileDependents`. */ - dependents: string[]; -} - -/* ------------------------------------------------ whole-file source view -- */ - -/** A reference the resolver never landed: a gutter port with no destination. */ -export interface WireFileOutsideRef { - line: number; - col: number; - name: string; - kind: string; -} - -/** Every edge from ONE symbol in a file to ONE symbol anywhere. */ -export interface WireFileCall { - /** The symbol making the calls — the file node itself for top-level code. */ - ownerId: string; - ownerLine: number; - relation: WireRelation; -} - -export interface WireFileCodePayload { - file: { - path: string; - language: string; - size: number; - indexedAt: number; - contentHash: string; - generated: boolean; - test: boolean; - errors: string[]; - id: string | null; - /** Lines on disk now — the height of the scrolling document. */ - totalLines: number | null; - }; - drift: boolean; - reason?: string; - outline: WireList; - calls: WireList; - outside: WireList; - /** Calls landing on a definition in this same file — the arc diagram's total. */ - intraFileCalls: number; - timing: { elapsedMs: number }; -} - -export interface WireBlastScale { - maxDirect: number; - maxWithinHops: number; - hops: number; - sampled: number; - estimated: boolean; -} - -/* ------------------------------------------------------- search palette -- */ - -/** How a result's text matched the query — the server's primary sort key. */ -export type MatchKind = 'exact' | 'prefix' | 'substring' | 'qualified' | 'file' | 'related'; - -export interface WireSearchResult extends WireNodeRef { - matchKind: MatchKind; -} - -export interface WireSearchGroup { - kind: NodeKind; - count: number; - items: WireSearchResult[]; -} - -export interface WireSearch { - query: string; - /** The free-text part, with any `kind:` / `lang:` / `path:` filters removed. */ - text: string; - filters: { kinds: string[]; languages: string[]; paths: string[]; names: string[] }; - results: WireList; - /** Kind buckets in ranked order — flattening them reproduces the ranking. */ - groups: WireSearchGroup[]; -} - -export interface WireNodeRefs { - items: WireNodeRef[]; - /** Ids that name nothing in this index — a stale link, not an error. */ - missing: string[]; -} - -/* ---------------------------------------------------------- entry points -- */ - -export interface WireEntryRoute { - /** The route node's name, verbatim: "POST /v1/users/{id}". */ - url: string; - /** The verb, when the name leads with one. Null for a file-routed page. */ - method: string | null; - /** The URL without the verb — the same string as `url` when there is none. */ - path: string; - handler: string; - handlerKind: string; - /** Where the request is SERVED. */ - file: string; - line: number; - handlerId: string | null; - /** Where the URL is REGISTERED — the router file, which is how routes group. */ - routeFile: string; - routeLine: number; - routeId: string; -} - -export interface WireEntryFile extends WireNodeRef { - /** Calls and instantiations made at the top level of the file. */ - calls: number; - /** Distinct other files this one's symbols reach. */ - reaches: number; - /** Other files reaching into this one. Zero means nothing imports it. */ - dependents: number; -} - -export interface WireEntryHub extends WireNodeRef { - dependents: number; -} - -export interface WireEntryTest extends WireNodeRef { - /** Distinct other files this test reaches — what it exercises. */ - reaches: number; - /** References behind that reach. */ - refs: number; -} - -export interface WireEntryPoints { - /** Frameworks the resolver detected — named in the Routes header. */ - frameworks: string[]; - routes: { - routed: boolean; - /** Every `route` node in the graph, resolved handler or not. */ - routeCount: number; - items: WireList; - }; - /** `total` is a floor on `files` and `hubs`; on `tests` it is exact. */ - files: WireList; - tests: WireList; - hubs: WireList; - index: { lastIndexedAt: number | null; files: number }; - timing: { elapsedMs: number; cached: boolean }; -} - -export interface WireStats { - project: { root: string; name: string }; - index: { - state: string | null; - lastIndexedAt: number | null; - stale: boolean; - version: string | null; - extractionVersion: number | null; - backend: string; - journalMode: string; - pendingReferences: number; - generatedFiles: number; - watching: boolean; - watcherDegraded: boolean; - }; - graph: { - nodes: number; - edges: number; - files: number; - nodesByKind: Record; - edgesByKind: Record; - filesByLanguage: Record; - dbSizeBytes: number; - walSizeBytes: number; - }; - frameworks: string[]; - thresholds: { hub: number; uncertainBelow: number }; - blastScale: WireBlastScale; -} - -/* ----------------------------------------------------------------- fetch -- */ - -/** An error the server described. `guidance` is its "what to do instead" line. */ -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; -} - -async function getJson(path: string, signal?: AbortSignal): Promise { - let response: Response; - try { - response = await fetch(path, { signal, headers: { accept: 'application/json' } }); - } catch (cause) { - if (signal?.aborted) throw cause; - // The one failure the server cannot describe, because it never heard the - // request: `codegraph ui` was stopped while the tab stayed open. - 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; -} - -/* ------------------------------------------------------------- flow strip -- */ - -export interface WireFlowEdge extends WireEdge { - /** The link's label: "calls", "via callback · registered at file:line". */ - label: string; - /** This hop reads callee → caller — the reader stepped UP into it. */ - upward: boolean; - /** Confidence below 0.6: the link is dashed `2 3`. */ - uncertain: boolean; - /** A synthesized dynamic-dispatch bridge: dashed `5 3`. */ - synthesized: boolean; -} - -export interface WireFlowSource { - file: string; - language: string; - from: number; - to: number; - /** Absent when `drift` — a mis-sliced window is worse than an empty card. */ - lines?: string[]; - highlight?: WireHighlight; - drift: boolean; - reason?: string; -} - -/** The call site a card is opened at — the identifier drawn as an accent link. */ -export interface WireFlowCallRef { - line: number; - col: number | null; - name: string; - targetId: string; - /** The link points back at the previous card, not on to the next one. */ - backwards: boolean; -} - -export interface WireFlowHop { - node: WireNodeRef; - /** The edge from the PREVIOUS hop into this one; null on the first. */ - edge: WireFlowEdge | null; - callRef: WireFlowCallRef | null; - source: WireFlowSource | null; -} - -/** One plausible runtime target of a keyed dispatch — a clickable cap row. */ -export interface WireBoundaryCandidate { - node: WireNodeRef; - display: string; - named: boolean; -} - -/** A dynamic-dispatch site: the form, the key when it is visible, the targets. */ -export interface WireBoundarySite { - form: string; - label: string; - snippet: string; - line: number; - key: string | null; - keyIsType: boolean; - moreSites: number; - candidates: WireBoundaryCandidate[]; - candidateNote: string | null; -} - -export interface WireFlowContinuation { - node: WireNodeRef; - line: number | null; - confidence: number | null; -} - -/** Where the graph stops — the strip's end cap (design spec §3.5). */ -export interface WireFlowBoundary { - node: WireNodeRef; - sites: WireBoundarySite[]; - uncertain: WireList; - further: WireList; - missed: WireNodeRef[]; -} - -export interface WireFlow { - id: string; - /** "execute → rowToFileRecord", for the header's flow picker. */ - label: string; - hops: WireFlowHop[]; - /** Null on a flow that reaches everything it was asked about. */ - boundary: WireFlowBoundary | null; - /** One card at the dispatch site, not a path: the answer ran out here. */ - partial: boolean; -} - -export interface WireFlowAmbiguity { - token: string; - chosen: WireNodeRef | null; - others: WireNodeRef[]; -} - -export interface WireFlowPayload { - query: { - kind: 'directed' | 'symbols' | 'trail'; - from: string | null; - to: string | null; - symbols: string[]; - }; - flows: WireFlow[]; - ambiguous: WireFlowAmbiguity[]; - /** Tokens that named nothing in this index. */ - unresolved: string[]; - /** Why there is no flow, when there is none. */ - reason: string | null; - index: { lastIndexedAt: number | null; edges: number; files: number }; - timing: { elapsedMs: number }; -} - -/* -------------------------------------------------------------- the map -- */ - -export interface WireMapModule { - /** Directory path, the `(root files)` bucket, or a façade file's own path. */ - id: string; - label: string; - files: number; - symbols: number; - languages: Array<{ language: string; files: number }>; - /** More than half its files are tests. */ - test: boolean; - /** A single file kept out of the root bucket because it is the façade. */ - facade: boolean; - /** Its files, capped — the side panel's list when the module is selected. */ - fileList: { total: number; shown: number; truncated: boolean; items: string[] }; -} - -export interface WireMapLink { - source: string; - target: string; - /** Every confident cross-module edge behind this link. */ - count: number; - /** - * The subset resolved through an import, a qualified name, an inheritance - * clause or a typed receiver — what the layering trusts. - */ - declared: number; - byKind: Array<{ kind: EdgeKind; count: number }>; - topPairs: Array<{ from: string; to: string; count: number; declared: number }>; -} - -export interface WireMapCycle { - size: number; - files: string[]; - modules: string[]; -} - -export interface WireMapPayload { - root: string; - depth: number; - roots: Array<{ root: string; label: string; files: number }>; - modules: WireMapModule[]; - links: WireMapLink[]; - cycles: { total: number; shown: number; truncated: boolean; items: WireMapCycle[] }; - excluded: { uncertainEdges: number; confidenceBelow: number }; - index: { lastIndexedAt: number | null; edges: number; files: number }; - timing: { elapsedMs: number; cached: boolean }; -} +export * from './wire'; +export { ApiFailure } from './adapter'; +export type { + GraphAdapter, + EntryPointsRequest, + FlowRequest, + HttpAdapterOptions, + LiveHandlers, + MapRequest, + RoutesRequest, + SearchRequest, + SourceRequest, +} from './adapter'; export function fetchStats(signal?: AbortSignal): Promise { - return getJson('api/stats', signal); + return getGraphAdapter().stats(signal); } export function fetchSymbol(id: string, signal?: AbortSignal): Promise { - // Ids carry ':' and '/' (`method:`, `file:src/mcp/tools.ts`); encode - // per segment so the path stays readable and still round-trips. - const encoded = id.split('/').map(encodeURIComponent).join('/'); - return getJson(`api/node/${encoded}`, signal); + return getGraphAdapter().node(id, signal); } export function fetchSearch( @@ -621,34 +53,31 @@ export function fetchSearch( opts: { limit?: number } = {}, signal?: AbortSignal ): Promise { - const params = new URLSearchParams({ q: query }); - if (opts.limit) params.set('limit', String(opts.limit)); - return getJson(`api/search?${params}`, signal); + 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 { - const params = new URLSearchParams(); - for (const id of ids) params.append('id', id); - return getJson(`api/nodes?${params}`, signal); + return getGraphAdapter().nodes(ids, signal); } export function fetchEntryPoints( opts: { limit?: number; routes?: number } = {}, signal?: AbortSignal ): Promise { - const params = new URLSearchParams(); - if (opts.limit) params.set('limit', String(opts.limit)); - if (opts.routes) params.set('routes', String(opts.routes)); - const query = params.toString(); - return getJson(`api/entrypoints${query ? `?${query}` : ''}`, signal); + return getGraphAdapter().entryPoints(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 { - // Paths carry '/'; encode per segment so `api/file/src/mcp/tools.ts` stays - // readable and a segment with a reserved character still round-trips. - const encoded = path.split('/').map(encodeURIComponent).join('/'); - return getJson(`api/file/${encoded}`, signal); + return getGraphAdapter().file(path, signal); } /** @@ -661,8 +90,7 @@ export function fetchFileCode( path: string, signal?: AbortSignal ): Promise { - const encoded = path.split('/').map(encodeURIComponent).join('/'); - return getJson(`api/filecode/${encoded}`, signal); + return getGraphAdapter().fileCode(path, signal); } /** @@ -683,29 +111,19 @@ export function fetchSource( signal?: AbortSignal, ondrift?: 'current' ): Promise { - const params = new URLSearchParams({ file, from: String(from) }); - // `to` is 1-based on the wire and absent means "to the end of the file" — - // sending 0 for that would be out of range, not a synonym. - if (to > 0) params.set('to', String(to)); - if (ondrift) params.set('ondrift', ondrift); - return getJson(`api/source?${params}`, signal); + 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 - * server pick the repository's source directory. + * adapter pick the repository's source directory. */ export function fetchMap( opts: { root?: string | null; depth?: number } = {}, signal?: AbortSignal ): Promise { - const params = new URLSearchParams(); - if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root); - if (opts.depth) params.set('depth', String(opts.depth)); - const query = params.toString(); - return getJson(`api/map${query ? `?${query}` : ''}`, signal); + return getGraphAdapter().map(opts, signal); } /** @@ -713,18 +131,11 @@ export function fetchMap( * * - `{ 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. Each one - * is its own parameter, because a node id can be a file path and a file path - * can contain a comma. + * - `{ trail }` — the hops the reader walked, as `` strings. */ export function fetchFlow( spec: { from?: string; to?: string; symbols?: string; trail?: readonly string[] }, signal?: AbortSignal ): Promise { - const params = new URLSearchParams(); - if (spec.from) params.set('from', spec.from); - if (spec.to) params.set('to', spec.to); - if (spec.symbols) params.set('symbols', spec.symbols); - for (const hop of spec.trail ?? []) params.append('hop', hop); - return getJson(`api/flow?${params}`, signal); + return getGraphAdapter().flow(spec, signal); } diff --git a/ui/src/lib/live.svelte.ts b/ui/src/lib/live.svelte.ts index 2946f65..a7f1a0b 100644 --- a/ui/src/lib/live.svelte.ts +++ b/ui/src/lib/live.svelte.ts @@ -16,13 +16,17 @@ * * ## Nothing polls, and nothing loops * - * `EventSource` is the transport, but its own reconnect is not: left alone it - * retries forever at a fixed interval, so a viewer left open against a stopped + * The transport is `GraphAdapter.events` — an `EventSource` on `/api/events` + * under `codegraph ui`, whatever a host already has under a host — but the + * reconnect is NOT the transport's. Left to itself an `EventSource` retries + * forever at a fixed interval, so a viewer left open against a stopped * `codegraph ui` becomes a request every three seconds until the tab is closed. * So each `error` closes the stream and schedules ONE reconnect on a backoff * that ends: after {@link MAX_ATTEMPTS} consecutive failures the connection * gives up and says so, and only a deliberate signal — the tab coming back to - * the foreground, or the window regaining focus — starts it again. + * the foreground, or the window regaining focus — starts it again. An adapter + * with no `events` at all leaves every counter at zero and nothing connects; + * a host can still move them by hand with `live.signal`. * * The same rule covers the server's own bad day: a `degraded` event means live * watching has stopped for good on that side. The client records it and shows @@ -31,6 +35,7 @@ */ import { untrack } from 'svelte'; +import { getGraphAdapter } from './adapter'; /* ----------------------------------------------------------- wire shapes -- */ @@ -58,6 +63,21 @@ export interface LiveChanged { at: number; } +/** + * What a host passes to `live.signal` — every field optional, because the + * counters are what the screens read and the detail is only there for the + * disk case, where "which files" decides whether a drift banner appears. + */ +export interface LiveSignalDetail { + files?: string[]; + total?: number; + truncated?: boolean; + /** The change could not be described file by file — assume any file is hit. */ + scan?: boolean; + index?: LiveIndexRevision; + at?: number; +} + export interface LiveIndexEvent { type: 'index'; index: LiveIndexRevision; @@ -86,10 +106,13 @@ let diskTick = $state(0); let lastIndex = $state(null); let lastChanged = $state(null); -let source: EventSource | null = null; +/** Closes the current subscription, or null when there is none open. */ +let close: (() => void) | null = null; let retry: ReturnType | null = null; let attempts = 0; let started = false; +/** The installed adapter has no live channel. Nothing to connect, ever. */ +let unsupported = false; /** * Ticks that arrived while the tab was in the background. @@ -138,71 +161,66 @@ function flushDeferred(): void { /* ------------------------------------------------------------ connection -- */ function open(): void { - if (source || typeof EventSource === 'undefined') return; + if (close !== null) return; if (retry !== null) { clearTimeout(retry); retry = null; } stopped = false; - const es = new EventSource('api/events'); - source = es; - - es.addEventListener('open', () => { - connected = true; - }); - - es.addEventListener('hello', (event) => { - const hello = parse(event); - if (!hello) return; - // A hello is the only proof the stream is really working: `open` fires on - // the response headers, and a server that answered and then died would - // otherwise reset the backoff it should have been paying. - attempts = 0; - connected = true; - watching = hello.watching; - degraded = hello.degraded; - }); - - es.addEventListener('changed', (event) => { - const changed = parse(event); - if (changed) bumpDisk(changed); - }); - - es.addEventListener('index', (event) => { - const moved = parse(event); - if (moved) bumpIndex(moved); - }); - - es.addEventListener('degraded', (event) => { - const note = parse<{ reason: string }>(event); - if (note) degraded = note.reason; - }); - - es.addEventListener('error', () => { - connected = false; - es.close(); - if (source === es) source = null; - attempts += 1; - if (attempts >= MAX_ATTEMPTS) { - // Out of attempts. Nothing on a timer from here — the tab coming back to - // the foreground is the only thing that tries again. - stopped = true; - return; - } - const delay = BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)] ?? 30_000; - retry = setTimeout(open, delay); - }); -} - -function parse(event: Event): T | null { - const data = (event as MessageEvent).data; - if (typeof data !== 'string') return null; - try { - return JSON.parse(data) as T; - } catch { - return null; + // The transport belongs to the adapter, not to this module: `codegraph ui` + // answers it with an EventSource on `/api/events`, and a host that already + // knows when its index moved answers it with whatever it already has. An + // adapter with no live channel simply omits `events` — and then nothing here + // ever runs, which is the correct behaviour and not a degraded one. + const subscribe = getGraphAdapter().events; + if (!subscribe) { + unsupported = true; + return; } + + close = subscribe({ + hello(event) { + const hello = event as LiveHello | null; + if (!hello) return; + // A hello is the only proof the stream is really working: a connection + // opens on the response headers, and a server that answered and then + // died would otherwise reset the backoff it should have been paying. + attempts = 0; + connected = true; + watching = hello.watching; + degraded = hello.degraded; + }, + changed(event) { + const changed = event as LiveChanged | null; + if (changed) bumpDisk(changed); + }, + index(event) { + const moved = event as LiveIndexEvent | null; + if (moved) bumpIndex(moved); + }, + degraded(event) { + const note = event as { reason: string } | null; + if (note) degraded = note.reason; + }, + error() { + connected = false; + // Take the closer before calling it: a transport that calls `error` + // again from inside its own teardown must not re-enter this. + const closer = close; + close = null; + closer?.(); + attempts += 1; + if (attempts >= MAX_ATTEMPTS) { + // Out of attempts. Nothing on a timer from here — the tab coming back + // to the foreground is the only thing that tries again. + stopped = true; + return; + } + const delay = BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)] ?? 30_000; + retry = setTimeout(open, delay); + }, + }); } /** Connect, once, for the life of the page. */ @@ -227,8 +245,8 @@ function start(): void { open(); }); window.addEventListener('pagehide', () => { - source?.close(); - source = null; + close?.(); + close = null; }); open(); @@ -263,7 +281,44 @@ export const live = { get lastChanged(): LiveChanged | null { return lastChanged; }, + /** The installed adapter has no live channel — this page never was live. */ + get unsupported(): boolean { + return unsupported; + }, start, + + /** + * Move a counter from outside. + * + * A host that learns about a sync through its own machinery — a websocket, a + * webhook, a store it already owns — calls this instead of implementing + * `GraphAdapter.events`, and every mounted screen refetches exactly as it + * does under `codegraph ui`. It is the same code path the stream uses, so + * there is no second way for a screen to go stale. + */ + signal(kind: 'index' | 'disk', detail: LiveSignalDetail = {}): void { + if (kind === 'index') { + bumpIndex({ + type: 'index', + index: detail.index ?? { lastIndexedAt: null, files: 0 }, + files: detail.files ?? [], + total: detail.total ?? detail.files?.length ?? 0, + truncated: detail.truncated ?? false, + at: detail.at ?? 0, + }); + return; + } + bumpDisk({ + type: 'changed', + files: detail.files ?? [], + total: detail.total ?? detail.files?.length ?? 0, + truncated: detail.truncated ?? false, + // No named files and no scan flag would mean "nothing changed", which is + // not what a caller asking for a disk tick means. + scan: detail.scan ?? (detail.files === undefined || detail.files.length === 0), + at: detail.at ?? 0, + }); + }, }; /** diff --git a/ui/src/lib/navigation.ts b/ui/src/lib/navigation.ts new file mode 100644 index 0000000..d64e601 --- /dev/null +++ b/ui/src/lib/navigation.ts @@ -0,0 +1,209 @@ +/** + * The navigation seam: where a click on a symbol, a file, a flow or the map + * takes the reader (task CG-61). + * + * The standalone viewer is a hash app — `#/s/`, `#/file/`, `#/map` — + * and that is the default driver below. A host embedding these components has + * its own router and its own URL space (a review page, a PR, a workspace), so + * it installs a {@link NavigationDriver} and every rail row, breadcrumb, chip + * and card in the package addresses *its* app instead. + * + * Two reasons the components go through href builders rather than through a + * single `onNavigate` callback: + * + * - **A row is a link.** Middle-click, cmd-click and "copy link address" are + * how people read code, and they only work if the `` really carries an + * href. A callback-only design turns every row into a `
` with an + * onclick, which is a worse screen. + * - **The trail travels in the address.** The walk is part of the URL, so + * building one is a thing the components must be able to do, not just ask + * for. + * + * The live route (`router.svelte.ts`) is the *app's* half and is deliberately + * not imported here: it attaches `hashchange`/`popstate` listeners at module + * scope, which a host must never inherit just by rendering a Symbol view. + */ + +export interface SymbolHrefOptions { + /** A line to highlight and scroll to in the destination. */ + line?: number; + /** The encoded trail, so a reload or a shared link reproduces the walk. */ + trail?: string; +} + +export interface FileHrefOptions { + line?: number; + /** The whole-file source view rather than the outline. */ + source?: boolean; +} + +export interface MapHrefOptions { + root?: string | null; + depth?: number; + tests?: boolean; +} + +export interface FlowHrefOptions { + from?: string; + to?: string; + symbols?: string; + trail?: string; +} + +/** + * Where the components send the reader. + * + * Implement all of it: a half-implemented driver produces a screen where some + * rows navigate the host and others silently jump to a hash the host does not + * serve. + */ +export interface NavigationDriver { + symbolHref(id: string, opts?: SymbolHrefOptions): string; + fileHref(path: string, opts?: FileHrefOptions): string; + mapHref(opts?: MapHrefOptions): string; + flowHref(opts?: FlowHrefOptions): string; + entryHref(): string; + /** Go to an href this driver built. */ + navigate(href: string, opts?: { replace?: boolean }): void; + /** Back one entry in the host's history. */ + back(): void; +} + +/* ------------------------------------------------------- the hash driver -- */ + +/** + * Node ids are opaque engine strings shaped `:` or + * `:`, so they can contain both ':' and '/'. Encoding per + * slash-separated segment keeps the URL readable (`#/file/src/mcp/tools.ts`) + * and still round-trips a segment that itself contains a reserved character. + */ +function encodePath(value: string): string { + return value.split('/').map(encodeURIComponent).join('/'); +} + +function query(params: URLSearchParams): string { + const text = params.toString(); + return text ? `?${text}` : ''; +} + +/** The `codegraph ui` address space: the hash is the route. */ +export const hashNavigation: NavigationDriver = { + symbolHref(id, opts = {}) { + const params = new URLSearchParams(); + if (opts.trail) params.set('t', opts.trail); + if (opts.line) params.set('hl', String(opts.line)); + return `#/s/${encodePath(id)}${query(params)}`; + }, + + fileHref(path, opts = {}) { + const params = new URLSearchParams(); + // `src` before `hl` so the two file URLs a reader shares differ in their + // first character after the path, not somewhere in the middle. + if (opts.source) params.set('src', '1'); + if (opts.line) params.set('hl', String(opts.line)); + return `#/file/${encodePath(path)}${query(params)}`; + }, + + mapHref(opts = {}) { + const params = new URLSearchParams(); + if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root); + if (opts.depth && opts.depth !== 1) params.set('depth', String(opts.depth)); + if (opts.tests) params.set('tests', '1'); + return `#/map${query(params)}`; + }, + + flowHref(opts = {}) { + const params = new URLSearchParams(); + if (opts.from) params.set('from', opts.from); + if (opts.to) params.set('to', opts.to); + if (opts.symbols) params.set('symbols', opts.symbols); + // `t`, not `trail`: the trail already travels under that name everywhere + // else, and a flow read from one is the same walk under a different lens. + if (opts.trail) params.set('t', opts.trail); + return `#/flow${query(params)}`; + }, + + entryHref() { + return '#/entry'; + }, + + navigate(href, opts = {}) { + const target = href.startsWith('#') ? href : `#${href}`; + if (opts.replace) { + history.replaceState(history.state, '', target); + onHashWritten(); + return; + } + if (location.hash === target) return; + location.hash = target; + // hashchange fires asynchronously; the sync is idempotent, so calling it + // now keeps a navigate() immediately followed by a read consistent. + onHashWritten(); + }, + + back() { + history.back(); + }, +}; + +/** + * The live route's re-read hook, registered by `router.svelte.ts`. + * + * The driver has to tell the route store that the hash moved, and the store + * has to attach window listeners — but a component importing the driver must + * not drag those listeners in. So the dependency runs this way round: the store + * registers itself with the driver, and a page that never loads the store gets + * a driver that simply writes the hash. + */ +let onHashWritten: () => void = () => {}; + +export function registerHashSync(sync: () => void): void { + onHashWritten = sync; +} + +/* ------------------------------------------------------------- registry -- */ + +let driver: NavigationDriver = hashNavigation; + +/** + * Install the driver every link in the package is built with. + * + * Call once, before anything renders. Passing `null` restores the hash driver. + */ +export function setNavigationDriver(next: NavigationDriver | null): void { + driver = next ?? hashNavigation; +} + +export function getNavigationDriver(): NavigationDriver { + return driver; +} + +/* --------------------------- what the components actually call ----------- */ + +export function symbolHref(id: string, opts: SymbolHrefOptions = {}): string { + return driver.symbolHref(id, opts); +} + +export function fileHref(path: string, opts: FileHrefOptions = {}): string { + return driver.fileHref(path, opts); +} + +export function mapHref(opts: MapHrefOptions = {}): string { + return driver.mapHref(opts); +} + +export function flowHref(opts: FlowHrefOptions = {}): string { + return driver.flowHref(opts); +} + +export function entryHref(): string { + return driver.entryHref(); +} + +export function navigate(href: string, opts: { replace?: boolean } = {}): void { + driver.navigate(href, opts); +} + +export function back(): void { + driver.back(); +} diff --git a/ui/src/lib/router.svelte.ts b/ui/src/lib/router.svelte.ts index cb36d5a..a238bce 100644 --- a/ui/src/lib/router.svelte.ts +++ b/ui/src/lib/router.svelte.ts @@ -18,8 +18,37 @@ * encoded *per slash-separated segment* and rejoined on the way out: the URL * stays readable (`#/file/src/mcp/tools.ts`) and still round-trips a segment * that itself contains a reserved character. + * + * This module is the APP's half — it parses the hash and holds the live route, + * and it attaches window listeners to do it. The href builders and `navigate` + * live in `./navigation`, behind a driver a host can replace, and the shared + * components import them from there: rendering a Symbol view inside somebody + * else's app must not install a hash router in it. They are re-exported below + * so this file stays the app's one-stop import. */ +import { registerHashSync } from './navigation'; + +export { + back, + entryHref, + fileHref, + flowHref, + getNavigationDriver, + hashNavigation, + mapHref, + navigate, + setNavigationDriver, + symbolHref, +} from './navigation'; +export type { + FileHrefOptions, + FlowHrefOptions, + MapHrefOptions, + NavigationDriver, + SymbolHrefOptions, +} from './navigation'; + export type Route = | { view: 'home' } | { view: 'symbol'; id: string; line: number | null } @@ -63,10 +92,6 @@ function decodeSegment(segment: string): string { } } -function encodePath(value: string): string { - return value.split('/').map(encodeURIComponent).join('/'); -} - function parseLine(params: URLSearchParams): number | null { const raw = params.get('hl'); if (raw === null) return null; @@ -120,58 +145,6 @@ export function parseHash(hash: string): RouterLocation { return { route, params, raw }; } -/* ---------- href builders (the only place hashes are assembled) ---------- */ - -export function symbolHref(id: string, opts: { line?: number; trail?: string } = {}): string { - const params = new URLSearchParams(); - if (opts.trail) params.set('t', opts.trail); - if (opts.line) params.set('hl', String(opts.line)); - const query = params.toString(); - return `#/s/${encodePath(id)}${query ? `?${query}` : ''}`; -} - -export function fileHref( - path: string, - opts: { line?: number; source?: boolean } = {} -): string { - const params = new URLSearchParams(); - // `src` before `hl` so the two file URLs a reader shares differ in their - // first character after the path, not somewhere in the middle. - if (opts.source) params.set('src', '1'); - if (opts.line) params.set('hl', String(opts.line)); - const query = params.toString(); - return `#/file/${encodePath(path)}${query ? `?${query}` : ''}`; -} - -export function mapHref( - opts: { root?: string | null; depth?: number; tests?: boolean } = {} -): string { - const params = new URLSearchParams(); - if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root); - if (opts.depth && opts.depth !== 1) params.set('depth', String(opts.depth)); - if (opts.tests) params.set('tests', '1'); - const query = params.toString(); - return `#/map${query ? `?${query}` : ''}`; -} - -export function entryHref(): string { - return '#/entry'; -} - -export function flowHref( - opts: { from?: string; to?: string; symbols?: string; trail?: string } = {} -): string { - const params = new URLSearchParams(); - if (opts.from) params.set('from', opts.from); - if (opts.to) params.set('to', opts.to); - if (opts.symbols) params.set('symbols', opts.symbols); - // `t`, not `trail`: the trail already travels under that name everywhere - // else, and a flow read from one is the same walk under a different lens. - if (opts.trail) params.set('t', opts.trail); - const query = params.toString(); - return `#/flow${query ? `?${query}` : ''}`; -} - /* ---------- the live route ---------- */ const initial = parseHash(typeof location === 'undefined' ? '' : location.hash); @@ -187,6 +160,10 @@ if (typeof window !== 'undefined') { // popstate too: `navigate(…, { replace: true })` and history.back() across // a replaced entry both move the hash without firing hashchange. window.addEventListener('popstate', sync); + // The hash driver writes `location.hash` directly; this is how it tells the + // route store to re-read. Registered here rather than imported there, so a + // host that never loads this module gets no window listeners at all. + registerHashSync(sync); } export const router = { @@ -200,21 +177,3 @@ export const router = { return current.params; }, }; - -export function navigate(href: string, opts: { replace?: boolean } = {}): void { - const target = href.startsWith('#') ? href : `#${href}`; - if (opts.replace) { - history.replaceState(history.state, '', target); - sync(); - return; - } - if (location.hash === target) return; - location.hash = target; - // hashchange fires asynchronously; sync() is idempotent, so calling it now - // keeps a navigate() immediately followed by a read consistent. - sync(); -} - -export function back(): void { - history.back(); -} diff --git a/ui/src/lib/theme.css b/ui/src/lib/theme.css new file mode 100644 index 0000000..2d755c3 --- /dev/null +++ b/ui/src/lib/theme.css @@ -0,0 +1,192 @@ +/* ===================================================================== + @colbymchenry/codegraph-ui — design tokens + + The engine's paper/ink editorial system, as specified in + docs/design/codegraph-ui-design-spec.md §2.2: flat, hairline rules, + square corners everywhere, no shadows, no gradients, sentence case, + one oxblood accent, one amber (the "untested" badge). + + This file is the package's whole theming surface. Import it once and + override any variable on a narrower selector — the components read + nothing else. What is NOT themable is geometry: 34px rail rows, the + 300/320px rails, the 20px code line. Those are measured against each + other by the Symbol view's layout pass, and a host that moves one of + them moves a callee row away from the line it points at. + + Colour and type only. Every token is defined once on the bare :root + below and only REDEFINED in the two dark blocks, so a host that sets + `--accent` on its own container gets it in both schemes. + ===================================================================== */ + +/* ---------- tokens: light / paper (the bare :root set) ---------- */ +:root { + --paper: #f7f6f2; + --paper-2: #f1efe8; + --press: #e8e6dd; + --press-2: #dedbd0; + --ink: #16150f; + --ink-2: #56544a; + --ink-3: #87847a; + --ink-4: #b4b1a5; + --rule: #16150f; + --rule-soft: #d6d3c8; + --rule-faint: #e6e3d9; + --accent: #7a2230; + --accent-ink: #5e1a25; + --accent-soft: #f0e3e5; + --accent-line: #d9b3b9; + --amber: #8a5a0b; + --amber-soft: #f3e9d2; + + /* The one code colour that is not a plain re-use of the ink ramp. + The spec asks for comments at --ink-3; measured against --paper that + is 3.46:1 and against the hot-line tint --accent-soft it is 3.00:1, + both under the 4.5:1 an AA reading of 12.5px body text needs. This is + the smallest step DOWN the same warm-grey ramp that clears 4.5:1 on + all three backgrounds a code line can have (paper 5.23, paper-2 4.92, + accent-soft 4.53) while staying quieter than --ink-2, which strings + and numbers use — so the recession order the spec describes is + unchanged, only legible. Dark needed the mirror step UP (4.51 on + accent-soft, where --ink-3 was 4.10). */ + --code-comment: #6a675d; + + --sans: 'Archivo Variable', 'Archivo', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif; + --mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + --code-size: 12.5px; + --code-lh: 20px; + + /* App-shell geometry, shared by the grid and by anything that has to + offset itself under the bars (sticky rail headers, SVG overlays). */ + --topbar-h: 48px; + --trailbar-h: 34px; + + color-scheme: light dark; +} + +/* ---------- tokens: dark / ink ---------- + Every colour is defined on the bare :root above; these blocks only + redefine. `:not([data-theme="light"])` lets an explicit light choice + win over the OS preference. */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + --paper: #16150f; + --paper-2: #1c1a14; + --press: #23211a; + --press-2: #2c2a22; + --ink: #f3f1ea; + --ink-2: #b8b5a8; + --ink-3: #87847a; + --ink-4: #5d5b52; + --rule: #f3f1ea; + --rule-soft: #34322a; + --rule-faint: #26241d; + --accent: #d48b96; + --accent-ink: #e5a5ae; + --accent-soft: #33201f; + --accent-line: #6b3a42; + --amber: #d9a94a; + --amber-soft: #2e2716; + --code-comment: #8e8b81; + } +} + +/* An explicit choice, wherever it is made. `:root[data-theme]` is the viewer's + own switch; the bare attribute selector is what `` + sets on its wrapper — custom properties inherit, so redefining them on a + container re-themes that subtree without touching :root. A host can therefore + put a light reader inside a dark application, and the reverse. */ +:root[data-theme='dark'], +[data-theme='dark'] { + --paper: #16150f; + --paper-2: #1c1a14; + --press: #23211a; + --press-2: #2c2a22; + --ink: #f3f1ea; + --ink-2: #b8b5a8; + --ink-3: #87847a; + --ink-4: #5d5b52; + --rule: #f3f1ea; + --rule-soft: #34322a; + --rule-faint: #26241d; + --accent: #d48b96; + --accent-ink: #e5a5ae; + --accent-soft: #33201f; + --accent-line: #6b3a42; + --amber: #d9a94a; + --amber-soft: #2e2716; + --code-comment: #8e8b81; + color-scheme: dark; +} + +/* The light values again, for a container that asks for light inside a page + the OS is painting dark. `:root[data-theme='light']` needs no block — the + media query above already excludes it. */ +[data-theme='light'] { + --paper: #f7f6f2; + --paper-2: #f1efe8; + --press: #e8e6dd; + --press-2: #dedbd0; + --ink: #16150f; + --ink-2: #56544a; + --ink-3: #87847a; + --ink-4: #b4b1a5; + --rule: #16150f; + --rule-soft: #d6d3c8; + --rule-faint: #e6e3d9; + --accent: #7a2230; + --accent-ink: #5e1a25; + --accent-soft: #f0e3e5; + --accent-line: #d9b3b9; + --amber: #8a5a0b; + --amber-soft: #f3e9d2; + --code-comment: #6a675d; + color-scheme: light; +} + +/* ---------- Svelte Flow ---------- + The Map and the Flow strip draw on @xyflow/svelte, which ships its own + blue-on-white palette in `--xy-*` variables. Mapping them onto the ink + ramp here — rather than in each canvas — is what stops a host from + seeing library defaults in the gaps our custom node and edge + components do not paint: the pane behind the cards, the controls, the + minimap, the selection ring, the attribution. + + Set on :root so it reaches the portalled panes too. A host that wants + the library's own look overrides these after importing this file. */ +:root { + --xy-background-color: var(--paper); + --xy-background-pattern-color: var(--rule-faint); + + --xy-edge-stroke: var(--ink-3); + --xy-edge-stroke-selected: var(--accent); + --xy-edge-stroke-width: 1; + --xy-connectionline-stroke: var(--ink-3); + + --xy-node-color: var(--ink); + --xy-node-background-color: var(--paper); + --xy-node-border: 1px solid var(--rule-soft); + --xy-node-boxshadow-hover: none; + --xy-node-boxshadow-selected: none; + --xy-selection-background-color: var(--accent-soft); + --xy-selection-border: 1px solid var(--accent-line); + + --xy-handle-background-color: transparent; + --xy-handle-border-color: transparent; + + --xy-controls-button-background-color: var(--paper); + --xy-controls-button-background-color-hover: var(--press); + --xy-controls-button-color: var(--ink-2); + --xy-controls-button-color-hover: var(--ink); + --xy-controls-button-border-color: var(--rule-soft); + --xy-controls-box-shadow: none; + + --xy-minimap-background-color: var(--paper-2); + --xy-minimap-mask-background-color: var(--paper); + --xy-minimap-node-background-color: var(--rule-soft); + --xy-minimap-node-stroke-color: var(--ink-3); + + --xy-attribution-background-color: transparent; + + --xy-resize-background-color: var(--accent); + --xy-error-color: var(--accent); +} diff --git a/ui/src/lib/walk.ts b/ui/src/lib/walk.ts index d6d12ba..8444918 100644 --- a/ui/src/lib/walk.ts +++ b/ui/src/lib/walk.ts @@ -13,7 +13,7 @@ * link reproduces the walk rather than starting a fresh one at the same symbol. */ -import { fileHref, navigate, symbolHref } from './router.svelte'; +import { fileHref, navigate, symbolHref } from './navigation'; import { encodeTrail, trail, type HopDirection } from './trail.svelte'; import type { EntryTarget } from './entry-model'; diff --git a/ui/src/lib/wire.ts b/ui/src/lib/wire.ts new file mode 100644 index 0000000..cc323b2 --- /dev/null +++ b/ui/src/lib/wire.ts @@ -0,0 +1,589 @@ +/** + * The wire shapes of the graph API — types only, no runtime. + * + * These mirror the server's payloads (`src/ui-server/api/`, CG-42) rather than + * re-deriving them: the API is versioned with the binary that serves it, so a + * field the server stopped sending should break the type-check here, not + * surface as `undefined` in a rail three screens later. + * + * They are also the vocabulary of {@link GraphAdapter} (`adapter.ts`): a host + * embedding these components answers in exactly these shapes, whether it is + * reading them over HTTP from `codegraph ui` or building them in-process from + * its own engine. Keeping them in a file with no imports and no side effects is + * what lets a host depend on the vocabulary without pulling in the transport. + */ + +import type { WireHighlight } from './highlight'; + +/* ---------------------------------------------------------------- shapes -- */ + +export type NodeKind = string; +export type EdgeKind = string; + +export interface WireNodeRef { + id: string; + kind: NodeKind; + name: string; + qualifiedName: string; + /** Project-relative, forward slashes on every platform. */ + file: string; + line: number; + endLine: number; + language: string; + signature?: string; + exported?: boolean; + /** Lives in a file that looks like test or fixture code. */ + test: boolean; +} + +export interface WireNodeDetail extends WireNodeRef { + startColumn: number; + endColumn: number; + docstring?: string; + visibility?: string; + async?: boolean; + static?: boolean; + abstract?: boolean; + decorators?: string[]; + typeParameters?: string[]; + returnType?: string; + lines: number; +} + +export interface WireMember extends WireNodeRef { + parentId: string; + /** 1 = a direct member; 2 = a member of a member (a method inside a file's class). */ + depth: number; + fanIn: number; + fanOut: number; +} + +export interface WireEdge { + kind: EdgeKind; + line?: number; + col?: number; + confidence?: number; + resolvedBy?: string; + provenance?: string; + synthesizedBy?: string; + via?: string; + registeredAt?: string; + valueRef?: boolean; +} + +/** Every edge between the focal symbol and ONE other symbol, as a single row. */ +export interface WireRelation { + node: WireNodeRef; + edgeKinds: EdgeKind[]; + edges: WireEdge[]; + edgeCount: number; + /** Distinct call-site lines, ascending — what the gutter ports anchor to. */ + lines: number[]; + confidence: number | null; + uncertain: boolean; + synthesized: boolean; + fanIn?: number; + hub?: boolean; +} + +export interface WireList { + total: number; + shown: number; + truncated: boolean; + items: T[]; +} + +export interface WireTestSummary { + reached: boolean; + hops: number | null; + fileCount: number; + files: string[]; + /** False weakens the claim to "no test calls this directly" — see the server. */ + exhaustive: boolean; + hopsSearched: number; +} + +export interface WireOutsideIndex { + total: number; + byKind: Record; + samples: Array<{ name: string; kind: string; line?: number; col?: number }>; +} + +export interface WireBlastSummary { + direct: number; + withinHops: number; + hops: number; + files: number; + testFiles: number; + routes: number; + topFiles: Array<{ file: string; symbols: number; test: boolean }>; +} + +export interface WireSymbolPayload { + node: WireNodeDetail; + /** Outermost first: file, then module/class, then the symbol's own parent. */ + ancestors: WireNodeRef[]; + members: WireList; + incoming: WireList; + outgoing: WireList; + typesUsed: WireRelation[]; + counts: { + callers: number; + callees: number; + typesUsed: number; + fanIn: number; + fanOut: number; + members: number; + hub: boolean; + }; + tests: WireTestSummary; + outsideIndex: WireOutsideIndex; + blast: WireBlastSummary | null; + /** The file changed on disk since the index — line ranges may be shifted. */ + drift: boolean; +} + +export interface WireSource { + file: string; + language: string; + drift: boolean; + /** + * Which numbering `lines` belong to. `'indexed'` — the file matches the + * index. `'current'` — it drifted and we asked for the bytes anyway + * (`ondrift: 'current'`), so nothing the graph holds about this file lines up + * with them. `'none'` — it drifted and no slice came back. + */ + showing: 'indexed' | 'current' | 'none'; + contentHash: string; + indexedAt: number; + generated: boolean; + totalLines: number | null; + from?: number; + to?: number; + /** Absent when the file drifted and `ondrift` was left at its default. */ + lines?: string[]; + truncated?: boolean; + reason?: string; + /** + * The same lines, classified by the server's tree-sitter parse — one entry + * per line, each a list of `[classId, text]` pairs indexed into `classes`. + * Absent whenever `lines` is, and `engine: 'plain'` whenever no grammar + * covers the file. See `lib/highlight.ts`. + */ + highlight?: WireHighlight; +} + +/* ------------------------------------------------------------- file view -- */ + +/** A row in the file outline — a symbol, its nesting and its edge counts. */ +export interface WireOutlineEntry extends WireNodeRef { + /** Containing symbol within this file, or null for a top-level one. */ + parentId: string | null; + /** Nesting depth from the top level of the file, starting at 0. */ + depth: number; + fanIn: number; + fanOut: number; +} + +/** One file at the far end of an import rail, with the symbols the edges name. */ +export interface WireImportRow { + file: string; + test: boolean; + symbols: Array<{ id: string; name: string; kind: string; line: number }>; + symbolCount: number; +} + +export interface WireFilePayload { + file: { + path: string; + language: string; + size: number; + modifiedAt: number; + indexedAt: number; + contentHash: string; + nodeCount: number; + generated: boolean; + test: boolean; + errors: string[]; + /** The file node's own id, so the viewer can open the file AS a symbol. */ + id: string | null; + }; + /** Calls made outside every definition — module-level code. */ + topLevel: { calls: number }; + /** The file changed on disk since it was indexed; the outline's lines shifted. */ + drift: boolean; + outline: WireList; + /** `imports` edges only — a subset of `dependencies`, with symbol names. */ + imports: WireList; + importedBy: WireList; + /** Import statements that resolved to nothing indexed: packages, builtins. */ + unresolvedImports: Array<{ name: string; line: number }>; + /** Every file this one reaches by any cross-file edge — `getFileDependencies`. */ + dependencies: string[]; + /** Every file that reaches into this one — `getFileDependents`. */ + dependents: string[]; +} + +/* ------------------------------------------------ whole-file source view -- */ + +/** A reference the resolver never landed: a gutter port with no destination. */ +export interface WireFileOutsideRef { + line: number; + col: number; + name: string; + kind: string; +} + +/** Every edge from ONE symbol in a file to ONE symbol anywhere. */ +export interface WireFileCall { + /** The symbol making the calls — the file node itself for top-level code. */ + ownerId: string; + ownerLine: number; + relation: WireRelation; +} + +export interface WireFileCodePayload { + file: { + path: string; + language: string; + size: number; + indexedAt: number; + contentHash: string; + generated: boolean; + test: boolean; + errors: string[]; + id: string | null; + /** Lines on disk now — the height of the scrolling document. */ + totalLines: number | null; + }; + drift: boolean; + reason?: string; + outline: WireList; + calls: WireList; + outside: WireList; + /** Calls landing on a definition in this same file — the arc diagram's total. */ + intraFileCalls: number; + timing: { elapsedMs: number }; +} + +export interface WireBlastScale { + maxDirect: number; + maxWithinHops: number; + hops: number; + sampled: number; + estimated: boolean; +} + +/* ------------------------------------------------------- search palette -- */ + +/** How a result's text matched the query — the server's primary sort key. */ +export type MatchKind = 'exact' | 'prefix' | 'substring' | 'qualified' | 'file' | 'related'; + +export interface WireSearchResult extends WireNodeRef { + matchKind: MatchKind; +} + +export interface WireSearchGroup { + kind: NodeKind; + count: number; + items: WireSearchResult[]; +} + +export interface WireSearch { + query: string; + /** The free-text part, with any `kind:` / `lang:` / `path:` filters removed. */ + text: string; + filters: { kinds: string[]; languages: string[]; paths: string[]; names: string[] }; + results: WireList; + /** Kind buckets in ranked order — flattening them reproduces the ranking. */ + groups: WireSearchGroup[]; +} + +export interface WireNodeRefs { + items: WireNodeRef[]; + /** Ids that name nothing in this index — a stale link, not an error. */ + missing: string[]; +} + +/* --------------------------------------------------------------- routes -- */ + +/** One row of the URL -> handler map (`/api/routes`). */ +export interface WireRoute { + /** The route node's name, verbatim: "POST /v1/users/{id}". */ + url: string; + /** The verb, when the name leads with one. Null for a file-routed page. */ + method: string | null; + /** The URL without the verb — the same string as `url` when there is none. */ + path: string; + handler: string; + handlerKind: string; + /** Where the request is SERVED. */ + file: string; + line: number; + handlerId: string | null; + /** Where the URL is REGISTERED — the router file, which is how routes group. */ + routeFile: string; + routeLine: number; + routeId: string; +} + +export interface WireRoutes { + routed: boolean; + /** Every URL the index holds, whether or not its handler resolved. */ + routeCount: number; + /** Rows in `entries` — the ones whose handler the manifest could name. */ + shown: number; + truncated: boolean; + topHandlerFile: string | null; + topHandlerFileCount: number; + entries: WireRoute[]; +} + +/* ---------------------------------------------------------- entry points -- */ + +export interface WireEntryRoute { + /** The route node's name, verbatim: "POST /v1/users/{id}". */ + url: string; + /** The verb, when the name leads with one. Null for a file-routed page. */ + method: string | null; + /** The URL without the verb — the same string as `url` when there is none. */ + path: string; + handler: string; + handlerKind: string; + /** Where the request is SERVED. */ + file: string; + line: number; + handlerId: string | null; + /** Where the URL is REGISTERED — the router file, which is how routes group. */ + routeFile: string; + routeLine: number; + routeId: string; +} + +export interface WireEntryFile extends WireNodeRef { + /** Calls and instantiations made at the top level of the file. */ + calls: number; + /** Distinct other files this one's symbols reach. */ + reaches: number; + /** Other files reaching into this one. Zero means nothing imports it. */ + dependents: number; +} + +export interface WireEntryHub extends WireNodeRef { + dependents: number; +} + +export interface WireEntryTest extends WireNodeRef { + /** Distinct other files this test reaches — what it exercises. */ + reaches: number; + /** References behind that reach. */ + refs: number; +} + +export interface WireEntryPoints { + /** Frameworks the resolver detected — named in the Routes header. */ + frameworks: string[]; + routes: { + routed: boolean; + /** Every `route` node in the graph, resolved handler or not. */ + routeCount: number; + items: WireList; + }; + /** `total` is a floor on `files` and `hubs`; on `tests` it is exact. */ + files: WireList; + tests: WireList; + hubs: WireList; + index: { lastIndexedAt: number | null; files: number }; + timing: { elapsedMs: number; cached: boolean }; +} + +export interface WireStats { + project: { root: string; name: string }; + index: { + state: string | null; + lastIndexedAt: number | null; + stale: boolean; + version: string | null; + extractionVersion: number | null; + backend: string; + journalMode: string; + pendingReferences: number; + generatedFiles: number; + watching: boolean; + watcherDegraded: boolean; + }; + graph: { + nodes: number; + edges: number; + files: number; + nodesByKind: Record; + edgesByKind: Record; + filesByLanguage: Record; + dbSizeBytes: number; + walSizeBytes: number; + }; + frameworks: string[]; + thresholds: { hub: number; uncertainBelow: number }; + blastScale: WireBlastScale; +} + +/* ------------------------------------------------------------- flow strip -- */ + +export interface WireFlowEdge extends WireEdge { + /** The link's label: "calls", "via callback · registered at file:line". */ + label: string; + /** This hop reads callee → caller — the reader stepped UP into it. */ + upward: boolean; + /** Confidence below 0.6: the link is dashed `2 3`. */ + uncertain: boolean; + /** A synthesized dynamic-dispatch bridge: dashed `5 3`. */ + synthesized: boolean; +} + +export interface WireFlowSource { + file: string; + language: string; + from: number; + to: number; + /** Absent when `drift` — a mis-sliced window is worse than an empty card. */ + lines?: string[]; + highlight?: WireHighlight; + drift: boolean; + reason?: string; +} + +/** The call site a card is opened at — the identifier drawn as an accent link. */ +export interface WireFlowCallRef { + line: number; + col: number | null; + name: string; + targetId: string; + /** The link points back at the previous card, not on to the next one. */ + backwards: boolean; +} + +export interface WireFlowHop { + node: WireNodeRef; + /** The edge from the PREVIOUS hop into this one; null on the first. */ + edge: WireFlowEdge | null; + callRef: WireFlowCallRef | null; + source: WireFlowSource | null; +} + +/** One plausible runtime target of a keyed dispatch — a clickable cap row. */ +export interface WireBoundaryCandidate { + node: WireNodeRef; + display: string; + named: boolean; +} + +/** A dynamic-dispatch site: the form, the key when it is visible, the targets. */ +export interface WireBoundarySite { + form: string; + label: string; + snippet: string; + line: number; + key: string | null; + keyIsType: boolean; + moreSites: number; + candidates: WireBoundaryCandidate[]; + candidateNote: string | null; +} + +export interface WireFlowContinuation { + node: WireNodeRef; + line: number | null; + confidence: number | null; +} + +/** Where the graph stops — the strip's end cap (design spec §3.5). */ +export interface WireFlowBoundary { + node: WireNodeRef; + sites: WireBoundarySite[]; + uncertain: WireList; + further: WireList; + missed: WireNodeRef[]; +} + +export interface WireFlow { + id: string; + /** "execute → rowToFileRecord", for the header's flow picker. */ + label: string; + hops: WireFlowHop[]; + /** Null on a flow that reaches everything it was asked about. */ + boundary: WireFlowBoundary | null; + /** One card at the dispatch site, not a path: the answer ran out here. */ + partial: boolean; +} + +export interface WireFlowAmbiguity { + token: string; + chosen: WireNodeRef | null; + others: WireNodeRef[]; +} + +export interface WireFlowPayload { + query: { + kind: 'directed' | 'symbols' | 'trail'; + from: string | null; + to: string | null; + symbols: string[]; + }; + flows: WireFlow[]; + ambiguous: WireFlowAmbiguity[]; + /** Tokens that named nothing in this index. */ + unresolved: string[]; + /** Why there is no flow, when there is none. */ + reason: string | null; + index: { lastIndexedAt: number | null; edges: number; files: number }; + timing: { elapsedMs: number }; +} + +/* -------------------------------------------------------------- the map -- */ + +export interface WireMapModule { + /** Directory path, the `(root files)` bucket, or a façade file's own path. */ + id: string; + label: string; + files: number; + symbols: number; + languages: Array<{ language: string; files: number }>; + /** More than half its files are tests. */ + test: boolean; + /** A single file kept out of the root bucket because it is the façade. */ + facade: boolean; + /** Its files, capped — the side panel's list when the module is selected. */ + fileList: { total: number; shown: number; truncated: boolean; items: string[] }; +} + +export interface WireMapLink { + source: string; + target: string; + /** Every confident cross-module edge behind this link. */ + count: number; + /** + * The subset resolved through an import, a qualified name, an inheritance + * clause or a typed receiver — what the layering trusts. + */ + declared: number; + byKind: Array<{ kind: EdgeKind; count: number }>; + topPairs: Array<{ from: string; to: string; count: number; declared: number }>; +} + +export interface WireMapCycle { + size: number; + files: string[]; + modules: string[]; +} + +export interface WireMapPayload { + root: string; + depth: number; + roots: Array<{ root: string; label: string; files: number }>; + modules: WireMapModule[]; + links: WireMapLink[]; + cycles: { total: number; shown: number; truncated: boolean; items: WireMapCycle[] }; + excluded: { uncertainEdges: number; confidenceBelow: number }; + index: { lastIndexedAt: number | null; edges: number; files: number }; + timing: { elapsedMs: number; cached: boolean }; +} diff --git a/ui/src/views/EntryView.svelte b/ui/src/views/EntryView.svelte index 8316fe1..58f22da 100644 --- a/ui/src/views/EntryView.svelte +++ b/ui/src/views/EntryView.svelte @@ -19,7 +19,7 @@ import EntrySection from '../components/entry/EntrySection.svelte'; import { palette } from '../lib/palette.svelte'; import { buildEntryPanel, flowPair, type EntryRow } from '../lib/entry-model'; - import { flowHref, navigate } from '../lib/router.svelte'; + import { flowHref, navigate } from '../lib/navigation'; import { openEntryTarget } from '../lib/walk'; interface Props { diff --git a/ui/src/views/FileView.svelte b/ui/src/views/FileView.svelte index 46cd9c0..1344e3e 100644 --- a/ui/src/views/FileView.svelte +++ b/ui/src/views/FileView.svelte @@ -29,7 +29,7 @@ buildFileRail, fileMetaLine, } from '../lib/file-model'; - import { fileHref, navigate } from '../lib/router.svelte'; + import { fileHref, navigate } from '../lib/navigation'; import { liveRefresh } from '../lib/live.svelte'; import { plural } from '../lib/symbol-model'; import { walkTo } from '../lib/walk'; diff --git a/ui/src/views/FlowView.svelte b/ui/src/views/FlowView.svelte index e871cb8..99d8144 100644 --- a/ui/src/views/FlowView.svelte +++ b/ui/src/views/FlowView.svelte @@ -23,7 +23,7 @@ import { exportFilename, flowSvg } from '../lib/export-svg'; import { fetchFlow, type WireFlow, type WireFlowPayload } from '../lib/api'; import { live } from '../lib/live.svelte'; - import { navigate, symbolHref } from '../lib/router.svelte'; + import { navigate, symbolHref } from '../lib/navigation'; import { trail, encodeTrail, type TrailHop } from '../lib/trail.svelte'; import { decodeTrail } from '../lib/trail-codec'; import { buildFlowLayout, type FlowCardLayout, type FlowLayout } from '../lib/flow-model'; diff --git a/ui/src/views/HomeView.svelte b/ui/src/views/HomeView.svelte index 9716446..276aa4b 100644 --- a/ui/src/views/HomeView.svelte +++ b/ui/src/views/HomeView.svelte @@ -16,7 +16,7 @@ import PaletteRows from '../components/PaletteRows.svelte'; import { palette } from '../lib/palette.svelte'; import { buildEntryPalette, type PaletteItem } from '../lib/search-model'; - import { entryHref, fileHref, flowHref, navigate } from '../lib/router.svelte'; + import { entryHref, fileHref, flowHref, navigate } from '../lib/navigation'; import { openEntryTarget, walkTo } from '../lib/walk'; interface Props { diff --git a/ui/src/views/MapView.svelte b/ui/src/views/MapView.svelte index d6fa584..7d6b314 100644 --- a/ui/src/views/MapView.svelte +++ b/ui/src/views/MapView.svelte @@ -21,7 +21,7 @@ import { exportFilename, mapSvg } from '../lib/export-svg'; import { fetchMap, type WireMapPayload } from '../lib/api'; import { live } from '../lib/live.svelte'; - import { mapHref, navigate } from '../lib/router.svelte'; + import { mapHref, navigate } from '../lib/navigation'; import { buildMapLayout, isEdgeVisible, diff --git a/ui/src/views/SymbolView.svelte b/ui/src/views/SymbolView.svelte index 66025bf..c9beeac 100644 --- a/ui/src/views/SymbolView.svelte +++ b/ui/src/views/SymbolView.svelte @@ -50,7 +50,7 @@ } from '../lib/symbol-model'; import { encodeTrail, trail } from '../lib/trail.svelte'; import { liveRefresh } from '../lib/live.svelte'; - import { fileHref, navigate, symbolHref } from '../lib/router.svelte'; + import { fileHref, navigate, symbolHref } from '../lib/navigation'; import { arrivedFrom, walkTo } from '../lib/walk'; interface Props { diff --git a/vitest.config.ts b/vitest.config.mts similarity index 82% rename from vitest.config.ts rename to vitest.config.mts index 9caca1e..6c4da5f 100644 --- a/vitest.config.ts +++ b/vitest.config.mts @@ -1,5 +1,11 @@ import { defineConfig } from 'vitest/config'; +/** + * The SHARED base. `vitest.workspace.mts` extends it twice — once for the + * engine's node-environment suites and once for the viewer package's jsdom + * one — so the environment, the plugins and the module-resolution conditions + * a browser test needs cannot leak into the other 200-odd suites. + */ export default defineConfig({ test: { globals: true, diff --git a/vitest.workspace.mts b/vitest.workspace.mts new file mode 100644 index 0000000..c37fae1 --- /dev/null +++ b/vitest.workspace.mts @@ -0,0 +1,63 @@ +import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte'; +import { defineWorkspace } from 'vitest/config'; + +/** + * Two projects, one command (`npm test` still runs everything). + * + * The split exists because of exactly one suite. `ui-package.test.ts` mounts + * `@colbymchenry/codegraph-ui`'s components against a mock adapter (task + * CG-61), and to do that it needs three things the engine's suites must never + * see: + * + * - the **Svelte plugin**, to compile `.svelte` and `.svelte.ts` modules; + * - **jsdom**, because a component without a document is not a render; + * - `resolve.conditions: ['browser']`, so `svelte` resolves to its client + * build rather than its server one (`mount()` throws on the server). + * + * That last one is why this is a workspace rather than one config with a + * couple of extra fields. `browser` is a package-resolution condition, not a + * test setting: applied globally it would also hand the engine's suites the + * browser builds of `web-tree-sitter` and friends, and the failures that + * causes look nothing like their cause. + * + * The engine project `extends` the shared base, so the env vars and Node guard + * in `vitest.config.mts` still apply to every engine test. The ui project does + * not — see the note on it. + */ +export default defineWorkspace([ + { + extends: './vitest.config.mts', + test: { + name: 'engine', + include: ['__tests__/**/*.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**', '__tests__/ui-package.test.ts'], + }, + }, + { + // Deliberately NOT `extends`: a workspace project CONCATENATES the base's + // `include` with its own, so extending here would run all 200-odd engine + // suites a second time inside jsdom (and two of them fail there, for + // reasons that have nothing to do with anything). This project stands + // alone, and it needs none of the base's spawn-related env anyway. + plugins: [ + // The same preprocessor `ui/svelte.config.js` builds with, so the test + // compiles what the package ships. + svelte({ preprocess: vitePreprocess() }), + ], + resolve: { conditions: ['browser'] }, + test: { + name: 'ui', + globals: true, + include: ['__tests__/ui-package.test.ts'], + environment: 'jsdom', + server: { + deps: { + // `@xyflow/svelte` ships uncompiled `.svelte` files, so it has to go + // through the plugin above rather than be externalised to Node, + // which has no idea what a `.svelte` file is. + inline: [/@xyflow\/svelte/], + }, + }, + }, + }, +]);