From 8b3247fb868a090cc89578bb13de74052b59c0ce Mon Sep 17 00:00:00 2001 From: nakul-malhotra Date: Fri, 18 Sep 2026 11:19:19 -0700 Subject: [PATCH 01/13] feat(map): support nested roots and deep grouping --- __tests__/ui-map-api.test.ts | 78 +++++++++++++++++++++-- src/lib/map-config.ts | 2 + src/ui-server/api/map.ts | 45 +++++++++---- ui/README.md | 5 ++ ui/src/components/map/MapSidePanel.svelte | 3 +- 5 files changed, 112 insertions(+), 21 deletions(-) create mode 100644 src/lib/map-config.ts diff --git a/__tests__/ui-map-api.test.ts b/__tests__/ui-map-api.test.ts index 6ba6d292e6..197a60e2fb 100644 --- a/__tests__/ui-map-api.test.ts +++ b/__tests__/ui-map-api.test.ts @@ -191,6 +191,29 @@ export function start(): void { ` ); + // These paths differ only after four directory segments beneath `src`. + // Depth 5 must separate them; depth 4 must not. + write( + projectRoot, + 'src/deep/a/b/c/left/item.ts', + `export const left = 'left';\n` + ); + write( + projectRoot, + 'src/deep/a/b/c/right/item.ts', + `export const right = 'right';\n` + ); + write( + projectRoot, + 'supabase/functions/_shared/auth.ts', + `export const authenticate = (): boolean => true;\n` + ); + write( + projectRoot, + 'supabase/functions/worker/serve.ts', + `import { authenticate } from '../_shared/auth';\n\nexport const serve = () => authenticate();\n` + ); + write( projectRoot, '__tests__/engine.test.ts', @@ -203,7 +226,7 @@ export function testBoot(): string[] { ); const cg = CodeGraph.initSync(projectRoot, { - config: { include: ['src/**/*.ts', '__tests__/**/*.ts'], exclude: [] }, + config: { include: ['src/**/*.ts', 'supabase/**/*.ts', '__tests__/**/*.ts'], exclude: [] }, }); await cg.indexAll(); cg.resolveReferences(); @@ -408,7 +431,14 @@ describe('GET /api/map', () => { expect(map.depth).toBe(1); const ids = map.modules.map((m: any) => m.id); - expect(ids).toEqual(['src/(root files)', 'src/api', 'src/core', 'src/db', 'src/index.ts']); + expect(ids).toEqual([ + 'src/(root files)', + 'src/api', + 'src/core', + 'src/db', + 'src/deep', + 'src/index.ts', + ]); expect(map.modules.find((m: any) => m.id === 'src/core').files).toBe(3); const facade = map.modules.find((m: any) => m.id === 'src/index.ts'); @@ -419,12 +449,16 @@ describe('GET /api/map', () => { expect(map.modules.every((m: any) => m.test === false)).toBe(true); }); - it('offers every top-level directory as a root, plus the repository itself', async () => { + it('offers every indexed directory with descendant file counts, plus the repository itself', async () => { const map = await getMap(); expect(map.roots[0]).toEqual({ root: '', label: 'whole repository', files: map.index.files }); - expect(map.roots.map((r: any) => r.root)).toEqual( - expect.arrayContaining(['', 'src', '__tests__']) - ); + expect(map.roots).toEqual(expect.arrayContaining([ + { root: 'supabase', label: 'supabase', files: 2 }, + { root: 'supabase/functions', label: ' ↳ functions', files: 2 }, + { root: 'supabase/functions/_shared', label: ' ↳ _shared', files: 1 }, + { root: 'supabase/functions/worker', label: ' ↳ worker', files: 1 }, + ])); + expect(new Set(map.roots.map((r: any) => r.root)).size).toBe(map.roots.length); }); it('counts cross-module edges only, with a declared subset and named pairs', async () => { @@ -520,6 +554,30 @@ describe('GET /api/map', () => { expect(slashed.modules).toEqual(deep.modules); }); + it('isolates a nested root without sibling files', async () => { + const map = await getMap('?root=supabase%2Ffunctions&depth=1'); + expect(map.root).toBe('supabase/functions'); + expect(map.modules.flatMap((module: any) => module.fileList.items)).toEqual( + expect.arrayContaining([ + 'supabase/functions/_shared/auth.ts', + 'supabase/functions/worker/serve.ts', + ]) + ); + expect(map.modules.flatMap((module: any) => module.fileList.items)).not.toContain('src/index.ts'); + }); + + it('uses manual depths through 12, including levels deeper than the automatic cap', async () => { + const shallow = await getMap('?root=src&depth=4'); + const deep = await getMap('?root=src&depth=5'); + expect(shallow.modules.map((module: any) => module.id)).toContain('src/deep/a/b/c'); + expect(deep.modules.map((module: any) => module.id)).toEqual( + expect.arrayContaining(['src/deep/a/b/c/left', 'src/deep/a/b/c/right']) + ); + + const boundary = await request('/api/map?root=src&depth=12'); + expect(boundary.status).toBe(200); + }); + it('counts the files outside each module that reference into it', async () => { const map = await getMap('?root=src&depth=1'); const by = new Map(map.modules.map((m: any) => [m.id, m])); @@ -544,7 +602,7 @@ describe('GET /api/map', () => { }); it('rejects an out-of-range depth as JSON, not as a crash', async () => { - const res = await request('/api/map?depth=9'); + const res = await request('/api/map?depth=13'); expect(res.status).toBe(400); expect(res.type).toBe('application/json; charset=utf-8'); const body = JSON.parse(res.body); @@ -552,6 +610,12 @@ describe('GET /api/map', () => { expect(body.error).toContain('depth'); }); + it.each(['0', '1.5', '3x'])('rejects non-whole depth `%s`', async (depth) => { + const res = await request(`/api/map?depth=${depth}`); + expect(res.status).toBe(400); + expect(JSON.parse(res.body).error).toContain('whole number from 1 to 12'); + }); + it('serves the second identical request from the cache, byte for byte', async () => { // Other cases in this file have already warmed `src` at depth 1; the point // here is the first-then-second transition, so start from a cold cache. diff --git a/src/lib/map-config.ts b/src/lib/map-config.ts new file mode 100644 index 0000000000..8b699070c9 --- /dev/null +++ b/src/lib/map-config.ts @@ -0,0 +1,2 @@ +/** Maximum grouping depth a reader may choose in the Architecture map. */ +export const MAX_MANUAL_MAP_DEPTH = 12; diff --git a/src/ui-server/api/map.ts b/src/ui-server/api/map.ts index b593e86bd9..e32a7d3eed 100644 --- a/src/ui-server/api/map.ts +++ b/src/ui-server/api/map.ts @@ -36,6 +36,7 @@ import type { CodeGraph } from '../../index'; import type { EdgeKind, Language } from '../../types'; +import { MAX_MANUAL_MAP_DEPTH } from '../../lib/map-config'; import { isTestFile } from '../../search/query-utils'; import { badRequest } from './respond'; import { UNCERTAIN_BELOW, toPosixPath, wireList, type WireList } from './wire'; @@ -83,7 +84,7 @@ const MAX_CYCLE_LENGTH = 12; /** Default segments below the root that name a module. */ const DEFAULT_DEPTH = 1; -const MAX_DEPTH = 4; +const MAX_AUTOMATIC_DEPTH = 4; /** * Basenames that stay their own box when they sit loose in a module root. @@ -379,7 +380,7 @@ export function pickDefaultDepth( let fallback = DEFAULT_DEPTH; let fallbackCount = 0; - for (let depth = DEFAULT_DEPTH; depth <= Math.min(MAX_DEPTH, deepest); depth += 1) { + for (let depth = DEFAULT_DEPTH; depth <= Math.min(MAX_AUTOMATIC_DEPTH, deepest); depth += 1) { const tally = tallyModules(files, root, depth); if (tally.count === 0) break; // Deeper only gets more crowded from here. @@ -433,9 +434,12 @@ export function parseMapQuery(query: URLSearchParams): { const rawDepth = query.get('depth'); let depth: number | null = null; if (rawDepth !== null && rawDepth !== '') { - depth = Number.parseInt(rawDepth, 10); - if (!Number.isFinite(depth) || depth < 1 || depth > MAX_DEPTH) { - throw badRequest(`depth must be a whole number from 1 to ${MAX_DEPTH}.`); + if (!/^\d+$/.test(rawDepth)) { + throw badRequest(`depth must be a whole number from 1 to ${MAX_MANUAL_MAP_DEPTH}.`); + } + depth = Number(rawDepth); + if (!Number.isSafeInteger(depth) || depth < 1 || depth > MAX_MANUAL_MAP_DEPTH) { + throw badRequest(`depth must be a whole number from 1 to ${MAX_MANUAL_MAP_DEPTH}.`); } } const rawRoot = query.get('root'); @@ -798,8 +802,9 @@ function tarjan(nodes: readonly string[], edgesOf: (id: string) => readonly stri } /** - * The roots the selector offers: the repository root plus every top-level - * directory that holds indexed files, biggest first. + * The roots the selector offers: the repository root plus every indexed + * directory. Top-level directories stay biggest-first; descendants follow + * their parent so a native select still reads like a directory tree. * * A monorepo's answer to "which project am I looking at" — and on a single * project it is a one-line list nobody has to use. @@ -809,13 +814,27 @@ function rootOptions( ): WireMapPayload['roots'] { const byDir = new Map(); for (const file of files) { - const slash = file.path.indexOf('/'); - if (slash <= 0) continue; - const dir = file.path.slice(0, slash); - byDir.set(dir, (byDir.get(dir) ?? 0) + 1); + const parts = toPosixPath(file.path).split('/').filter(Boolean); + for (let depth = 1; depth < parts.length; depth += 1) { + const dir = parts.slice(0, depth).join('/'); + byDir.set(dir, (byDir.get(dir) ?? 0) + 1); + } } const dirs = [...byDir] - .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) - .map(([root, count]) => ({ root, label: root, files: count })); + .sort(([a], [b]) => { + const aTop = a.split('/')[0]!; + const bTop = b.split('/')[0]!; + const topOrder = (byDir.get(bTop) ?? 0) - (byDir.get(aTop) ?? 0) || aTop.localeCompare(bTop); + if (topOrder !== 0) return topOrder; + return a.localeCompare(b); + }) + .map(([root, count]) => { + const parts = root.split('/'); + return { + root, + label: parts.length === 1 ? root : `${' '.repeat(parts.length - 1)}↳ ${parts.at(-1)}`, + files: count, + }; + }); return [{ root: '', label: 'whole repository', files: files.length }, ...dirs]; } diff --git a/ui/README.md b/ui/README.md index 5f5dd18a36..9efc2d348f 100644 --- a/ui/README.md +++ b/ui/README.md @@ -235,6 +235,11 @@ Mono, so the code grid survives and only the letterforms change. | `#/entry` | entry points — routes, files that run something, tests, hubs | | `#/screens` | screens — the app's screens and the transitions between them | +The map's **Showing** selector includes every indexed directory, including nested +directories such as `supabase/functions/_shared`; its file count covers all indexed +descendants. **Grouping** is automatic by default (up to four levels), or can be set +manually from one through twelve directory levels. + ## Entry points `#/entry` draws `/api/entrypoints` as file groups, reusing the Symbol view's diff --git a/ui/src/components/map/MapSidePanel.svelte b/ui/src/components/map/MapSidePanel.svelte index fdc056ae90..23714b8d24 100644 --- a/ui/src/components/map/MapSidePanel.svelte +++ b/ui/src/components/map/MapSidePanel.svelte @@ -16,6 +16,7 @@ import { plural } from '../../lib/symbol-model'; import type { WireMapLink, WireMapPayload } from '../../lib/api'; import type { MapLayout } from '../../lib/map-model'; + import { MAX_MANUAL_MAP_DEPTH } from '../../../../src/lib/map-config'; interface Props { payload: WireMapPayload; @@ -59,7 +60,7 @@ * choice is wrong for what the reader is looking at — an escape hatch, not * the thing anybody should have to reach for. */ - const DEPTHS = [1, 2, 3, 4] as const; + const DEPTHS = Array.from({ length: MAX_MANUAL_MAP_DEPTH }, (_, index) => index + 1); function depthLabel(depth: number): string { return depth === 1 ? 'top-level folders' : `${depth} folders deep`; From 050186e36dd9ebe9e3e5a851676cf4250e19dc80 Mon Sep 17 00:00:00 2001 From: nakul-malhotra Date: Fri, 18 Sep 2026 11:29:22 -0700 Subject: [PATCH 02/13] feat(map): configure scopes and grouping depth --- __tests__/project-config-viewer.test.ts | 102 +++++++++++++++++++ __tests__/ui-map-api.test.ts | 91 ++++++++++++++--- src/lib/map-config.ts | 25 ++++- src/project-config.ts | 115 +++++++++++++++++++++- src/ui-server/api/map.ts | 46 +++++---- ui/README.md | 27 ++++- ui/src/components/map/MapSidePanel.svelte | 6 +- ui/src/lib/wire.ts | 2 + 8 files changed, 375 insertions(+), 39 deletions(-) create mode 100644 __tests__/project-config-viewer.test.ts diff --git a/__tests__/project-config-viewer.test.ts b/__tests__/project-config-viewer.test.ts new file mode 100644 index 0000000000..d2bebad623 --- /dev/null +++ b/__tests__/project-config-viewer.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + clearProjectConfigCache, + loadViewerMapConfig, +} from '../src/project-config'; +import { defaultLogger, setLogger, type Logger } from '../src/errors'; + +describe('viewer map configuration (codegraph.json)', () => { + let dir: string; + let warnings: string[]; + let logger: Logger; + let nextMtime: number; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-viewer-config-')); + warnings = []; + logger = { + debug: () => {}, + warn: (message) => warnings.push(message), + error: () => {}, + }; + setLogger(logger); + clearProjectConfigCache(); + nextMtime = Date.now(); + }); + + afterEach(() => { + setLogger(defaultLogger); + clearProjectConfigCache(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + function writeConfig(config: unknown): void { + const file = path.join(dir, 'codegraph.json'); + fs.writeFileSync(file, JSON.stringify(config)); + nextMtime += 2_000; + const future = new Date(nextMtime); + fs.utimesSync(file, future, future); + } + + it('uses depth four and no named scopes by default', () => { + expect(loadViewerMapConfig(dir)).toEqual({ maxDepth: 4, scopes: [] }); + }); + + it('loads normalized named scopes and permits maxDepth through 32', () => { + writeConfig({ + viewer: { + map: { + maxDepth: 32, + scopes: [ + { label: 'Backend', root: './supabase/' }, + { label: 'Shared backend', root: 'supabase/functions/_shared' }, + ], + }, + }, + }); + expect(loadViewerMapConfig(dir)).toEqual({ + maxDepth: 32, + scopes: [ + { label: 'Backend', root: 'supabase' }, + { label: 'Shared backend', root: 'supabase/functions/_shared' }, + ], + }); + }); + + it('warns and falls back or skips invalid map settings deterministically', () => { + writeConfig({ + viewer: { + map: { + maxDepth: 33, + scopes: [ + { label: '', root: 'backend' }, + { label: 'Traversal', root: '../backend' }, + { label: 'Absolute', root: '/backend' }, + { label: 'Backend', root: 'backend' }, + { label: 'Backend', root: 'other' }, + { label: 'Duplicate root', root: 'backend' }, + ], + }, + }, + }); + expect(loadViewerMapConfig(dir)).toEqual({ + maxDepth: 4, + scopes: [{ label: 'Backend', root: 'backend' }], + }); + expect(warnings).toHaveLength(6); + }); + + it('refreshes changed settings and returns to defaults after removal', () => { + writeConfig({ viewer: { map: { maxDepth: 12, scopes: [{ label: 'Backend', root: 'backend' }] } } }); + expect(loadViewerMapConfig(dir).maxDepth).toBe(12); + + writeConfig({ viewer: { map: { maxDepth: 1 } } }); + expect(loadViewerMapConfig(dir)).toEqual({ maxDepth: 1, scopes: [] }); + + fs.rmSync(path.join(dir, 'codegraph.json')); + expect(loadViewerMapConfig(dir)).toEqual({ maxDepth: 4, scopes: [] }); + }); +}); diff --git a/__tests__/ui-map-api.test.ts b/__tests__/ui-map-api.test.ts index 197a60e2fb..a2c8c89895 100644 --- a/__tests__/ui-map-api.test.ts +++ b/__tests__/ui-map-api.test.ts @@ -78,6 +78,17 @@ function write(root: string, rel: string, body: string): void { fs.writeFileSync(full, body); } +function writeViewerConfig(config: unknown): void { + const file = path.join(projectRoot, 'codegraph.json'); + fs.writeFileSync(file, JSON.stringify(config)); + const future = new Date(Math.max(fs.statSync(file).mtimeMs, Date.now()) + 2_000); + fs.utimesSync(file, future, future); +} + +function removeViewerConfig(): void { + fs.rmSync(path.join(projectRoot, 'codegraph.json'), { force: true }); +} + beforeAll(async () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-map-')); projectRoot = path.join(tempDir, 'project'); @@ -213,6 +224,7 @@ export function start(): void { 'supabase/functions/worker/serve.ts', `import { authenticate } from '../_shared/auth';\n\nexport const serve = () => authenticate();\n` ); + write(projectRoot, 'supabasex/functions/other.ts', `export const other = true;\n`); write( projectRoot, @@ -226,7 +238,7 @@ export function testBoot(): string[] { ); const cg = CodeGraph.initSync(projectRoot, { - config: { include: ['src/**/*.ts', 'supabase/**/*.ts', '__tests__/**/*.ts'], exclude: [] }, + config: { include: ['src/**/*.ts', 'supabase/**/*.ts', 'supabasex/**/*.ts', '__tests__/**/*.ts'], exclude: [] }, }); await cg.indexAll(); cg.resolveReferences(); @@ -429,6 +441,7 @@ describe('GET /api/map', () => { const map = await getMap(); expect(map.root).toBe('src'); expect(map.depth).toBe(1); + expect(map.maxDepth).toBe(4); const ids = map.modules.map((m: any) => m.id); expect(ids).toEqual([ @@ -566,16 +579,72 @@ describe('GET /api/map', () => { expect(map.modules.flatMap((module: any) => module.fileList.items)).not.toContain('src/index.ts'); }); - it('uses manual depths through 12, including levels deeper than the automatic cap', async () => { - const shallow = await getMap('?root=src&depth=4'); - const deep = await getMap('?root=src&depth=5'); - expect(shallow.modules.map((module: any) => module.id)).toContain('src/deep/a/b/c'); - expect(deep.modules.map((module: any) => module.id)).toEqual( - expect.arrayContaining(['src/deep/a/b/c/left', 'src/deep/a/b/c/right']) - ); + it('uses configured scopes and grouping depths without leaking prefix siblings', async () => { + writeViewerConfig({ + viewer: { + map: { + maxDepth: 12, + scopes: [ + { label: 'Backend', root: 'supabase' }, + { label: 'Shared backend', root: 'supabase/functions/_shared' }, + ], + }, + }, + }); + try { + const configured = await getMap(); + expect(configured.maxDepth).toBe(12); + expect(configured.roots.slice(0, 3)).toEqual([ + { root: '', label: 'whole repository', files: configured.index.files }, + { root: 'supabase', label: 'Backend', files: 2 }, + { root: 'supabase/functions/_shared', label: 'Shared backend', files: 1 }, + ]); + + const scoped = await getMap('?root=supabase&depth=1'); + const paths = scoped.modules.flatMap((module: any) => module.fileList.items); + expect(paths).toEqual(expect.arrayContaining([ + 'supabase/functions/_shared/auth.ts', + 'supabase/functions/worker/serve.ts', + ])); + expect(paths).not.toContain('supabasex/functions/other.ts'); + + const shallow = await getMap('?root=src&depth=4'); + const deep = await getMap('?root=src&depth=5'); + expect(shallow.modules.map((module: any) => module.id)).toContain('src/deep/a/b/c'); + expect(deep.modules.map((module: any) => module.id)).toEqual( + expect.arrayContaining(['src/deep/a/b/c/left', 'src/deep/a/b/c/right']) + ); + expect((await request('/api/map?root=src&depth=12')).status).toBe(200); + } finally { + removeViewerConfig(); + } + }); + + it('caps automatic depth at a smaller configured maximum', async () => { + writeViewerConfig({ viewer: { map: { maxDepth: 1 } } }); + try { + const map = await getMap('?root=src'); + expect(map.maxDepth).toBe(1); + expect(map.depth).toBeLessThanOrEqual(1); + } finally { + removeViewerConfig(); + } + }); + + it('refreshes the map configuration cache after edits and removal', async () => { + resetMapCache(); + writeViewerConfig({ viewer: { map: { maxDepth: 12, scopes: [{ label: 'Backend', root: 'supabase' }] } } }); + expect((await getMap()).maxDepth).toBe(12); + + writeViewerConfig({ viewer: { map: { maxDepth: 1, scopes: [{ label: 'Only source', root: 'src' }] } } }); + const changed = await getMap(); + expect(changed.maxDepth).toBe(1); + expect(changed.roots[1]).toEqual({ root: 'src', label: 'Only source', files: 11 }); - const boundary = await request('/api/map?root=src&depth=12'); - expect(boundary.status).toBe(200); + removeViewerConfig(); + const removed = await getMap(); + expect(removed.maxDepth).toBe(4); + expect(removed.roots.some((root: any) => root.label === 'Only source')).toBe(false); }); it('counts the files outside each module that reference into it', async () => { @@ -613,7 +682,7 @@ describe('GET /api/map', () => { it.each(['0', '1.5', '3x'])('rejects non-whole depth `%s`', async (depth) => { const res = await request(`/api/map?depth=${depth}`); expect(res.status).toBe(400); - expect(JSON.parse(res.body).error).toContain('whole number from 1 to 12'); + expect(JSON.parse(res.body).error).toContain('whole number from 1 to 4'); }); it('serves the second identical request from the cache, byte for byte', async () => { diff --git a/src/lib/map-config.ts b/src/lib/map-config.ts index 8b699070c9..377ba75936 100644 --- a/src/lib/map-config.ts +++ b/src/lib/map-config.ts @@ -1,2 +1,23 @@ -/** Maximum grouping depth a reader may choose in the Architecture map. */ -export const MAX_MANUAL_MAP_DEPTH = 12; +/** The zero-config grouping limit, retained for existing projects and adapters. */ +export const DEFAULT_MAP_MAX_DEPTH = 4; + +/** Highest grouping limit accepted from a project's `codegraph.json`. */ +export const MAX_MAP_MAX_DEPTH = 32; + +export interface MapScope { + label: string; + root: string; +} + +export interface ViewerMapConfig { + maxDepth: number; + scopes: MapScope[]; +} + +/** Normalize a map root without resolving it against the filesystem. */ +export function normalizeMapRoot(raw: string | undefined): string { + let root = (raw ?? '').trim().replace(/\\/g, '/'); + while (root.startsWith('./')) root = root.slice(2); + while (root.endsWith('/')) root = root.slice(0, -1); + return root === '.' || root === '/' ? '' : root; +} diff --git a/src/project-config.ts b/src/project-config.ts index 56c5debe18..747b6bd814 100644 --- a/src/project-config.ts +++ b/src/project-config.ts @@ -27,6 +27,13 @@ import * as path from 'path'; import { Language } from './types'; import { isLanguageSupported } from './extraction/grammars'; import { logWarn } from './errors'; +import { + DEFAULT_MAP_MAX_DEPTH, + MAX_MAP_MAX_DEPTH, + normalizeMapRoot, + type MapScope, + type ViewerMapConfig, +} from './lib/map-config'; /** Filename of the project-scoped config, resolved relative to the project root. */ export const PROJECT_CONFIG_FILENAME = 'codegraph.json'; @@ -82,6 +89,13 @@ export interface ProjectConfig { * beyond the built-ins. */ deprioritize?: string[]; + /** Optional read-only browser viewer settings. */ + viewer?: { + map?: { + maxDepth?: number; + scopes?: Array<{ label?: string; root?: string }>; + }; + }; } /** Parsed, validated view of a project's `codegraph.json`. */ @@ -91,6 +105,7 @@ interface ParsedConfig { exclude: string[]; deprioritize: string[]; include: string[]; + viewerMap: ViewerMapConfig; } interface CacheEntry { @@ -108,12 +123,17 @@ const cache = new Map(); /** Shared frozen empties so the no-config path allocates nothing. */ const EMPTY_EXTENSIONS: Record = Object.freeze({}); +const EMPTY_VIEWER_MAP: ViewerMapConfig = Object.freeze({ + maxDepth: DEFAULT_MAP_MAX_DEPTH, + scopes: Object.freeze([]) as unknown as MapScope[], +}); const EMPTY_CONFIG: ParsedConfig = Object.freeze({ extensions: EMPTY_EXTENSIONS, includeIgnored: Object.freeze([]) as unknown as string[], exclude: Object.freeze([]) as unknown as string[], include: Object.freeze([]) as unknown as string[], deprioritize: Object.freeze([]) as unknown as string[], + viewerMap: EMPTY_VIEWER_MAP, }); /** @@ -167,16 +187,102 @@ function parseConfig(file: string): ParsedConfig { const exclude = extractExclude(parsed, file); const include = extractInclude(parsed, file); const deprioritize = extractPatternList(parsed, file, 'deprioritize'); + const viewerMap = extractViewerMapConfig(parsed, file); if ( extensions === EMPTY_EXTENSIONS && includeIgnored.length === 0 && exclude.length === 0 && include.length === 0 && - deprioritize.length === 0 + deprioritize.length === 0 && + viewerMap === EMPTY_VIEWER_MAP ) { return EMPTY_CONFIG; } - return { extensions, includeIgnored, exclude, include, deprioritize }; + return { extensions, includeIgnored, exclude, include, deprioritize, viewerMap }; +} + +function extractViewerMapConfig(parsed: object, file: string): ViewerMapConfig { + const viewer = (parsed as ProjectConfig).viewer; + if (viewer === undefined) return EMPTY_VIEWER_MAP; + if (!viewer || typeof viewer !== 'object' || Array.isArray(viewer)) { + logWarn(`Ignoring "viewer" in ${PROJECT_CONFIG_FILENAME}: must be an object`, { file }); + return EMPTY_VIEWER_MAP; + } + const map = viewer.map; + if (map === undefined) return EMPTY_VIEWER_MAP; + if (!map || typeof map !== 'object' || Array.isArray(map)) { + logWarn(`Ignoring "viewer.map" in ${PROJECT_CONFIG_FILENAME}: must be an object`, { file }); + return EMPTY_VIEWER_MAP; + } + + let maxDepth = DEFAULT_MAP_MAX_DEPTH; + if (map.maxDepth !== undefined) { + if ( + typeof map.maxDepth !== 'number' || + !Number.isSafeInteger(map.maxDepth) || + map.maxDepth < 1 || + map.maxDepth > MAX_MAP_MAX_DEPTH + ) { + logWarn( + `Ignoring "viewer.map.maxDepth" in ${PROJECT_CONFIG_FILENAME}: must be a whole number from 1 to ${MAX_MAP_MAX_DEPTH}`, + { file } + ); + } else { + maxDepth = map.maxDepth; + } + } + + const scopes = extractMapScopes(map.scopes, file); + return maxDepth === DEFAULT_MAP_MAX_DEPTH && scopes.length === 0 + ? EMPTY_VIEWER_MAP + : { maxDepth, scopes }; +} + +function extractMapScopes(raw: unknown, file: string): MapScope[] { + if (raw === undefined) return []; + if (!Array.isArray(raw)) { + logWarn(`Ignoring "viewer.map.scopes" in ${PROJECT_CONFIG_FILENAME}: must be an array`, { file }); + return []; + } + + const scopes: MapScope[] = []; + const labels = new Set(); + const roots = new Set(); + for (const scope of raw) { + if (!scope || typeof scope !== 'object' || Array.isArray(scope)) { + logWarn(`Ignoring a "viewer.map.scopes" entry in ${PROJECT_CONFIG_FILENAME}: must be an object`, { file }); + continue; + } + const { label, root } = scope as { label?: unknown; root?: unknown }; + if (typeof label !== 'string' || !label.trim()) { + logWarn(`Ignoring a "viewer.map.scopes" entry in ${PROJECT_CONFIG_FILENAME}: label must be a non-empty string`, { file }); + continue; + } + if (typeof root !== 'string') { + logWarn(`Ignoring scope "${label.trim()}" in ${PROJECT_CONFIG_FILENAME}: root must be a string`, { file }); + continue; + } + const slashRoot = root.trim().replace(/\\/g, '/'); + const normalizedRoot = normalizeMapRoot(root); + if ( + !normalizedRoot || + slashRoot.startsWith('/') || + /^[A-Za-z]:\//.test(slashRoot) || + normalizedRoot.split('/').includes('..') + ) { + logWarn(`Ignoring scope "${label.trim()}" in ${PROJECT_CONFIG_FILENAME}: root must be a relative directory without traversal`, { file }); + continue; + } + const normalizedLabel = label.trim(); + if (labels.has(normalizedLabel) || roots.has(normalizedRoot)) { + logWarn(`Ignoring duplicate viewer map scope "${normalizedLabel}" in ${PROJECT_CONFIG_FILENAME}: labels and roots are first-wins`, { file }); + continue; + } + labels.add(normalizedLabel); + roots.add(normalizedRoot); + scopes.push({ label: normalizedLabel, root: normalizedRoot }); + } + return scopes; } /** @@ -391,6 +497,11 @@ export function loadIncludePatterns(rootDir: string): string[] { return loadParsedConfig(rootDir).include; } +/** Load validated named scopes and grouping limit for the read-only map viewer. */ +export function loadViewerMapConfig(rootDir: string): ViewerMapConfig { + return loadParsedConfig(rootDir).viewerMap; +} + /** Test/maintenance hook: forget cached config (e.g. after rewriting it in a test). */ export function clearProjectConfigCache(): void { cache.clear(); diff --git a/src/ui-server/api/map.ts b/src/ui-server/api/map.ts index e32a7d3eed..c35b9fa0bd 100644 --- a/src/ui-server/api/map.ts +++ b/src/ui-server/api/map.ts @@ -36,7 +36,8 @@ import type { CodeGraph } from '../../index'; import type { EdgeKind, Language } from '../../types'; -import { MAX_MANUAL_MAP_DEPTH } from '../../lib/map-config'; +import { loadViewerMapConfig } from '../../project-config'; +import { DEFAULT_MAP_MAX_DEPTH, normalizeMapRoot } from '../../lib/map-config'; import { isTestFile } from '../../search/query-utils'; import { badRequest } from './respond'; import { UNCERTAIN_BELOW, toPosixPath, wireList, type WireList } from './wire'; @@ -179,6 +180,8 @@ export interface WireMapCycle { export interface WireMapPayload { root: string; depth: number; + /** Effective configured upper bound for a reader-selected grouping depth. */ + maxDepth: number; /** Every root the selector may offer, this index's own directories. */ roots: Array<{ root: string; label: string; files: number }>; modules: WireMapModule[]; @@ -210,13 +213,7 @@ export interface MapQuery { // ============================================================================= /** Strip a trailing slash and any leading `./`, so `src/` and `src` are one root. */ -export function normalizeRoot(raw: string | undefined): string { - let root = (raw ?? '').trim().replace(/\\/g, '/'); - while (root.startsWith('./')) root = root.slice(2); - while (root.endsWith('/')) root = root.slice(0, -1); - if (root === '.' || root === '/') return ''; - return root; -} +export const normalizeRoot = normalizeMapRoot; function stemOf(basename: string): string { const dot = basename.indexOf('.'); @@ -365,7 +362,8 @@ function tallyModules( */ export function pickDefaultDepth( files: ReadonlyArray<{ path: string; symbols: number; test: boolean }>, - root: string + root: string, + maxDepth = DEFAULT_MAP_MAX_DEPTH ): number { // Past the deepest directory, a bigger number only renames boxes to // `src/a/(root files)`. There is nothing below the leaves. @@ -380,7 +378,7 @@ export function pickDefaultDepth( let fallback = DEFAULT_DEPTH; let fallbackCount = 0; - for (let depth = DEFAULT_DEPTH; depth <= Math.min(MAX_AUTOMATIC_DEPTH, deepest); depth += 1) { + for (let depth = DEFAULT_DEPTH; depth <= Math.min(MAX_AUTOMATIC_DEPTH, maxDepth, deepest); depth += 1) { const tally = tallyModules(files, root, depth); if (tally.count === 0) break; // Deeper only gets more crowded from here. @@ -427,7 +425,7 @@ export function resetMapCache(): void { * to survive parsing: a depth defaulted to 1 here is indistinguishable from a * reader who asked for 1, and {@link pickDefaultDepth} would never run. */ -export function parseMapQuery(query: URLSearchParams): { +export function parseMapQuery(query: URLSearchParams, maxDepth = DEFAULT_MAP_MAX_DEPTH): { root: string | null; depth: number | null; } { @@ -435,11 +433,11 @@ export function parseMapQuery(query: URLSearchParams): { let depth: number | null = null; if (rawDepth !== null && rawDepth !== '') { if (!/^\d+$/.test(rawDepth)) { - throw badRequest(`depth must be a whole number from 1 to ${MAX_MANUAL_MAP_DEPTH}.`); + throw badRequest(`depth must be a whole number from 1 to ${maxDepth}.`); } depth = Number(rawDepth); - if (!Number.isSafeInteger(depth) || depth < 1 || depth > MAX_MANUAL_MAP_DEPTH) { - throw badRequest(`depth must be a whole number from 1 to ${MAX_MANUAL_MAP_DEPTH}.`); + if (!Number.isSafeInteger(depth) || depth < 1 || depth > maxDepth) { + throw badRequest(`depth must be a whole number from 1 to ${maxDepth}.`); } } const rawRoot = query.get('root'); @@ -484,7 +482,8 @@ function collapseLoneRootFiles(ids: ReadonlySet): Map { export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchParams): WireMapPayload { const started = Date.now(); - const { root: requestedRoot, depth: requestedDepth } = parseMapQuery(query); + const viewerMap = loadViewerMapConfig(projectRoot); + const { root: requestedRoot, depth: requestedDepth } = parseMapQuery(query, viewerMap.maxDepth); const fileRecords = cg.getFiles().map((file) => { const path = toPosixPath(file.path); @@ -501,7 +500,7 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar // Root first, then depth against THAT root: how finely to cut depends on // what is being cut. Choosing `src` and then asking for one level under it // is the same question as choosing the whole project and asking for two. - const depth = requestedDepth ?? pickDefaultDepth(fileRecords, root); + const depth = requestedDepth ?? pickDefaultDepth(fileRecords, root, viewerMap.maxDepth); const stats = cg.getStats(); const key = [ projectRoot, @@ -510,6 +509,7 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar stats.fileCount, root, depth, + JSON.stringify(viewerMap), ].join('\u0000'); const hit = cache.get(key); if (hit) { @@ -623,7 +623,8 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar const payload: WireMapPayload = { root, depth, - roots: rootOptions(fileRecords), + maxDepth: viewerMap.maxDepth, + roots: rootOptions(fileRecords, viewerMap.scopes), modules: [...modules.values()] .map((entry) => { const shown = entry.paths.slice().sort().slice(0, MAX_FILES_PER_MODULE); @@ -810,7 +811,8 @@ function tarjan(nodes: readonly string[], edgesOf: (id: string) => readonly stri * project it is a one-line list nobody has to use. */ function rootOptions( - files: ReadonlyArray<{ path: string; symbols: number }> + files: ReadonlyArray<{ path: string; symbols: number }>, + scopes: ReadonlyArray<{ label: string; root: string }> ): WireMapPayload['roots'] { const byDir = new Map(); for (const file of files) { @@ -836,5 +838,11 @@ function rootOptions( files: count, }; }); - return [{ root: '', label: 'whole repository', files: files.length }, ...dirs]; + const named = scopes.map((scope) => ({ + root: scope.root, + label: scope.label, + files: byDir.get(scope.root) ?? 0, + })); + const namedRoots = new Set(scopes.map((scope) => scope.root)); + return [{ root: '', label: 'whole repository', files: files.length }, ...named, ...dirs.filter((dir) => !namedRoots.has(dir.root))]; } diff --git a/ui/README.md b/ui/README.md index 9efc2d348f..b886cef4dd 100644 --- a/ui/README.md +++ b/ui/README.md @@ -236,9 +236,30 @@ Mono, so the code grid survives and only the letterforms change. | `#/screens` | screens — the app's screens and the transitions between them | The map's **Showing** selector includes every indexed directory, including nested -directories such as `supabase/functions/_shared`; its file count covers all indexed -descendants. **Grouping** is automatic by default (up to four levels), or can be set -manually from one through twelve directory levels. +directories; its file count covers all indexed descendants. **Grouping** is automatic +by default (up to four levels). + +Projects can add named map scopes and raise the manual grouping limit in their existing +root `codegraph.json`: + +```json +{ + "viewer": { + "map": { + "maxDepth": 12, + "scopes": [ + { "label": "Application", "root": "server" }, + { "label": "Shared application", "root": "server/shared" } + ] + } + } +} +``` + +`maxDepth` accepts whole numbers from 1 through 32 and limits both the selector and +explicit map URLs. Automatic grouping never exceeds four levels or a smaller configured +limit. Scope roots are relative indexed directories; invalid settings are ignored with a +warning. Refresh the browser after editing `codegraph.json` to load the new settings. ## Entry points diff --git a/ui/src/components/map/MapSidePanel.svelte b/ui/src/components/map/MapSidePanel.svelte index 23714b8d24..4b7de7fead 100644 --- a/ui/src/components/map/MapSidePanel.svelte +++ b/ui/src/components/map/MapSidePanel.svelte @@ -16,7 +16,7 @@ import { plural } from '../../lib/symbol-model'; import type { WireMapLink, WireMapPayload } from '../../lib/api'; import type { MapLayout } from '../../lib/map-model'; - import { MAX_MANUAL_MAP_DEPTH } from '../../../../src/lib/map-config'; + import { DEFAULT_MAP_MAX_DEPTH } from '../../../../src/lib/map-config'; interface Props { payload: WireMapPayload; @@ -60,7 +60,9 @@ * choice is wrong for what the reader is looking at — an escape hatch, not * the thing anybody should have to reach for. */ - const DEPTHS = Array.from({ length: MAX_MANUAL_MAP_DEPTH }, (_, index) => index + 1); + const DEPTHS = $derived( + Array.from({ length: payload.maxDepth ?? DEFAULT_MAP_MAX_DEPTH }, (_, index) => index + 1) + ); function depthLabel(depth: number): string { return depth === 1 ? 'top-level folders' : `${depth} folders deep`; diff --git a/ui/src/lib/wire.ts b/ui/src/lib/wire.ts index 95d04f09cc..6f3dd7b135 100644 --- a/ui/src/lib/wire.ts +++ b/ui/src/lib/wire.ts @@ -641,6 +641,8 @@ export interface WireMapCycle { export interface WireMapPayload { root: string; depth: number; + /** Absent in older library adapters; the viewer falls back to four. */ + maxDepth?: number; roots: Array<{ root: string; label: string; files: number }>; modules: WireMapModule[]; links: WireMapLink[]; From 6592902c8bbc6597e524fd0fdd588110d283d66d Mon Sep 17 00:00:00 2001 From: nakul-malhotra Date: Fri, 18 Sep 2026 11:31:49 -0700 Subject: [PATCH 03/13] fix(map): validate normalized scope roots --- __tests__/project-config-viewer.test.ts | 15 +++++++++++++++ src/lib/map-config.ts | 8 ++++---- src/project-config.ts | 12 ++++++++---- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/__tests__/project-config-viewer.test.ts b/__tests__/project-config-viewer.test.ts index d2bebad623..5577b4e00a 100644 --- a/__tests__/project-config-viewer.test.ts +++ b/__tests__/project-config-viewer.test.ts @@ -89,6 +89,21 @@ describe('viewer map configuration (codegraph.json)', () => { expect(warnings).toHaveLength(6); }); + it.each([ + ['viewer array', { viewer: [] }], + ['map array', { viewer: { map: [] } }], + ['scope array entry', { viewer: { map: { scopes: [[]] } } }], + ['non-string scope label', { viewer: { map: { scopes: [{ label: 1, root: 'backend' }] } } }], + ['non-string scope root', { viewer: { map: { scopes: [{ label: 'Backend', root: 1 }] } } }], + ['normalized Unix absolute root', { viewer: { map: { scopes: [{ label: 'Backend', root: './/backend' }] } } }], + ['normalized Windows absolute root', { viewer: { map: { scopes: [{ label: 'Backend', root: './C:/backend' }] } } }], + ['Windows absolute root', { viewer: { map: { scopes: [{ label: 'Backend', root: 'C:/backend' }] } } }], + ])('warns and safely ignores a %s', (_case, config) => { + writeConfig(config); + expect(loadViewerMapConfig(dir)).toEqual({ maxDepth: 4, scopes: [] }); + expect(warnings).toHaveLength(1); + }); + it('refreshes changed settings and returns to defaults after removal', () => { writeConfig({ viewer: { map: { maxDepth: 12, scopes: [{ label: 'Backend', root: 'backend' }] } } }); expect(loadViewerMapConfig(dir).maxDepth).toBe(12); diff --git a/src/lib/map-config.ts b/src/lib/map-config.ts index 377ba75936..096902a304 100644 --- a/src/lib/map-config.ts +++ b/src/lib/map-config.ts @@ -5,13 +5,13 @@ export const DEFAULT_MAP_MAX_DEPTH = 4; export const MAX_MAP_MAX_DEPTH = 32; export interface MapScope { - label: string; - root: string; + readonly label: string; + readonly root: string; } export interface ViewerMapConfig { - maxDepth: number; - scopes: MapScope[]; + readonly maxDepth: number; + readonly scopes: readonly MapScope[]; } /** Normalize a map root without resolving it against the filesystem. */ diff --git a/src/project-config.ts b/src/project-config.ts index 747b6bd814..79bb905d84 100644 --- a/src/project-config.ts +++ b/src/project-config.ts @@ -93,7 +93,7 @@ export interface ProjectConfig { viewer?: { map?: { maxDepth?: number; - scopes?: Array<{ label?: string; root?: string }>; + scopes?: MapScope[]; }; }; } @@ -125,7 +125,7 @@ const cache = new Map(); const EMPTY_EXTENSIONS: Record = Object.freeze({}); const EMPTY_VIEWER_MAP: ViewerMapConfig = Object.freeze({ maxDepth: DEFAULT_MAP_MAX_DEPTH, - scopes: Object.freeze([]) as unknown as MapScope[], + scopes: Object.freeze([]), }); const EMPTY_CONFIG: ParsedConfig = Object.freeze({ extensions: EMPTY_EXTENSIONS, @@ -266,8 +266,8 @@ function extractMapScopes(raw: unknown, file: string): MapScope[] { const normalizedRoot = normalizeMapRoot(root); if ( !normalizedRoot || - slashRoot.startsWith('/') || - /^[A-Za-z]:\//.test(slashRoot) || + isAbsoluteMapRoot(slashRoot) || + isAbsoluteMapRoot(normalizedRoot) || normalizedRoot.split('/').includes('..') ) { logWarn(`Ignoring scope "${label.trim()}" in ${PROJECT_CONFIG_FILENAME}: root must be a relative directory without traversal`, { file }); @@ -285,6 +285,10 @@ function extractMapScopes(raw: unknown, file: string): MapScope[] { return scopes; } +function isAbsoluteMapRoot(root: string): boolean { + return root.startsWith('/') || /^[A-Za-z]:\//.test(root); +} + /** * Validate the `extensions` map. Every failure mode degrades to "no overrides * from this entry" — a bad value or a typo'd language never throws. From 15557367a2eb54b25b9586c1cff39a3294f7ed76 Mon Sep 17 00:00:00 2001 From: nakul-malhotra Date: Fri, 18 Sep 2026 11:39:30 -0700 Subject: [PATCH 04/13] fix(map): preserve configured deep links --- __tests__/ui-package.test.ts | 19 +++++++++++++++++++ ui/src/components/map/MapSidePanel.svelte | 6 ++++-- ui/src/lib/router.svelte.ts | 7 +++++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/__tests__/ui-package.test.ts b/__tests__/ui-package.test.ts index ed0ed19d3d..8946bdfa06 100644 --- a/__tests__/ui-package.test.ts +++ b/__tests__/ui-package.test.ts @@ -692,6 +692,25 @@ describe('@colbymchenry/codegraph-ui — the seams', () => { expect(chosen).toMatchObject({ view: 'symbol', id: 'function:x' }); }); + it('preserves valid deep map grouping links for the API to validate', async () => { + const { parseHash } = await import('../ui/src/lib/router.svelte'); + + for (const depth of [5, 12, 32]) { + expect(parseHash(`#/map?root=src&depth=${depth}`).route).toMatchObject({ + view: 'map', + root: 'src', + depth, + }); + } + for (const depth of ['1.5', '12x']) { + expect(parseHash(`#/map?root=src&depth=${depth}`).route).toMatchObject({ + view: 'map', + root: 'src', + depth: null, + }); + } + }); + it('sends every nav tab to its own view', async () => { const { parseHash } = await import('../ui/src/lib/router.svelte'); const { entryHref, screensHref, stepsHref, deadHref } = await import( diff --git a/ui/src/components/map/MapSidePanel.svelte b/ui/src/components/map/MapSidePanel.svelte index 4b7de7fead..106e1d7421 100644 --- a/ui/src/components/map/MapSidePanel.svelte +++ b/ui/src/components/map/MapSidePanel.svelte @@ -16,7 +16,9 @@ import { plural } from '../../lib/symbol-model'; import type { WireMapLink, WireMapPayload } from '../../lib/api'; import type { MapLayout } from '../../lib/map-model'; - import { DEFAULT_MAP_MAX_DEPTH } from '../../../../src/lib/map-config'; + + /** Older independently packaged adapters did not send `maxDepth`. */ + const LEGACY_ADAPTER_MAX_DEPTH = 4; interface Props { payload: WireMapPayload; @@ -61,7 +63,7 @@ * the thing anybody should have to reach for. */ const DEPTHS = $derived( - Array.from({ length: payload.maxDepth ?? DEFAULT_MAP_MAX_DEPTH }, (_, index) => index + 1) + Array.from({ length: payload.maxDepth ?? LEGACY_ADAPTER_MAX_DEPTH }, (_, index) => index + 1) ); function depthLabel(depth: number): string { diff --git a/ui/src/lib/router.svelte.ts b/ui/src/lib/router.svelte.ts index de725c15c6..d265717b02 100644 --- a/ui/src/lib/router.svelte.ts +++ b/ui/src/lib/router.svelte.ts @@ -149,11 +149,14 @@ export function parseHash(hash: string): RouterLocation { // grouping — top-level directories — that is wrong for every project whose // program lives under a single `src/`. const root = params.get('root'); - const depth = Number.parseInt(params.get('depth') ?? '', 10); + const rawDepth = params.get('depth'); + const depth = rawDepth !== null && /^\d+$/.test(rawDepth) ? Number(rawDepth) : Number.NaN; route = { view: 'map', root: root === null ? null : root, - depth: Number.isFinite(depth) && depth >= 1 && depth <= 4 ? depth : null, + // The server owns the configured upper bound; keep any valid integer + // link intact so an explicit configured depth survives the round trip. + depth: Number.isSafeInteger(depth) && depth >= 1 ? depth : null, tests: params.get('tests') === '1', }; } else if (head === 'entry' && rest.length === 0) { From 0f2688067300e002446749c0d2b9a9f7ecd5acdb Mon Sep 17 00:00:00 2001 From: nakul-malhotra Date: Fri, 18 Sep 2026 12:11:56 -0700 Subject: [PATCH 05/13] feat(map): focus transitive module relationships --- __tests__/ui-map-api.test.ts | 28 ++++++- __tests__/ui-map-model.test.ts | 35 +++++++++ __tests__/ui-package.test.ts | 14 ++++ src/ui-server/api/map.ts | 24 +++++- ui/README.md | 8 ++ ui/src/App.svelte | 9 ++- ui/src/components/map/MapSidePanel.svelte | 33 +++++++- ui/src/index.ts | 2 + ui/src/lib/adapter.ts | 3 + ui/src/lib/api.ts | 2 +- ui/src/lib/map-focus.ts | 45 +++++++++++ ui/src/lib/navigation.ts | 8 ++ ui/src/lib/router.svelte.ts | 19 ++++- ui/src/lib/wire.ts | 2 + ui/src/views/MapView.svelte | 95 ++++++++++++++++++++--- 15 files changed, 309 insertions(+), 18 deletions(-) create mode 100644 ui/src/lib/map-focus.ts diff --git a/__tests__/ui-map-api.test.ts b/__tests__/ui-map-api.test.ts index a2c8c89895..0b1787b8b5 100644 --- a/__tests__/ui-map-api.test.ts +++ b/__tests__/ui-map-api.test.ts @@ -225,6 +225,11 @@ export function start(): void { `import { authenticate } from '../_shared/auth';\n\nexport const serve = () => authenticate();\n` ); write(projectRoot, 'supabasex/functions/other.ts', `export const other = true;\n`); + write( + projectRoot, + 'tools/consumer.ts', + `import { Engine } from '../src/core/engine';\n\nexport const consume = () => new Engine().boot();\n` + ); write( projectRoot, @@ -238,7 +243,7 @@ export function testBoot(): string[] { ); const cg = CodeGraph.initSync(projectRoot, { - config: { include: ['src/**/*.ts', 'supabase/**/*.ts', 'supabasex/**/*.ts', '__tests__/**/*.ts'], exclude: [] }, + config: { include: ['src/**/*.ts', 'supabase/**/*.ts', 'supabasex/**/*.ts', 'tools/**/*.ts', '__tests__/**/*.ts'], exclude: [] }, }); await cg.indexAll(); cg.resolveReferences(); @@ -705,4 +710,25 @@ describe('GET /api/map', () => { expect(all.root).toBe(''); expect(all.modules.map((m: any) => m.id)).not.toEqual(src.modules.map((m: any) => m.id)); }); + + it('uses repository context to retain a nested scope identity and outside callers', async () => { + const scoped = await getMap('?root=src&depth=1'); + const repository = await getMap('?root=src&depth=1&context=repository'); + + expect(repository.context).toBe('repository'); + expect(repository.modules.map((module: any) => module.id)).toEqual( + expect.arrayContaining(['src/core', 'tools']) + ); + const scopedCore = scoped.modules.find((module: any) => module.id === 'src/core'); + const repositoryCore = repository.modules.find((module: any) => module.id === 'src/core'); + expect(repositoryCore).toMatchObject({ + id: scopedCore.id, + files: scopedCore.files, + symbols: scopedCore.symbols, + fileList: scopedCore.fileList, + }); + expect(repository.links).toEqual( + expect.arrayContaining([expect.objectContaining({ source: 'tools', target: 'src/core' })]) + ); + }); }); diff --git a/__tests__/ui-map-model.test.ts b/__tests__/ui-map-model.test.ts index f04a51ffea..61ccb5f55a 100644 --- a/__tests__/ui-map-model.test.ts +++ b/__tests__/ui-map-model.test.ts @@ -32,6 +32,7 @@ import { NODE_HEIGHT, type MapLayout, } from '../ui/src/lib/map-model'; +import { focusMapPayload } from '../ui/src/lib/map-focus'; import type { WireMapLink, WireMapModule } from '../ui/src/lib/api'; /* ------------------------------------------------------------- fixtures -- */ @@ -76,6 +77,40 @@ const OPTS = { includeTests: false }; /* ---------------------------------------------------------------- specs -- */ +describe('focusMapPayload', () => { + const modules = [ + mod('app'), + mod('core'), + mod('db'), + mod('thin'), + mod('cycle'), + mod('test-bridge', { test: true }), + mod('isolated'), + mod('unrelated'), + ]; + const links = [ + link('app', 'core', 10), + link('core', 'db', 10), + link('db', 'cycle', 1), + link('cycle', 'core', 1), + link('core', 'thin', 1), + link('test-bridge', 'db', 10), + link('unrelated', 'db', 10), + ]; + + it('walks complete eligible links transitively in either direction and retains isolates', () => { + const payload = { modules, links } as any; + expect(focusMapPayload(payload, 'app', 'depends-on', false)?.modules.map((module) => module.id)) + .toEqual(['app', 'core', 'db', 'thin', 'cycle']); + expect(focusMapPayload(payload, 'db', 'used-by', false)?.modules.map((module) => module.id)) + .toEqual(['app', 'core', 'db', 'cycle', 'unrelated']); + expect(focusMapPayload(payload, 'db', 'used-by', true)?.modules.map((module) => module.id)) + .toEqual(['app', 'core', 'db', 'cycle', 'test-bridge', 'unrelated']); + expect(focusMapPayload(payload, 'isolated', 'depends-on', false)?.modules.map((module) => module.id)) + .toEqual(['isolated']); + }); +}); + describe('nodeWidth', () => { it('fits the wider of the two lines and never goes under the floor', () => { expect(nodeWidth('ui')).toBe(110); diff --git a/__tests__/ui-package.test.ts b/__tests__/ui-package.test.ts index 8946bdfa06..06a432b6d3 100644 --- a/__tests__/ui-package.test.ts +++ b/__tests__/ui-package.test.ts @@ -711,6 +711,20 @@ describe('@colbymchenry/codegraph-ui — the seams', () => { } }); + it('restores explicit focus state from a map deep link', async () => { + const { parseHash } = await import('../ui/src/lib/router.svelte'); + + expect(parseHash('#/map?root=src&depth=12&tests=1&focus=src%2Fcore&direction=used-by').route) + .toMatchObject({ + view: 'map', + root: 'src', + depth: 12, + tests: true, + focus: 'src/core', + direction: 'used-by', + }); + }); + it('sends every nav tab to its own view', async () => { const { parseHash } = await import('../ui/src/lib/router.svelte'); const { entryHref, screensHref, stepsHref, deadHref } = await import( diff --git a/src/ui-server/api/map.ts b/src/ui-server/api/map.ts index c35b9fa0bd..b083daf666 100644 --- a/src/ui-server/api/map.ts +++ b/src/ui-server/api/map.ts @@ -180,6 +180,8 @@ export interface WireMapCycle { export interface WireMapPayload { root: string; depth: number; + /** The aggregation extent; older adapters may omit it. */ + context: 'scope' | 'repository'; /** Effective configured upper bound for a reader-selected grouping depth. */ maxDepth: number; /** Every root the selector may offer, this index's own directories. */ @@ -206,6 +208,7 @@ export interface WireMapPayload { export interface MapQuery { root: string; depth: number; + context: 'scope' | 'repository'; } // ============================================================================= @@ -428,6 +431,7 @@ export function resetMapCache(): void { export function parseMapQuery(query: URLSearchParams, maxDepth = DEFAULT_MAP_MAX_DEPTH): { root: string | null; depth: number | null; + context: 'scope' | 'repository'; } { const rawDepth = query.get('depth'); let depth: number | null = null; @@ -440,8 +444,16 @@ export function parseMapQuery(query: URLSearchParams, maxDepth = DEFAULT_MAP_MAX throw badRequest(`depth must be a whole number from 1 to ${maxDepth}.`); } } + const rawContext = query.get('context'); + if (rawContext !== null && rawContext !== 'scope' && rawContext !== 'repository') { + throw badRequest('context must be "scope" or "repository".'); + } const rawRoot = query.get('root'); - return { root: rawRoot === null ? null : normalizeRoot(rawRoot), depth }; + return { + root: rawRoot === null ? null : normalizeRoot(rawRoot), + depth, + context: rawContext === 'repository' ? 'repository' : 'scope', + }; } /** @@ -483,7 +495,7 @@ function collapseLoneRootFiles(ids: ReadonlySet): Map { export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchParams): WireMapPayload { const started = Date.now(); const viewerMap = loadViewerMapConfig(projectRoot); - const { root: requestedRoot, depth: requestedDepth } = parseMapQuery(query, viewerMap.maxDepth); + const { root: requestedRoot, depth: requestedDepth, context } = parseMapQuery(query, viewerMap.maxDepth); const fileRecords = cg.getFiles().map((file) => { const path = toPosixPath(file.path); @@ -501,6 +513,10 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar // what is being cut. Choosing `src` and then asking for one level under it // is the same question as choosing the whole project and asking for two. const depth = requestedDepth ?? pickDefaultDepth(fileRecords, root, viewerMap.maxDepth); + const groupingRoot = context === 'repository' ? '' : root; + const groupingDepth = context === 'repository' + ? depth + root.split('/').filter(Boolean).length + : depth; const stats = cg.getStats(); const key = [ projectRoot, @@ -509,6 +525,7 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar stats.fileCount, root, depth, + context, JSON.stringify(viewerMap), ].join('\u0000'); const hit = cache.get(key); @@ -537,7 +554,7 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar const assigned = new Map(); for (const file of fileRecords) { - const at = moduleIdFor(file.path, root, depth); + const at = moduleIdFor(file.path, groupingRoot, groupingDepth); if (at !== null) assigned.set(file.path, at); } const renamed = collapseLoneRootFiles(new Set([...assigned.values()].map((a) => a.id))); @@ -623,6 +640,7 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar const payload: WireMapPayload = { root, depth, + context, maxDepth: viewerMap.maxDepth, roots: rootOptions(fileRecords, viewerMap.scopes), modules: [...modules.values()] diff --git a/ui/README.md b/ui/README.md index b886cef4dd..b2ccd8bc9f 100644 --- a/ui/README.md +++ b/ui/README.md @@ -261,6 +261,14 @@ explicit map URLs. Automatic grouping never exceeds four levels or a smaller con limit. Scope roots are relative indexed directories; invalid settings are ignored with a warning. Refresh the browser after editing `codegraph.json` to load the new settings. +Selecting a map module offers **Focus**. **Depends on** and **Used by** show that +module's transitive reachable modules across the indexed repository, following the same +confidence and test-visibility policy as the map; this is module-level reachability, not +proof of an exact symbol impact. Focus holds the original root and grouping stable so +Clear focus restores the prior map. A viewer adapter must advertise repository map context +for Focus; older adapters remain available for ordinary maps and show a recoverable error +instead of a partial focus result. + ## Entry points `#/entry` draws `/api/entrypoints` as file groups, reusing the Symbol view's diff --git a/ui/src/App.svelte b/ui/src/App.svelte index ba7d8294f1..4d685829e0 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -165,7 +165,14 @@ {:else if route.view === 'file'} {:else if route.view === 'map'} - + {:else if route.view === 'flow'} string; /** File stem for a downloaded map, without an extension. */ exportName: string; + focus: { id: string; direction: MapFocusDirection } | null; + onFocus: (id: string) => void; + onSelectFocusDirection: (direction: MapFocusDirection) => void; + onClearFocus: () => void; } let { @@ -51,6 +56,10 @@ onSelect, buildSvg, exportName, + focus, + onFocus, + onSelectFocusDirection, + onClearFocus, }: Props = $props(); /** @@ -115,9 +124,12 @@ + {#if focus !== null} +

Grouping stays fixed while focused so module identities remain stable.

+ + + {/if}