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}
{:else if layout !== null}
-
+ >
@@ -348,15 +367,19 @@
{/each}
-
+
+ {/key}
e.thin && !e.back).length}
declaredBasis={layout.basis.kind === 'declared'}
- open={legendOpen}
- onToggle={(next) => (legendOpen = next)}
+ open={legendIsOpen}
+ onToggle={(next) => {
+ if (focus === null) legendOpen = next;
+ else focusedLegendOpen = next;
+ }}
/>
{#if hovered !== null}
@@ -383,7 +406,7 @@
{/if}
- {#if payload !== null && layout !== null}
+ {#if error === null && !loading && payload !== null && layout !== null}
{/if}
From f21a9448d8e9be8c65faf149bf19f481b105170d Mon Sep 17 00:00:00 2001
From: nakul-malhotra
Date: Fri, 18 Sep 2026 12:39:51 -0700
Subject: [PATCH 07/13] fix(map): clarify focus state
---
__tests__/ui-package.test.ts | 107 ++++++++++++++++++++++
ui/README.md | 3 +-
ui/src/components/map/MapSidePanel.svelte | 24 ++++-
ui/src/components/map/ModuleNode.svelte | 3 +-
ui/src/views/MapView.svelte | 1 -
5 files changed, 131 insertions(+), 7 deletions(-)
diff --git a/__tests__/ui-package.test.ts b/__tests__/ui-package.test.ts
index b7c2c7a727..ada8a68cb5 100644
--- a/__tests__/ui-package.test.ts
+++ b/__tests__/ui-package.test.ts
@@ -20,6 +20,7 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { flushSync, mount, unmount } from 'svelte';
+import { SvelteMap } from 'svelte/reactivity';
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import {
@@ -598,6 +599,16 @@ describe('@colbymchenry/codegraph-ui — a host renders the package', () => {
const text = host.textContent ?? '';
expect(text).toContain('auth');
expect(text).toContain('http');
+ const island = [...host.querySelectorAll('.mnode')].find((node) =>
+ node.textContent?.includes('src/http')
+ );
+ expect(island?.title).toContain('Nothing in this view depends on it.');
+ island?.click();
+ for (let turn = 0; turn < 4; turn += 1) {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ flushSync();
+ }
+ expect(host.textContent ?? '').toContain('Nothing in this view depends on this module');
});
it('shows one recoverable focus error when an adapter lacks repository context', async () => {
@@ -617,6 +628,102 @@ describe('@colbymchenry/codegraph-ui — a host renders the package', () => {
expect(host.querySelector('button')?.textContent).toContain('Clear focus');
});
+ it('identifies the focused module in Showing without replacing the folder choices', async () => {
+ const { adapter } = mockAdapter();
+ adapter.map = () => Promise.resolve({ ...MAP, context: 'repository' });
+ setGraphAdapter(adapter);
+
+ await render(ArchitectureMap, {
+ root: 'src',
+ depth: 1,
+ tests: false,
+ focus: 'src/auth',
+ direction: 'depends-on',
+ });
+
+ const showing = host.querySelector('select') as HTMLSelectElement;
+ expect(showing.value).toBe('.');
+ expect(showing.selectedOptions[0]?.disabled).toBe(true);
+ expect(showing.selectedOptions[0]?.textContent).toContain('Focus: src/auth · Depends on');
+ expect([...showing.options].some((option) => option.value === 'src')).toBe(true);
+ expect(host.textContent ?? '').toContain(
+ 'No other indexed modules are reachable through its dependencies under the current filters.'
+ );
+ });
+
+ it('keeps the ordinary map when a deferred focused adapter ignores abort', async () => {
+ const { adapter } = mockAdapter();
+ const route = new SvelteMap([
+ ['root', 'src'],
+ ['depth', 1],
+ ['tests', false],
+ ['focus', 'src/auth'],
+ ['direction', 'depends-on'],
+ ]);
+ let resolveFocused: ((payload: WireMapPayload) => void) | null = null;
+ let resolveOrdinary: ((payload: WireMapPayload) => void) | null = null;
+ adapter.map = (request) =>
+ new Promise((resolve) => {
+ if (request?.context === 'repository') resolveFocused = resolve;
+ else resolveOrdinary = resolve;
+ });
+ setGraphAdapter(adapter);
+
+ mounted = mount(ArchitectureMap, {
+ target: host,
+ props: {
+ get root() {
+ return route.get('root') as string | null;
+ },
+ get depth() {
+ return route.get('depth') as number | null;
+ },
+ get tests() {
+ return route.get('tests') as boolean;
+ },
+ get focus() {
+ return route.get('focus') as string | null;
+ },
+ get direction() {
+ return route.get('direction') as 'depends-on' | 'used-by' | null;
+ },
+ },
+ }) as Record;
+ for (let turn = 0; turn < 4; turn += 1) {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ flushSync();
+ }
+ expect(resolveFocused).not.toBeNull();
+
+ route.set('focus', null);
+ route.set('direction', null);
+ for (let turn = 0; turn < 4; turn += 1) {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ flushSync();
+ }
+ expect(resolveOrdinary).not.toBeNull();
+
+ const ordinary: WireMapPayload = {
+ ...MAP,
+ modules: [{ ...MAP.modules[1]!, id: 'src/ordinary', label: 'ordinary' }],
+ links: [],
+ };
+ resolveOrdinary!(ordinary);
+ for (let turn = 0; turn < 4; turn += 1) {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ flushSync();
+ }
+ expect(host.textContent ?? '').toContain('ordinary');
+
+ resolveFocused!({ ...MAP, context: 'repository' });
+ for (let turn = 0; turn < 4; turn += 1) {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ flushSync();
+ }
+ expect(host.textContent ?? '').toContain('ordinary');
+ expect(host.textContent ?? '').not.toContain('cannot focus across the repository');
+ });
+
it('TrailBar and SearchPalette mount and read through the same adapter', async () => {
const { adapter } = mockAdapter();
setGraphAdapter(adapter);
diff --git a/ui/README.md b/ui/README.md
index b2ccd8bc9f..5de8f38236 100644
--- a/ui/README.md
+++ b/ui/README.md
@@ -265,7 +265,8 @@ Selecting a map module offers **Focus**. **Depends on** and **Used by** show tha
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
+Clear focus restores the prior map, while Showing identifies the focused module and direction.
+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.
diff --git a/ui/src/components/map/MapSidePanel.svelte b/ui/src/components/map/MapSidePanel.svelte
index b889620c67..2179ca397c 100644
--- a/ui/src/components/map/MapSidePanel.svelte
+++ b/ui/src/components/map/MapSidePanel.svelte
@@ -20,6 +20,8 @@
/** Older independently packaged adapters did not send `maxDepth`. */
const LEGACY_ADAPTER_MAX_DEPTH = 4;
+ /** Focus is a view state, never a normalized indexed directory root. */
+ const FOCUSED_SHOWING_VALUE = '.';
interface Props {
payload: WireMapPayload;
@@ -109,6 +111,7 @@
);
const thinCount = $derived(layout.edges.filter((e) => e.thin && !e.back).length);
+ const focusDirectionLabel = $derived(focus?.direction === 'depends-on' ? 'Depends on' : 'Used by');