diff --git a/CHANGELOG.md b/CHANGELOG.md index 1815c41..c39258f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -201,6 +201,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### Symbols, tests and the viewer +- **The Map groups a repository the way that repository is shaped.** It always drew top-level directories, so a project whose whole program lives under one `src/` opened as a picture of four boxes — `src`, `ios`, `.github`, `(root files)` — with two thirds of the code inside one of them and nothing to say about it. The Map now picks its own grouping: the shallowest one that is not a single box holding the program, so a mobile app opens on `src/app`, `src/components`, `src/api`, `ios/CaptureView` and the rest, and a project packaged as `frontend/src/…` opens on the screens, components and reducers instead of on the word `frontend`. A repository whose top-level directories really are its modules is left exactly where it was. A new **Grouping** control on the right says which one was chosen and lets you take it a level in or out, and a leaf directory is now named for itself rather than as `…/(root files)`. Each box now also says how much leans on it — how many files elsewhere reference straight into it — with a bar along its bottom edge scaled against the most depended-on box on screen, so the folder you have to be careful with is the one you can see at a glance rather than the one with the longest name. The Map also has a **Key** now, like the Screens and Steps tabs — including what the dashed maroon lines mean, which only appear once you select a module: that module reaching back UP into something that depends on it. + +- **The Symbol tab opens the Symbol tab.** With no symbol open and no trail to return to, clicking **Symbol** in the top bar took you to the landing page — which, on any project that has screens, is the Screens tab. So the button said Symbol and gave you somebody else's view. It now has an address of its own (`#/s`) that opens the "nothing selected" screen: the search prompt and the where-to-start list of routes, entry files and the symbols the most code depends on. + - **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges. - **Production code under a `samples` or `examples` package path is no longer treated as test code.** A Kotlin or Java project whose package path runs through `com/google/samples/…` (Now in Android, for one) had nearly every file counted as a fixture, so the Map opened on `build-logic`, the entry points hid the app, and dead-code and test badges were wrong. Only the project layout above a `src/` folder decides now; the package path below it never does. diff --git a/__tests__/ui-export-svg.test.ts b/__tests__/ui-export-svg.test.ts index 700eadd..85e37dd 100644 --- a/__tests__/ui-export-svg.test.ts +++ b/__tests__/ui-export-svg.test.ts @@ -139,6 +139,7 @@ function mod(id: string, over: Partial = {}): WireMapModule { test: over.test ?? false, facade: over.facade ?? false, fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] }, + dependents: over.dependents ?? { files: 0, modules: 0 }, }; } @@ -449,6 +450,37 @@ describe('mapSvg', () => { expect(svg).not.toContain('>__tests__'); }); + it('exports the weight bar the canvas draws, scaled the same way', () => { + const weighted = buildMapLayout( + { + modules: [ + mod('src/types', { dependents: { files: 80, modules: 4 } }), + mod('src/db', { dependents: { files: 20, modules: 2 } }), + mod('src/bin'), + ], + links: [link('src/db', 'src/types', 30), link('src/bin', 'src/db', 30)], + }, + { includeTests: false } + ); + const svg = mapSvg(weighted); + const nodeOf = (id: string) => weighted.nodes.find((n) => n.id === id)!; + // Full bar for the heaviest, a quarter for the module a quarter as leaned + // on, and NO rect at all for the one nothing depends on. + // The export rounds to a tenth, as every coordinate in this file does. + const tenth = (n: number) => Math.round(n * 10) / 10; + const full = nodeOf('src/types'); + const quarter = nodeOf('src/db'); + expect(quarter.weight).toBeCloseTo(0.25, 5); + expect(svg).toContain(`width="${tenth(full.width)}" height="4" fill="${EXPORT_COLORS.ink}"`); + expect(svg).toContain( + `width="${tenth(quarter.width * 0.25)}" height="4" fill="${EXPORT_COLORS.ink}"` + ); + expect(nodeOf('src/bin').weight).toBe(0); + expect(svg.match(/height="4" fill=/g)?.length).toBe(2); + // …and the count rides in the meta line, as on screen. + expect(svg).toContain('· 80 depend on it'); + }); + it('names the top and bottom bands', () => { const svg = mapSvg(layout); expect(svg).toContain('>entry points'); diff --git a/__tests__/ui-map-api.test.ts b/__tests__/ui-map-api.test.ts index 5feb9ca..6ba6d29 100644 --- a/__tests__/ui-map-api.test.ts +++ b/__tests__/ui-map-api.test.ts @@ -24,7 +24,13 @@ import * as os from 'os'; import * as path from 'path'; import CodeGraph from '../src/index'; import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server'; -import { moduleIdFor, normalizeRoot, pickDefaultRoot, resetMapCache } from '../src/ui-server/api/map'; +import { + moduleIdFor, + normalizeRoot, + pickDefaultDepth, + pickDefaultRoot, + resetMapCache, +} from '../src/ui-server/api/map'; let server: UiServerHandle; let api: GraphApi; @@ -293,6 +299,102 @@ describe('pickDefaultRoot', () => { }); }); +describe('pickDefaultDepth', () => { + /** `n` files under `dir`, each carrying `each` symbols. */ + function spread(dir: string, n: number, each: number, test = false) { + return Array.from({ length: n }, (_, i) => ({ + path: `${dir}/f${i}.ts`, + symbols: each, + test, + })); + } + + it('goes deeper when one box holds the program', () => { + // The shape this rule exists for: a React-Native-ish repo whose whole app + // is under `src/`. At depth 1 the map is a box labelled `src` and nothing + // else — 285 files and two thirds of the symbols, unopenable. + const files = [ + ...spread('src/components', 119, 12), + ...spread('src/app', 53, 21), + ...spread('src/api', 47, 7), + ...spread('src/utils', 24, 6), + ...spread('ios/CaptureView', 63, 28), + ...spread('ios/Camera', 3, 38), + ...spread('.github/workflows', 4, 0), + ]; + expect(pickDefaultDepth(files, '')).toBe(2); + }); + + it('keeps a repository whose directories ARE its modules at one level', () => { + const files = [ + ...spread('src/db', 8, 40), + ...spread('src/graph', 9, 40), + ...spread('src/mcp', 7, 40), + ...spread('src/search', 5, 40), + ...spread('src/sync', 4, 40), + ]; + expect(pickDefaultDepth(files, 'src')).toBe(1); + }); + + it('does not open a dominant box that has nothing in it', () => { + // `src/core` holds most of the symbols but only three files: this is a + // small project honestly drawn, not a coarse grouping. + const files = [ + ...spread('src/core', 3, 90), + ...spread('src/db', 2, 10), + ...spread('src/api', 2, 10), + { path: 'src/index.ts', symbols: 5, test: false }, + ]; + expect(pickDefaultDepth(files, 'src')).toBe(1); + }); + + it('keeps going while the picture is still one box', () => { + // `frontend/` then `frontend/src/` — two levels of packaging before the + // code. Neither is a map; the third level is. + const files = [ + ...spread('frontend/src/screens', 15, 10), + ...spread('frontend/src/components', 14, 10), + ...spread('frontend/src/hooks', 8, 10), + ...spread('frontend/src/api', 6, 10), + ...spread('backend/app', 5, 8), + ]; + expect(pickDefaultDepth(files, '')).toBe(3); + }); + + it('stops before a deeper grouping becomes a crowd', () => { + const files = [ + ...spread('src/a', 30, 10), + ...Array.from({ length: 70 }, (_, i) => ({ + path: `src/b/m${i}/f.ts`, + symbols: 1, + test: false, + })), + ]; + // Depth 2 is dominated by `src/a`, but depth 3 would draw 71 boxes. + expect(pickDefaultDepth(files, '')).toBe(2); + }); + + it('does not chase a tree that has no more levels to give', () => { + const files = [ + ...spread('src/a', 30, 10), + ...spread('src/b', 2, 1), + ]; + expect(pickDefaultDepth(files, 'src')).toBe(1); + }); + + it('counts only the modules the map draws by default', () => { + // Test files are hidden unless the reader asks for them, so a depth that + // is only "enough boxes" once tests are counted is not enough boxes. + const files = [ + ...spread('src/app', 40, 10), + ...spread('src/__tests__/a', 12, 10, true), + ...spread('src/__tests__/b', 12, 10, true), + ...spread('src/__tests__/c', 12, 10, true), + ]; + expect(pickDefaultDepth(files, 'src')).toBe(1); + }); +}); + describe('GET /api/map', () => { it('is listed by the API index', async () => { const res = await request('/api'); @@ -407,13 +509,40 @@ describe('GET /api/map', () => { // down joins that level's bucket rather than being promoted to a module. expect(ids).toContain('src/core/passes'); expect(ids).toContain('src/core/(root files)'); - expect(ids).toContain('src/api/(root files)'); expect(ids).not.toContain('src/core'); + // …but the bucket keeps its name only because `src/core/passes` sits beside + // it. `src/api` has nothing below it, so its bucket IS `src/api` and saying + // otherwise would name a directory the repository does not have. + expect(ids).toContain('src/api'); + expect(ids).not.toContain('src/api/(root files)'); const slashed = await getMap('?root=src%2F&depth=2'); expect(slashed.modules).toEqual(deep.modules); }); + 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])); + + // `src/types.ts` and `src/index.ts` are what the rest of the fixture + // imports, so the bucket holding types is the most depended-on box. + const types = by.get('src/(root files)'); + expect(types.dependents.files).toBeGreaterThan(0); + expect(types.dependents.modules).toBeGreaterThan(1); + + // Every count is FILES OUTSIDE the module: never more than the rest of the + // repository, and a module's own internal imports never inflate it. + const total = map.modules.reduce((sum: number, m: any) => sum + m.files, 0); + for (const module of map.modules) { + expect(module.dependents.files).toBeLessThanOrEqual(total - module.files); + expect(module.dependents.modules).toBeLessThanOrEqual(map.modules.length - 1); + // A module nothing arrives at is an island, and the two must agree — + // they are computed from different queries and a reader sees both. + const arrives = map.links.some((l: any) => l.target === module.id); + if (!arrives) expect(module.dependents.files).toBe(0); + } + }); + it('rejects an out-of-range depth as JSON, not as a crash', async () => { const res = await request('/api/map?depth=9'); expect(res.status).toBe(400); diff --git a/__tests__/ui-map-model.test.ts b/__tests__/ui-map-model.test.ts index 7216eb5..f04a51f 100644 --- a/__tests__/ui-map-model.test.ts +++ b/__tests__/ui-map-model.test.ts @@ -46,6 +46,7 @@ function mod(id: string, over: Partial = {}): WireMapModule { test: over.test ?? false, facade: over.facade ?? false, fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] }, + dependents: over.dependents ?? { files: 0, modules: 0 }, }; } @@ -473,3 +474,49 @@ describe('directional ports and room', () => { expect(wide.layers[0]!.y - wide.layers[1]!.y).toBe(NODE_HEIGHT + 116); }); }); + +describe('how much leans on a box', () => { + it('scales the bar against the heaviest module DRAWN', () => { + const modules = [ + mod('src/types', { dependents: { files: 90, modules: 5 } }), + mod('src/db', { dependents: { files: 45, modules: 3 } }), + mod('src/cli', { dependents: { files: 0, modules: 0 } }), + ]; + const links = [link('src/db', 'src/types', 20), link('src/cli', 'src/db', 20)]; + const layout = buildMapLayout({ modules, links }, OPTS); + const weightOf = (id: string) => layout.nodes.find((n) => n.id === id)!.weight; + + expect(weightOf('src/types')).toBe(1); + expect(weightOf('src/db')).toBeCloseTo(0.5, 5); + // Nothing depends on the CLI, so it draws no bar at all rather than a + // sliver a reader would have to squint at to call empty. + expect(weightOf('src/cli')).toBe(0); + }); + + it('rescales when a heavier test module joins the picture', () => { + const modules = [ + mod('src/types', { dependents: { files: 40, modules: 4 } }), + mod('src/app', { dependents: { files: 10, modules: 1 } }), + mod('__tests__', { test: true, dependents: { files: 80, modules: 6 } }), + ]; + const links = [link('src/app', 'src/types', 20), link('__tests__', 'src/app', 20)]; + const spec = { modules, links }; + // Tests off: the app's own busiest box is the full bar. + const off = buildMapLayout(spec, { includeTests: false }); + expect(off.nodes.find((n) => n.id === 'src/types')!.weight).toBe(1); + // Tests on: the scale moves, rather than leaving a bar running past a + // maximum the reader cannot see. + const on = buildMapLayout(spec, { includeTests: true }); + expect(on.nodes.find((n) => n.id === 'src/types')!.weight).toBeCloseTo(0.5, 5); + expect(on.nodes.find((n) => n.id === '__tests__')!.weight).toBe(1); + }); + + it('says the count on the box, and says nothing when nothing depends on it', () => { + expect(moduleMetaLabel(mod('src/db', { dependents: { files: 45, modules: 3 } }))).toBe( + '30 symbols · 3 files · 45 depend on it' + ); + expect(moduleMetaLabel(mod('src/cli'))).toBe('30 symbols · 3 files'); + // An island's line is still the one sentence that matters about it. + expect(moduleMetaLabel(mod('src/cli'), true)).toBe('nothing depends on this'); + }); +}); diff --git a/__tests__/ui-package.test.ts b/__tests__/ui-package.test.ts index 05fadaa..ed0ed19 100644 --- a/__tests__/ui-package.test.ts +++ b/__tests__/ui-package.test.ts @@ -674,6 +674,46 @@ describe('@colbymchenry/codegraph-ui — the seams', () => { expect(symbolHref('function:x')).toBe('#/s/function%3Ax'); }); + it('gives the Symbol tab an address of its own when no symbol is chosen', async () => { + const { parseHash } = await import('../ui/src/lib/router.svelte'); + + // The regression this pins: the tab used to fall back to `#/`, and `#/` is + // the landing page — which renders the SCREENS tab on any project that has + // screens. Clicking Symbol landed you on somebody else's view. + expect(symbolHref(null)).toBe('#/s'); + expect(parseHash('#/').route.view).toBe('home'); + + const empty = parseHash(symbolHref(null)).route; + expect(empty.view).toBe('symbol'); + expect(empty).toMatchObject({ view: 'symbol', id: null }); + + // …and a chosen symbol still round-trips, id and all. + const chosen = parseHash(symbolHref('function:x')).route; + expect(chosen).toMatchObject({ view: 'symbol', id: 'function:x' }); + }); + + 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( + '../ui/src/lib/navigation' + ); + + // One href per tab in the top bar, each parsed back. A tab whose link + // resolves to a different tab's view is the bug above, in general form. + const tabs: Array<[string, string]> = [ + ['screens', screensHref()], + ['steps', stepsHref()], + ['entry', entryHref()], + ['map', mapHref()], + ['symbol', symbolHref(null)], + ['flow', flowHref()], + ['dead', deadHref()], + ]; + for (const [view, href] of tabs) { + expect(parseHash(href).route.view, `${href} should open the ${view} view`).toBe(view); + } + }); + it('the default adapter is the loopback JSON API and asks for `api/...`', async () => { const asked: string[] = []; const adapter = createHttpAdapter({ diff --git a/docs/design/codegraph-ui-design-spec.md b/docs/design/codegraph-ui-design-spec.md index 78f76cb..bfba4f9 100644 --- a/docs/design/codegraph-ui-design-spec.md +++ b/docs/design/codegraph-ui-design-spec.md @@ -207,7 +207,13 @@ with — so the strip and the MCP answer cannot disagree. Grid: canvas `minmax(600px,1fr)` | side panel **320px** (`--rule-soft` left border, 14px 16px padding). Nodes: rect `width = max(110, label.length × 7.3 + 28)`, **height 40**, `--paper` fill, 1px `--ink` stroke (2px + `--press` fill when hovered/selected; `--ink-4` when dimmed; test modules dashed `4 3` in `--ink-3`), label 13px mono at (10,17), count -"N symbols · M files" 11px `--ink-3` at (10,32). Layers: vertical gap **74px**, horizontal gap **34px**, padding 44px; entry points at the +"N symbols · M files · R depend on it" 11px `--ink-3` at (10,32). **Weight bar:** 4px band inside the bottom edge, `--ink` at +0.3 (0.55 hovered/selected, 0.1 dimmed or generated), `width = node.width × (R / max R drawn)` — how much of the picture +leans on this box. `R` is `dependents.files`: files OUTSIDE the module holding a direct confident reference into one of its +files. **Direct, not transitive** — the transitive closure was measured and saturates on any repository with a dependency +cycle (139–282 of 377 files on a real mobile app, a flat spread that only reports cyclicity), while the direct count on the +same repository spreads 0–127 and names the modules a reader would name by hand. Relative to the heaviest box *drawn*, so +turning tests on rescales rather than overflowing a maximum nobody can see; a module with R=0 draws no bar at all. Layers: vertical gap **74px**, horizontal gap **34px**, padding 44px; entry points at the top ("entry points" label), foundations at the bottom ("foundations — depend on nothing below"); faint layer lines `--rule-faint`. Layout: aggregate edges by module; break 2-cycles keeping the heavier direction; longest-path layering (a module sits one layer above everything it depends on); barycenter ordering, 3 sweeps; single-node layers centred; ports spread along each box diff --git a/src/ui-server/api/map.ts b/src/ui-server/api/map.ts index b978bea..b593e86 100644 --- a/src/ui-server/api/map.ts +++ b/src/ui-server/api/map.ts @@ -126,6 +126,25 @@ export interface WireMapModule { facade: boolean; /** Its files, capped — what the side panel lists when the module is selected. */ fileList: WireList; + /** + * What a change in here reaches: files OUTSIDE this module holding a direct, + * confident reference into one of its files, and how many modules those + * files span. + * + * DIRECT, deliberately. The transitive closure was measured first and it is + * useless on a real repository: any dependency cycle — and a mobile app had + * nine mutual pairs — saturates it, so every module comes out reaching + * nearly every file (139–282 of 377, a flat 2× spread that says nothing but + * "this repo has cycles"). The direct count on the same repository spreads + * 0–127 and names the modules a reader would name by hand: the shared types + * at the top, the CLI at zero. + * + * The counts are FILES, not symbols: a module is a set of files, and "94 + * files would have to be re-read if this changed" is a claim the index can + * stand behind. It is a floor on blast radius, not the whole of it — a + * symbol-level answer for one symbol is what the Symbol view is for. + */ + dependents: { files: number; modules: number }; } export interface WireMapLink { @@ -276,6 +295,107 @@ export function pickDefaultRoot( return bestSymbols * 2 > total ? best : ''; } +/** + * A box holding more than this share of the mapped symbols IS the program, and + * a map whose subject is one box has not said anything. + */ +const DOMINANT_SHARE = 0.4; + +/** + * …but only if there is something inside it. A dominant box of four files is a + * small project honestly drawn; opening it just spreads four files over four + * boxes. This is the line between "grouped too coarsely" and "actually small". + */ +const DOMINANT_MIN_FILES = 25; + +/** Fewer boxes than this is a list, not a picture. */ +const MIN_MODULES = 4; + +/** More than this and a deeper grouping has traded one unreadable map for another. */ +const MAX_MODULES = 60; + +/** The non-test modules a given depth would draw, and how concentrated they are. */ +function tallyModules( + files: ReadonlyArray<{ path: string; symbols: number; test: boolean }>, + root: string, + depth: number +): { count: number; share: number; largestFiles: number } { + const byModule = new Map(); + let total = 0; + for (const file of files) { + if (file.test) continue; + const assigned = moduleIdFor(file.path, root, depth); + if (assigned === null) continue; + let entry = byModule.get(assigned.id); + if (!entry) byModule.set(assigned.id, (entry = { symbols: 0, files: 0 })); + entry.symbols += file.symbols; + entry.files += 1; + total += file.symbols; + } + let largest = { symbols: 0, files: 0 }; + for (const entry of byModule.values()) { + if (entry.symbols > largest.symbols) largest = entry; + } + return { + count: byModule.size, + share: total === 0 ? 0 : largest.symbols / total, + largestFiles: largest.files, + }; +} + +/** + * How many segments name a module, when the reader has not said. + * + * Depth is not a property of the reader's taste, it is a property of the + * repository: one level under the root is the right grouping for a project + * whose directories ARE its modules, and the wrong one for the very common + * shape where every line of the program lives under a single `src/`. Drawing + * that project at depth 1 produces the map this rule exists to prevent — a box + * labelled `src`, holding two thirds of the code, with nothing to say about it. + * + * So: take the shallowest depth that is neither dominated by one box worth + * opening nor too small to be a picture; stop before a deeper one becomes a + * crowd; and never go past the last level the directory tree actually has. + * + * The walk does NOT stop at the first depth that fails to add boxes. A repo + * packaged as `frontend/src/...` plateaus at two boxes for two levels running + * before the third splits it, and a rule that gave up on the plateau would + * draw exactly the picture this function exists to avoid. + */ +export function pickDefaultDepth( + files: ReadonlyArray<{ path: string; symbols: number; test: boolean }>, + root: string +): number { + // Past the deepest directory, a bigger number only renames boxes to + // `src/a/(root files)`. There is nothing below the leaves. + let deepest = DEFAULT_DEPTH; + for (const file of files) { + if (file.test) continue; + const path = toPosixPath(file.path); + if (root && !path.startsWith(`${root}/`)) continue; + const rel = root ? path.slice(root.length + 1) : path; + deepest = Math.max(deepest, rel.split('/').filter(Boolean).length - 1); + } + + let fallback = DEFAULT_DEPTH; + let fallbackCount = 0; + for (let depth = DEFAULT_DEPTH; depth <= Math.min(MAX_DEPTH, deepest); depth += 1) { + const tally = tallyModules(files, root, depth); + if (tally.count === 0) break; + // Deeper only gets more crowded from here. + if (tally.count > MAX_MODULES) break; + const dominated = tally.share > DOMINANT_SHARE && tally.largestFiles >= DOMINANT_MIN_FILES; + if (tally.count >= MIN_MODULES && !dominated) return depth; + // Not a picture yet. Worth keeping only if it drew more than the last one: + // a deeper grouping that splits nothing is the same map with longer labels. + if (tally.count > fallbackCount) { + fallback = depth; + fallbackCount = tally.count; + } + } + return fallback; +} + // ============================================================================= // Cache // ============================================================================= @@ -301,9 +421,17 @@ export function resetMapCache(): void { // Build // ============================================================================= -export function parseMapQuery(query: URLSearchParams): { root: string | null; depth: number } { +/** + * `null` for either field means "nobody said" — the answer picks. Absence has + * 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): { + root: string | null; + depth: number | null; +} { const rawDepth = query.get('depth'); - let depth = DEFAULT_DEPTH; + let depth: number | null = null; if (rawDepth !== null && rawDepth !== '') { depth = Number.parseInt(rawDepth, 10); if (!Number.isFinite(depth) || depth < 1 || depth > MAX_DEPTH) { @@ -314,9 +442,45 @@ export function parseMapQuery(query: URLSearchParams): { root: string | null; de return { root: rawRoot === null ? null : normalizeRoot(rawRoot), depth }; } +/** + * Rename `x/(root files)` to `x` wherever the bucket is all `x` has. + * + * The bucket earns its name only when it stands beside something: `src` holding + * both `src/api` and three loose files needs a box for the loose ones, and that + * box has to say it is not the whole of `src`. But a `backend/controllers` with + * no subdirectories in it is not a directory with a bucket in it — it IS the + * directory, and drawing it as `backend/controllers/(root files)` names a thing + * the repository does not have. Deeper groupings hit this constantly (every + * leaf directory becomes a bucket), which is what makes it worth a pass. + * + * Returns only the ids that move, so a caller can leave the rest alone. + */ +function collapseLoneRootFiles(ids: ReadonlySet): Map { + const renamed = new Map(); + for (const id of ids) { + const cut = id.lastIndexOf('/(root files)'); + // A bucket at the very top (`(root files)`) has no directory to become. + if (cut <= 0 || cut + '/(root files)'.length !== id.length) continue; + const dir = id.slice(0, cut); + let alone = true; + for (const other of ids) { + // A façade counts: `src/utils` beside `src/utils/index.tsx` would read as + // if the box contained the file drawn next to it. + if (other !== id && other.startsWith(`${dir}/`)) { + alone = false; + break; + } + } + // `dir` can only already be a module if something lives BELOW it, which is + // exactly the case `alone` just ruled out — so this rename cannot collide. + if (alone) renamed.set(id, dir); + } + return renamed; +} + export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchParams): WireMapPayload { const started = Date.now(); - let { root: requestedRoot, depth } = parseMapQuery(query); + const { root: requestedRoot, depth: requestedDepth } = parseMapQuery(query); const fileRecords = cg.getFiles().map((file) => { const path = toPosixPath(file.path); @@ -330,10 +494,10 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar }); const root = requestedRoot ?? pickDefaultRoot(fileRecords); - // Left to choose, and choosing the whole project (two substantial roots): - // one level deeper, so the boxes are `src/app` and `ios/CaptureView`, not - // `src` and `ios`. - if (requestedRoot === null && root === '' && !query.has('depth')) depth = 2; + // 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 stats = cg.getStats(); const key = [ projectRoot, @@ -367,16 +531,24 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar >(); const moduleOfFile = new Map(); + const assigned = new Map(); for (const file of fileRecords) { - const assigned = moduleIdFor(file.path, root, depth); - if (assigned === null) continue; - assignments.push({ filePath: file.path, module: assigned.id }); - moduleOfFile.set(file.path, assigned.id); - let entry = modules.get(assigned.id); + const at = moduleIdFor(file.path, root, depth); + if (at !== null) assigned.set(file.path, at); + } + const renamed = collapseLoneRootFiles(new Set([...assigned.values()].map((a) => a.id))); + + for (const file of fileRecords) { + const at = assigned.get(file.path); + if (at === undefined) continue; + const id = renamed.get(at.id) ?? at.id; + assignments.push({ filePath: file.path, module: id }); + moduleOfFile.set(file.path, id); + let entry = modules.get(id); if (!entry) { entry = { - id: assigned.id, - facade: assigned.facade, + id, + facade: at.facade, files: 0, symbols: 0, testFiles: 0, @@ -385,7 +557,7 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar languages: new Map(), paths: [], }; - modules.set(assigned.id, entry); + modules.set(id, entry); } entry.files += 1; entry.paths.push(file.path); @@ -438,6 +610,12 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar } } + // ONE fetch of the file edge list, read twice: the cycle finder and the + // dependent counts are both questions about it, and it is the expensive query + // on this screen. + const filePairs = cg.getFileDependencyPairs(UNCERTAIN_BELOW); + const dependents = countDependents(filePairs, moduleOfFile); + const payload: WireMapPayload = { root, depth, @@ -460,6 +638,7 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar // draws are the same list — the count-equals-list rule. generatedFiles: shown.filter((path) => entry.generatedPaths.has(path)), fileList: wireList(shown, entry.files), + dependents: dependents.get(entry.id) ?? { files: 0, modules: 0 }, }; }) // Sorted so two runs over one index produce byte-identical payloads — @@ -468,7 +647,7 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar links: [...links.values()].sort( (a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target) ), - cycles: fileCycles(cg, moduleOfFile), + cycles: fileCycles(filePairs, moduleOfFile), excluded: { uncertainEdges, confidenceBelow: UNCERTAIN_BELOW }, index: { lastIndexedAt: cg.getLastIndexedAt(), @@ -486,6 +665,38 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar return payload; } +/** + * Per module: how many files outside it reference into it, and across how many + * modules those files sit. + * + * One pass over the edge list. A pair whose two ends land in the same module is + * internal cohesion, not blast radius, and is skipped; a pair touching a file + * outside the chosen root has no module and is skipped too. The `Set` per + * module is what makes the count DISTINCT FILES rather than distinct + * references — twelve calls from one file are one file that has to be re-read. + */ +function countDependents( + pairs: ReadonlyArray<{ source: string; target: string }>, + moduleOfFile: Map +): Map { + const incoming = new Map>(); + for (const pair of pairs) { + const from = moduleOfFile.get(pair.source); + const to = moduleOfFile.get(pair.target); + if (from === undefined || to === undefined || from === to) continue; + let seen = incoming.get(to); + if (!seen) incoming.set(to, (seen = new Set())); + seen.add(pair.source); + } + const out = new Map(); + for (const [module, files] of incoming) { + const modules = new Set(); + for (const file of files) modules.add(moduleOfFile.get(file)!); + out.set(module, { files: files.size, modules: modules.size }); + } + return out; +} + /** * File-level circular dependencies, as strongly connected components. * @@ -496,11 +707,11 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar * list anybody reads. */ function fileCycles( - cg: CodeGraph, + pairs: ReadonlyArray<{ source: string; target: string }>, moduleOfFile: Map ): WireMapPayload['cycles'] { const adjacency = new Map(); - for (const pair of cg.getFileDependencyPairs(UNCERTAIN_BELOW)) { + for (const pair of pairs) { if (!moduleOfFile.has(pair.source) || !moduleOfFile.has(pair.target)) continue; let out = adjacency.get(pair.source); if (!out) adjacency.set(pair.source, (out = [])); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 080e3d1..ba7d829 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -81,7 +81,8 @@ const encoded = router.params.get('t'); untrack(() => { trail.hydrate(encoded); - if (current.view === 'symbol' && trail.current?.id !== current.id) { + // `id: null` is the tab with nothing chosen — there is no hop to record. + if (current.view === 'symbol' && current.id !== null && trail.current?.id !== current.id) { trail.push({ id: current.id }); } }); @@ -157,7 +158,7 @@
- {#if route.view === 'symbol'} + {#if route.view === 'symbol' && route.id !== null} {:else if route.view === 'file' && route.source} @@ -175,6 +176,8 @@ {:else if route.view === 'entry'} {:else if route.view === 'screens' || (route.view === 'home' && hasScreens)} + {:else if route.view === 'steps'} diff --git a/ui/src/components/TopBar.svelte b/ui/src/components/TopBar.svelte index b45d56f..6f2a598 100644 --- a/ui/src/components/TopBar.svelte +++ b/ui/src/components/TopBar.svelte @@ -20,12 +20,15 @@ let view = $derived(router.route.view); // The Symbol tab returns you to where you were reading, not to a blank - // view: the current symbol if you are on one, else the trail's last hop. + // view: the current symbol if you are on one, else the trail's last hop — + // and failing both, the tab's own empty screen. NOT `#/`: the landing page + // renders the Screens tab on any project that has screens, so that fallback + // sent a reader who clicked Symbol to somebody else's view. let symbolTabHref = $derived.by(() => { const route = router.route; - if (route.view === 'symbol') return symbolHref(route.id); + if (route.view === 'symbol' && route.id !== null) return symbolHref(route.id); const current = trail.current; - return current ? symbolHref(current.id) : '#/'; + return symbolHref(current ? current.id : null); }); /** What `/` and Cmd-K reach — the palette owns its own keyboard. */ diff --git a/ui/src/components/map/MapKey.svelte b/ui/src/components/map/MapKey.svelte new file mode 100644 index 0000000..cf01c71 --- /dev/null +++ b/ui/src/components/map/MapKey.svelte @@ -0,0 +1,189 @@ + + +
+ + {#if open} +
+
+ src/api + A module — one directory, with the symbols and files in it +
+
+ src/db + + The bar along the bottom is how much leans on it — files elsewhere that reference + straight into it, against the most depended-on box here. The count is on the box + +
+
+ + + Depends on — the box above calls, imports, extends or names a type from the box below. + Thicker is more references{declaredBasis + ? '' + : '; here the layering had too few imports to trust, so it used raw counts'} + +
+
+ + + Points back up — the lighter half of a mutual dependency, or a link with no import or + declared type behind it. Drawn only while a module it touches is selected + +
+
+ top / bottom + + A module sits one layer above everything it depends on, so entry points end up at the top + and the foundations — which depend on nothing below — at the bottom + +
+
+ src/api + Selected: click a module to bring out its links and list its files; everything more than one hop away fades +
+
+ nothing depends on this + No link in the index arrives here — a script, a workflow, an unreferenced corner +
+
+ __tests__ + More than half its files are tests; off unless you turn tests on +
+
+ gen + Every file in it is tool-generated — nobody wrote it and nobody edits it +
+ {#if thinCount > 0} +
+ {thinCount} hidden + + Links carrying fewer than {minWeight} references wait until you select a module they + touch, so a weak coincidence never draws as a dependency + +
+ {/if} +
+ {/if} +
+ + diff --git a/ui/src/components/map/MapSidePanel.svelte b/ui/src/components/map/MapSidePanel.svelte index f782742..fdc056a 100644 --- a/ui/src/components/map/MapSidePanel.svelte +++ b/ui/src/components/map/MapSidePanel.svelte @@ -25,6 +25,9 @@ files: string[]; onToggleTests: (value: boolean) => void; onSelectRoot: (root: string) => void; + /** What the reader asked for, or `null` when the depth in `payload` was chosen for them. */ + chosenDepth: number | null; + onSelectDepth: (depth: number | null) => void; onSelect: (id: string | null) => void; /** Builds the map as an SVG at a given device-pixel scale. */ buildSvg: (scale: number) => string; @@ -40,11 +43,31 @@ files, onToggleTests, onSelectRoot, + chosenDepth, + onSelectDepth, onSelect, buildSvg, exportName, }: Props = $props(); + /** + * The grouping options. + * + * The first one is the default and is not a number: the answering side reads + * the repository and picks the shallowest grouping that is not one box + * holding the whole program. The numbers below it are there for when its + * 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; + + function depthLabel(depth: number): string { + return depth === 1 ? 'top-level folders' : `${depth} folders deep`; + } + + /** An em dash the mono face has; the select is narrow enough to notice a tofu. */ + const DASH = '\u2014'; + const selectedNode = $derived( selected === null ? null : (layout.nodes.find((n) => n.id === selected) ?? null) ); @@ -96,6 +119,25 @@ + + +