`GET /api/map` rolls the whole edge table up to module granularity in one `GROUP BY`, and the Map tab draws it: one box per directory, dependencies pointing down, nothing placed by hand. Two decisions carry the screen. The vertical order rests on each link's `declared` weight — the edges resolved through an import, a qualified name, an inheritance clause or a typed receiver — not on its raw count. Bare name matching resolves `run`, `push` and `finish` across unrelated directories, and layering on raw counts put `src/db` directly under `src/bin` on this repository's own index. On declared edges the same data reproduces the pipeline CLAUDE.md describes, with a third of the mutual pairs. When too few links carry a declared edge to describe a project, the layout falls back to raw counts and the side panel says so. And the aggregation is a single scan. Grouping by the symbol names as well as the modules costs nothing extra — the join is what is expensive — so one query yields both the link weights and the tooltip's symbol pairs. Measured against this index inflated to 800k edges: 1.28s for one scan against 1.89s for two, which is the difference between meeting and missing the cold budget on a ten-thousand-file repository. Cached answers come back in ~3ms. Nothing is dropped silently: thin links are hidden until a module they touch is selected and counted in the panel, uncertain references are excluded from every number on screen and the total is printed, and mutual dependencies, module loops and file-level circular imports are listed rather than straightened away. An edge that still points up after layering is drawn dashed on selection instead of being reversed or removed. The layout — cycle-breaking, longest-path layering, barycenter ordering, ports — is a pure function of the payload in `ui/src/lib/map-model.ts`, so the tests toggle and the selection cost no round-trip and the same project always draws the same picture. Svelte Flow supplies pan, zoom and fit; never a layout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
577 lines
21 KiB
TypeScript
577 lines
21 KiB
TypeScript
/**
|
|
* `GET /api/map` — the repository at module granularity.
|
|
*
|
|
* The Map answers "what is in here and how is it organised" without anybody
|
|
* having drawn a diagram: modules are directories, the arrows between them are
|
|
* the edges the index already holds, and the vertical order falls out of the
|
|
* dependency direction (design spec §3.6). This module produces the *data*;
|
|
* the layering, cycle-breaking and geometry are pure functions in the viewer
|
|
* (`ui/src/lib/map-model.ts`), so toggling tests or selecting a module never
|
|
* costs a round-trip.
|
|
*
|
|
* Three decisions shape the payload, and all three are about not lying:
|
|
*
|
|
* **A module is a directory, not a guess.** `moduleIdFor` maps each indexed
|
|
* file to the first {@link MapQuery.depth} path segments under the chosen root.
|
|
* A file sitting loose in the root gets folded into one `(root files)` box —
|
|
* except a façade (`index.ts`, `lib.rs`, `__init__.py`), which is its own box
|
|
* because it is the thing everything else imports. No clustering, no
|
|
* heuristics about "what belongs together": if two files are in the same
|
|
* directory the repository already said they belong together.
|
|
*
|
|
* **Weight counts edges; layering counts *declared* edges.** A link's `count`
|
|
* is every confident cross-module edge behind it, which is what the reader
|
|
* sees as thickness. Its `declared` count is the subset resolved through an
|
|
* import, a qualified name, an inheritance clause or a typed receiver — and
|
|
* that is what the layout layers on. The difference is not academic: on this
|
|
* repository, bare name matching resolves calls to `run`, `push` and `finish`
|
|
* across unrelated directories, and layering on raw counts puts the storage
|
|
* layer directly under the CLI. Layering on declared edges reproduces the
|
|
* pipeline the project's own docs describe.
|
|
*
|
|
* **Nothing is dropped silently.** Uncertain edges (confidence below
|
|
* {@link UNCERTAIN_BELOW}) are excluded from every count, and how many were
|
|
* excluded rides on the payload so the side panel can say so.
|
|
*/
|
|
|
|
import type { CodeGraph } from '../../index';
|
|
import type { EdgeKind, Language } from '../../types';
|
|
import { isTestFile } from '../../search/query-utils';
|
|
import { badRequest } from './respond';
|
|
import { UNCERTAIN_BELOW, toPosixPath, wireList, type WireList } from './wire';
|
|
|
|
/**
|
|
* The edge kinds that count as "module A reaches into module B".
|
|
*
|
|
* `contains` is absent on purpose — a file containing its own symbols is not a
|
|
* dependency, and including it would make every module depend on itself.
|
|
*/
|
|
export const MAP_EDGE_KINDS: readonly EdgeKind[] = [
|
|
'calls',
|
|
'imports',
|
|
'references',
|
|
'instantiates',
|
|
'extends',
|
|
'implements',
|
|
];
|
|
|
|
/**
|
|
* The kinds whose symbol pairs the tooltip names.
|
|
*
|
|
* A `references` edge to a type is real traffic but "Config → Config" is not
|
|
* an interesting row; calls and imports are what a reader wants named.
|
|
*/
|
|
const PAIR_EDGE_KINDS: readonly EdgeKind[] = ['calls', 'imports', 'instantiates'];
|
|
|
|
/** Symbol pairs kept per link — the tooltip shows four (design spec §3.6). */
|
|
const TOP_PAIRS_PER_LINK = 4;
|
|
|
|
/**
|
|
* File paths listed per module.
|
|
*
|
|
* The panel's file list is a drill-down, not a directory listing, and it rides
|
|
* on this payload so that clicking a module and then one of its files costs no
|
|
* round-trip at all. Capped because a module can hold hundreds of files and the
|
|
* map is not where you read them; `total` stays the real number.
|
|
*/
|
|
const MAX_FILES_PER_MODULE = 40;
|
|
|
|
/** Longest cycle reported, and how many. Beyond this a cycle list stops being readable. */
|
|
const MAX_FILE_CYCLES = 40;
|
|
const MAX_CYCLE_LENGTH = 12;
|
|
|
|
/** Default segments below the root that name a module. */
|
|
const DEFAULT_DEPTH = 1;
|
|
const MAX_DEPTH = 4;
|
|
|
|
/**
|
|
* Basenames that stay their own box when they sit loose in a module root.
|
|
*
|
|
* These are façades — the file every other module imports the directory
|
|
* *through*. Folding `src/index.ts` into a "(root files)" bucket with the type
|
|
* declarations next to it hides the busiest node on the map.
|
|
*/
|
|
const FACADE_STEMS = new Set(['index', 'main', 'lib', 'mod', '__init__', 'init']);
|
|
|
|
/** Id of the bucket loose files fall into. Deliberately not a real directory name. */
|
|
export function rootFilesId(root: string): string {
|
|
return root ? `${root}/(root files)` : '(root files)';
|
|
}
|
|
|
|
// =============================================================================
|
|
// Wire shapes
|
|
// =============================================================================
|
|
|
|
export interface WireMapModule {
|
|
/** Directory path, or the `(root files)` bucket, or a façade file's own path. */
|
|
id: string;
|
|
/** Last path segment — what the node label shows when the id is long. */
|
|
label: string;
|
|
files: number;
|
|
symbols: number;
|
|
/** File count by language, most files first. */
|
|
languages: Array<{ language: Language; files: number }>;
|
|
/** More than half its files are tests — drawn dashed, hidden by default. */
|
|
test: boolean;
|
|
/** True when this box is a single file kept out of the root bucket (a façade). */
|
|
facade: boolean;
|
|
/** Its files, capped — what the side panel lists when the module is selected. */
|
|
fileList: WireList<string>;
|
|
}
|
|
|
|
export interface WireMapLink {
|
|
source: string;
|
|
target: string;
|
|
/** Every confident cross-module edge behind this link. Drives thickness. */
|
|
count: number;
|
|
/**
|
|
* The subset resolved through an import, a qualified name, an inheritance
|
|
* clause or a typed receiver. Drives the layering — see the module header.
|
|
*/
|
|
declared: number;
|
|
/** `count` broken down by edge kind, biggest first. */
|
|
byKind: Array<{ kind: EdgeKind; count: number }>;
|
|
/**
|
|
* The busiest symbol pairs behind the link, at most
|
|
* {@link TOP_PAIRS_PER_LINK}, declared ones first.
|
|
*/
|
|
topPairs: Array<{ from: string; to: string; count: number; declared: number }>;
|
|
}
|
|
|
|
export interface WireMapCycle {
|
|
/** How many files are in the component. `files` may be shorter. */
|
|
size: number;
|
|
/** The files, capped — a 200-file knot is a fact, not a list anybody reads. */
|
|
files: string[];
|
|
/** The modules the cycle passes through, deduped in order. */
|
|
modules: string[];
|
|
}
|
|
|
|
export interface WireMapPayload {
|
|
root: string;
|
|
depth: number;
|
|
/** Every root the selector may offer, this index's own directories. */
|
|
roots: Array<{ root: string; label: string; files: number }>;
|
|
modules: WireMapModule[];
|
|
links: WireMapLink[];
|
|
/**
|
|
* File-level circular dependencies — the strongly connected components of
|
|
* the file graph, which is what `findCircularDependencies` reports, computed
|
|
* from one query so it stays affordable on a large index.
|
|
*/
|
|
cycles: { total: number; shown: number; truncated: boolean; items: WireMapCycle[] };
|
|
excluded: {
|
|
/** Cross-module edges left out for being name-only guesses. */
|
|
uncertainEdges: number;
|
|
/** The confidence floor applied. */
|
|
confidenceBelow: number;
|
|
};
|
|
index: { lastIndexedAt: number | null; edges: number; files: number };
|
|
/** How long the aggregation took, and whether this answer came from the cache. */
|
|
timing: { elapsedMs: number; cached: boolean };
|
|
}
|
|
|
|
export interface MapQuery {
|
|
root: string;
|
|
depth: number;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Module naming
|
|
// =============================================================================
|
|
|
|
/** 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;
|
|
}
|
|
|
|
function stemOf(basename: string): string {
|
|
const dot = basename.indexOf('.');
|
|
return dot <= 0 ? basename : basename.slice(0, dot);
|
|
}
|
|
|
|
/**
|
|
* Which module a file belongs to, or `null` when it is outside the root.
|
|
*
|
|
* `depth` segments under the root name the module. A file with fewer segments
|
|
* than that is loose in the root: a façade keeps its own box, everything else
|
|
* joins the `(root files)` bucket.
|
|
*/
|
|
export function moduleIdFor(
|
|
filePath: string,
|
|
root: string,
|
|
depth: number
|
|
): { id: string; facade: boolean } | null {
|
|
const path = toPosixPath(filePath);
|
|
let rel = path;
|
|
if (root) {
|
|
if (!path.startsWith(`${root}/`)) return null;
|
|
rel = path.slice(root.length + 1);
|
|
}
|
|
const parts = rel.split('/').filter(Boolean);
|
|
if (parts.length === 0) return null;
|
|
if (parts.length <= depth) {
|
|
// A loose file. The directories it DOES have still qualify it, so
|
|
// `src/a/b.ts` at depth 2 lands in `src/a/(root files)`, not the top one.
|
|
const dir = [root, ...parts.slice(0, -1)].filter(Boolean).join('/');
|
|
if (FACADE_STEMS.has(stemOf(parts[parts.length - 1] ?? ''))) {
|
|
return { id: [root, ...parts].filter(Boolean).join('/'), facade: true };
|
|
}
|
|
return { id: rootFilesId(dir), facade: false };
|
|
}
|
|
return { id: [root, ...parts.slice(0, depth)].filter(Boolean).join('/'), facade: false };
|
|
}
|
|
|
|
/**
|
|
* The root the map opens on: the directory holding the most non-test symbols.
|
|
*
|
|
* A repository's source almost always lives under one directory (`src`, `lib`,
|
|
* `pkg`, `app`), and opening there is what keeps the default map about the
|
|
* program rather than about its tests, scripts and sibling packages. The
|
|
* fallback is the repository root, which is correct for a flat project.
|
|
*
|
|
* A directory only wins if it holds a clear majority of the symbols — anything
|
|
* less and the honest answer is "this repository has no single source root".
|
|
*/
|
|
export function pickDefaultRoot(
|
|
files: ReadonlyArray<{ path: string; symbols: number; test: boolean }>
|
|
): string {
|
|
const byDir = new Map<string, number>();
|
|
let total = 0;
|
|
for (const file of files) {
|
|
if (file.test) continue;
|
|
const slash = file.path.indexOf('/');
|
|
if (slash <= 0) continue;
|
|
const dir = file.path.slice(0, slash);
|
|
byDir.set(dir, (byDir.get(dir) ?? 0) + file.symbols);
|
|
total += file.symbols;
|
|
}
|
|
if (total === 0) return '';
|
|
let best = '';
|
|
let bestSymbols = 0;
|
|
for (const [dir, symbols] of [...byDir].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
if (symbols > bestSymbols) {
|
|
best = dir;
|
|
bestSymbols = symbols;
|
|
}
|
|
}
|
|
return bestSymbols * 2 > total ? best : '';
|
|
}
|
|
|
|
// =============================================================================
|
|
// Cache
|
|
// =============================================================================
|
|
|
|
/**
|
|
* One aggregation per (project, index build, root, depth).
|
|
*
|
|
* The map is the one screen whose cost is proportional to the whole edge
|
|
* table, so it is also the one screen worth caching. Keyed on the index's
|
|
* stamp AND its edge count, exactly as the blast scale is: a re-index or a
|
|
* sync that only moved edges must invalidate it, or the map draws a shape the
|
|
* code no longer has. A handful of entries, because the root selector is the
|
|
* only thing that varies.
|
|
*/
|
|
const CACHE_LIMIT = 8;
|
|
const cache = new Map<string, WireMapPayload>();
|
|
|
|
export function resetMapCache(): void {
|
|
cache.clear();
|
|
}
|
|
|
|
// =============================================================================
|
|
// Build
|
|
// =============================================================================
|
|
|
|
export function parseMapQuery(query: URLSearchParams): { root: string | null; depth: number } {
|
|
const rawDepth = query.get('depth');
|
|
let depth = DEFAULT_DEPTH;
|
|
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}.`);
|
|
}
|
|
}
|
|
const rawRoot = query.get('root');
|
|
return { root: rawRoot === null ? null : normalizeRoot(rawRoot), depth };
|
|
}
|
|
|
|
export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchParams): WireMapPayload {
|
|
const started = Date.now();
|
|
const { root: requestedRoot, depth } = parseMapQuery(query);
|
|
|
|
const fileRecords = cg.getFiles().map((file) => {
|
|
const path = toPosixPath(file.path);
|
|
return {
|
|
path,
|
|
language: file.language,
|
|
symbols: file.nodeCount ?? 0,
|
|
test: isTestFile(path),
|
|
};
|
|
});
|
|
|
|
const root = requestedRoot ?? pickDefaultRoot(fileRecords);
|
|
const stats = cg.getStats();
|
|
const key = [
|
|
projectRoot,
|
|
cg.getLastIndexedAt() ?? 0,
|
|
stats.edgeCount,
|
|
stats.fileCount,
|
|
root,
|
|
depth,
|
|
].join('\u0000');
|
|
const hit = cache.get(key);
|
|
if (hit) {
|
|
// Re-stamp rather than mutate: the cached body is shared, and a caller
|
|
// must not see another request's elapsed time.
|
|
return { ...hit, timing: { elapsedMs: Date.now() - started, cached: true } };
|
|
}
|
|
|
|
const assignments: Array<{ filePath: string; module: string }> = [];
|
|
const modules = new Map<
|
|
string,
|
|
{
|
|
id: string;
|
|
facade: boolean;
|
|
files: number;
|
|
symbols: number;
|
|
testFiles: number;
|
|
languages: Map<Language, number>;
|
|
paths: string[];
|
|
}
|
|
>();
|
|
const moduleOfFile = new Map<string, string>();
|
|
|
|
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);
|
|
if (!entry) {
|
|
entry = {
|
|
id: assigned.id,
|
|
facade: assigned.facade,
|
|
files: 0,
|
|
symbols: 0,
|
|
testFiles: 0,
|
|
languages: new Map(),
|
|
paths: [],
|
|
};
|
|
modules.set(assigned.id, entry);
|
|
}
|
|
entry.files += 1;
|
|
entry.paths.push(file.path);
|
|
entry.symbols += file.symbols;
|
|
if (file.test) entry.testFiles += 1;
|
|
entry.languages.set(file.language, (entry.languages.get(file.language) ?? 0) + 1);
|
|
}
|
|
|
|
const aggregation = cg.getModuleAggregation(assignments, {
|
|
kinds: MAP_EDGE_KINDS,
|
|
minConfidence: UNCERTAIN_BELOW,
|
|
topPairsPerLink: TOP_PAIRS_PER_LINK,
|
|
pairKinds: PAIR_EDGE_KINDS,
|
|
});
|
|
|
|
const links = new Map<string, WireMapLink>();
|
|
let uncertainEdges = 0;
|
|
for (const row of aggregation.links) {
|
|
// The same pass counts what the confidence floor left out, so the "N
|
|
// name-only matches excluded" note reports the number the map actually
|
|
// applied rather than a second query's opinion of it.
|
|
uncertainEdges += row.uncertain;
|
|
if (row.count === 0) continue;
|
|
const id = `${row.source}\u0000${row.target}`;
|
|
let link = links.get(id);
|
|
if (!link) {
|
|
link = { source: row.source, target: row.target, count: 0, declared: 0, byKind: [], topPairs: [] };
|
|
links.set(id, link);
|
|
}
|
|
link.count += row.count;
|
|
link.declared += row.declared;
|
|
link.byKind.push({ kind: row.kind, count: row.count });
|
|
}
|
|
for (const link of links.values()) {
|
|
link.byKind.sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind));
|
|
}
|
|
for (const pair of aggregation.pairs) {
|
|
const link = links.get(`${pair.source}\u0000${pair.target}`);
|
|
if (link && link.topPairs.length < TOP_PAIRS_PER_LINK) {
|
|
link.topPairs.push({
|
|
from: pair.from,
|
|
to: pair.to,
|
|
count: pair.count,
|
|
declared: pair.declared,
|
|
});
|
|
}
|
|
}
|
|
|
|
const payload: WireMapPayload = {
|
|
root,
|
|
depth,
|
|
roots: rootOptions(fileRecords),
|
|
modules: [...modules.values()]
|
|
.map((entry) => ({
|
|
id: entry.id,
|
|
label: entry.id.slice(entry.id.lastIndexOf('/') + 1) || entry.id,
|
|
files: entry.files,
|
|
symbols: entry.symbols,
|
|
languages: [...entry.languages]
|
|
.map(([language, files]) => ({ language, files }))
|
|
.sort((a, b) => b.files - a.files || a.language.localeCompare(b.language)),
|
|
test: entry.testFiles * 2 > entry.files,
|
|
facade: entry.facade,
|
|
fileList: wireList(entry.paths.slice().sort().slice(0, MAX_FILES_PER_MODULE), entry.files),
|
|
}))
|
|
// Sorted so two runs over one index produce byte-identical payloads —
|
|
// the layout is deterministic, and it cannot be if its input is not.
|
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
links: [...links.values()].sort(
|
|
(a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target)
|
|
),
|
|
cycles: fileCycles(cg, moduleOfFile),
|
|
excluded: { uncertainEdges, confidenceBelow: UNCERTAIN_BELOW },
|
|
index: {
|
|
lastIndexedAt: cg.getLastIndexedAt(),
|
|
edges: stats.edgeCount,
|
|
files: stats.fileCount,
|
|
},
|
|
timing: { elapsedMs: Date.now() - started, cached: false },
|
|
};
|
|
|
|
if (cache.size >= CACHE_LIMIT) {
|
|
const oldest = cache.keys().next();
|
|
if (!oldest.done) cache.delete(oldest.value);
|
|
}
|
|
cache.set(key, payload);
|
|
return payload;
|
|
}
|
|
|
|
/**
|
|
* File-level circular dependencies, as strongly connected components.
|
|
*
|
|
* Tarjan over the one-query file edge list. Components of size 1 are not
|
|
* cycles (a file depending on itself is a same-file edge, already excluded),
|
|
* and a component longer than {@link MAX_CYCLE_LENGTH} is reported truncated
|
|
* rather than printed — a 200-file knot is a fact about the repository, not a
|
|
* list anybody reads.
|
|
*/
|
|
function fileCycles(
|
|
cg: CodeGraph,
|
|
moduleOfFile: Map<string, string>
|
|
): WireMapPayload['cycles'] {
|
|
const adjacency = new Map<string, string[]>();
|
|
for (const pair of cg.getFileDependencyPairs(UNCERTAIN_BELOW)) {
|
|
if (!moduleOfFile.has(pair.source) || !moduleOfFile.has(pair.target)) continue;
|
|
let out = adjacency.get(pair.source);
|
|
if (!out) adjacency.set(pair.source, (out = []));
|
|
out.push(pair.target);
|
|
}
|
|
// Deterministic iteration: SQLite's DISTINCT ordering is not a contract.
|
|
const nodes = [...new Set([...adjacency.keys(), ...[...adjacency.values()].flat()])].sort();
|
|
for (const list of adjacency.values()) list.sort();
|
|
|
|
const components = tarjan(nodes, (id) => adjacency.get(id) ?? []);
|
|
const cycles = components
|
|
.filter((component) => component.length > 1)
|
|
.map((component) => component.slice().sort())
|
|
.sort((a, b) => a.length - b.length || (a[0] ?? '').localeCompare(b[0] ?? ''));
|
|
|
|
const items = cycles.slice(0, MAX_FILE_CYCLES).map((files) => ({
|
|
size: files.length,
|
|
files: files.slice(0, MAX_CYCLE_LENGTH),
|
|
modules: [...new Set(files.map((file) => moduleOfFile.get(file) ?? file))].sort(),
|
|
}));
|
|
return {
|
|
total: cycles.length,
|
|
shown: items.length,
|
|
truncated: cycles.length > items.length,
|
|
items,
|
|
};
|
|
}
|
|
|
|
/** Tarjan's strongly connected components, iterative so a deep graph cannot blow the stack. */
|
|
function tarjan(nodes: readonly string[], edgesOf: (id: string) => readonly string[]): string[][] {
|
|
const index = new Map<string, number>();
|
|
const low = new Map<string, number>();
|
|
const onStack = new Set<string>();
|
|
const stack: string[] = [];
|
|
const out: string[][] = [];
|
|
let counter = 0;
|
|
|
|
for (const start of nodes) {
|
|
if (index.has(start)) continue;
|
|
const work: Array<{ id: string; edges: readonly string[]; at: number }> = [
|
|
{ id: start, edges: edgesOf(start), at: 0 },
|
|
];
|
|
index.set(start, counter);
|
|
low.set(start, counter);
|
|
counter += 1;
|
|
stack.push(start);
|
|
onStack.add(start);
|
|
|
|
while (work.length > 0) {
|
|
const frame = work[work.length - 1];
|
|
if (frame === undefined) break;
|
|
if (frame.at < frame.edges.length) {
|
|
const next = frame.edges[frame.at]!;
|
|
frame.at += 1;
|
|
if (!index.has(next)) {
|
|
index.set(next, counter);
|
|
low.set(next, counter);
|
|
counter += 1;
|
|
stack.push(next);
|
|
onStack.add(next);
|
|
work.push({ id: next, edges: edgesOf(next), at: 0 });
|
|
} else if (onStack.has(next)) {
|
|
low.set(frame.id, Math.min(low.get(frame.id) ?? 0, index.get(next) ?? 0));
|
|
}
|
|
continue;
|
|
}
|
|
work.pop();
|
|
if (low.get(frame.id) === index.get(frame.id)) {
|
|
const component: string[] = [];
|
|
for (;;) {
|
|
const popped = stack.pop();
|
|
if (popped === undefined) break;
|
|
onStack.delete(popped);
|
|
component.push(popped);
|
|
if (popped === frame.id) break;
|
|
}
|
|
out.push(component);
|
|
}
|
|
const parent = work[work.length - 1];
|
|
if (parent) low.set(parent.id, Math.min(low.get(parent.id) ?? 0, low.get(frame.id) ?? 0));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The roots the selector offers: the repository root plus every top-level
|
|
* directory that holds indexed files, biggest first.
|
|
*
|
|
* 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.
|
|
*/
|
|
function rootOptions(
|
|
files: ReadonlyArray<{ path: string; symbols: number }>
|
|
): WireMapPayload['roots'] {
|
|
const byDir = new Map<string, number>();
|
|
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 dirs = [...byDir]
|
|
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
.map(([root, count]) => ({ root, label: root, files: count }));
|
|
return [{ root: '', label: 'whole repository', files: files.length }, ...dirs];
|
|
}
|