feat(ui): the Map — the repository at module granularity, layered from the graph (CG-49)
`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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a1dfa72cac
commit
6d0f60f32c
@@ -2075,6 +2075,161 @@ export class QueryBuilder {
|
||||
.all(JSON.stringify(filePaths)) as Array<{ filePath: string; dependents: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll the whole edge table up to module granularity in one pass.
|
||||
*
|
||||
* The caller decides what a module IS — it hands in a file → module
|
||||
* assignment and gets back the cross-module traffic. That split is
|
||||
* deliberate: naming modules is a *policy* (top-level directories, a façade
|
||||
* file kept separate, a monorepo root) that belongs where the reader lives,
|
||||
* while grouping a million edges by it is *mechanics* that must happen in
|
||||
* SQLite. Doing the fold in JavaScript instead means materialising every
|
||||
* cross-file edge in memory; doing the naming in SQL means a tower of
|
||||
* `instr`/`substr` no one can read.
|
||||
*
|
||||
* The assignment lands in a TEMP table with a primary key, so the join is
|
||||
* indexed and the result set is bounded by modules², not by edges. Temp
|
||||
* tables live in SQLite's own temp database, so this stays valid against a
|
||||
* read-only main.
|
||||
*
|
||||
* Two result sets, because they need two different groupings over the same
|
||||
* join: `links` counts edges per (module, module, kind), and `pairs` names
|
||||
* the busiest symbol pairs behind each link (the map's tooltip). `pairs` is
|
||||
* ranked and cut inside SQLite — the un-cut grouping is the one thing here
|
||||
* that scales with distinct symbol names rather than with modules. Pairs are
|
||||
* ranked by `declared` before raw count, so a link's tooltip names the
|
||||
* symbols the source actually points at rather than whichever `has`/`get`
|
||||
* happened to name-match most often.
|
||||
*
|
||||
* `declared` is the subset of a link's edges that came from something the
|
||||
* source *writes down*: an import, a qualified name, an inheritance clause,
|
||||
* or a call through a typed receiver. It exists because bare name matching
|
||||
* (`resolvedBy: 'exact-match'`) is what invents cross-module links out of
|
||||
* common method names — `run`, `push`, `finish` — and a map that lets those
|
||||
* decide the layering puts the storage layer above the CLI.
|
||||
*/
|
||||
aggregateModuleGraph(
|
||||
assignments: ReadonlyArray<{ filePath: string; module: string }>,
|
||||
options: {
|
||||
kinds: readonly EdgeKind[];
|
||||
minConfidence: number;
|
||||
topPairsPerLink: number;
|
||||
pairKinds: readonly EdgeKind[];
|
||||
}
|
||||
): {
|
||||
links: Array<{
|
||||
source: string;
|
||||
target: string;
|
||||
kind: EdgeKind;
|
||||
count: number;
|
||||
declared: number;
|
||||
uncertain: number;
|
||||
}>;
|
||||
pairs: Array<{
|
||||
source: string;
|
||||
target: string;
|
||||
from: string;
|
||||
to: string;
|
||||
count: number;
|
||||
declared: number;
|
||||
}>;
|
||||
} {
|
||||
if (assignments.length === 0 || options.kinds.length === 0) return { links: [], pairs: [] };
|
||||
|
||||
const CONFIDENCE = `COALESCE(json_extract(e.metadata, '$.confidence'), 1)`;
|
||||
const DECLARED = `(json_extract(e.metadata, '$.resolvedBy') IN ('import', 'qualified-name')
|
||||
OR e.kind IN ('extends', 'implements')
|
||||
OR (json_extract(e.metadata, '$.resolvedBy') = 'instance-method'
|
||||
AND ${CONFIDENCE} >= 0.9))`;
|
||||
|
||||
this.db.exec('DROP TABLE IF EXISTS temp.cg_module_map');
|
||||
this.db.exec('CREATE TEMP TABLE cg_module_map (path TEXT PRIMARY KEY, mod TEXT NOT NULL)');
|
||||
try {
|
||||
const insert = this.db.prepare(
|
||||
'INSERT OR REPLACE INTO cg_module_map (path, mod) VALUES (?, ?)'
|
||||
);
|
||||
this.db.exec('BEGIN');
|
||||
try {
|
||||
for (const row of assignments) insert.run(row.filePath, row.module);
|
||||
this.db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
this.db.exec('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
|
||||
// ONE pass over the edge table. Grouping by the symbol names as well as
|
||||
// the modules costs nothing extra in scan time — the join is what is
|
||||
// expensive — and it buys both results from a single scan. Measured on
|
||||
// this index inflated to 1.6M edges: 1.66s for this query against 3.0s
|
||||
// for the module-level and name-level queries run separately, which is
|
||||
// the difference between meeting and missing the map's cold budget on a
|
||||
// ten-thousand-file repository.
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT ms.mod AS source, mt.mod AS target, e.kind AS kind,
|
||||
sn.name AS "from", tn.name AS "to",
|
||||
SUM(CASE WHEN ${CONFIDENCE} >= ? THEN 1 ELSE 0 END) AS count,
|
||||
SUM(CASE WHEN ${CONFIDENCE} >= ? AND ${DECLARED} THEN 1 ELSE 0 END) AS declared,
|
||||
SUM(CASE WHEN ${CONFIDENCE} < ? THEN 1 ELSE 0 END) AS uncertain
|
||||
FROM edges e
|
||||
JOIN nodes sn ON sn.id = e.source
|
||||
JOIN nodes tn ON tn.id = e.target
|
||||
JOIN cg_module_map ms ON ms.path = sn.file_path
|
||||
JOIN cg_module_map mt ON mt.path = tn.file_path
|
||||
WHERE e.kind IN (SELECT value FROM json_each(?))
|
||||
AND ms.mod <> mt.mod
|
||||
GROUP BY ms.mod, mt.mod, e.kind, sn.name, tn.name`
|
||||
)
|
||||
.all(
|
||||
options.minConfidence,
|
||||
options.minConfidence,
|
||||
options.minConfidence,
|
||||
JSON.stringify(options.kinds)
|
||||
) as Array<{
|
||||
source: string;
|
||||
target: string;
|
||||
kind: EdgeKind;
|
||||
from: string;
|
||||
to: string;
|
||||
count: number;
|
||||
declared: number;
|
||||
uncertain: number;
|
||||
}>;
|
||||
|
||||
return foldModuleRows(rows, options);
|
||||
} finally {
|
||||
this.db.exec('DROP TABLE IF EXISTS temp.cg_module_map');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every ordered pair of files where one reaches into the other, once each.
|
||||
*
|
||||
* The input a cycle finder wants: file-level circular dependencies are the
|
||||
* strongly connected components of this graph. One query instead of the
|
||||
* dependency lookup per file that {@link GraphQueryManager.findCircularDependencies}
|
||||
* does — which matters because a cycle report is only interesting on a large
|
||||
* repo, and that is exactly where a query per file stops being affordable.
|
||||
*
|
||||
* `contains` is excluded (a file "contains" its own symbols, which is not a
|
||||
* dependency), and so are same-file edges and low-confidence name matches:
|
||||
* a cycle conjured by a common method name is a false alarm a reader cannot
|
||||
* check.
|
||||
*/
|
||||
getCrossFileDependencyPairs(minConfidence: number): Array<{ source: string; target: string }> {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT DISTINCT sn.file_path AS source, tn.file_path AS target
|
||||
FROM edges e
|
||||
JOIN nodes sn ON sn.id = e.source
|
||||
JOIN nodes tn ON tn.id = e.target
|
||||
WHERE e.kind <> 'contains'
|
||||
AND sn.file_path <> tn.file_path
|
||||
AND COALESCE(json_extract(e.metadata, '$.confidence'), 1) >= ?`
|
||||
)
|
||||
.all(minConfidence) as Array<{ source: string; target: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* References recorded against a symbol that never resolved to a node — the
|
||||
* calls and type mentions that leave the index (a third-party package, a
|
||||
@@ -3135,3 +3290,118 @@ export class QueryBuilder {
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the module aggregation's one result set into its two answers.
|
||||
*
|
||||
* The query groups by module pair AND kind AND symbol names, because the join
|
||||
* is what costs and a finer grouping rides along free. That leaves two folds:
|
||||
* counts per (module, module, kind) for the map's link weights, and the busiest
|
||||
* symbol pairs per link for its tooltip.
|
||||
*
|
||||
* Pairs are ranked `declared` first and only then by raw count, so a link's
|
||||
* tooltip names the symbols the source actually points at rather than whichever
|
||||
* `has`/`get`/`run` happened to name-match most often. Only `pairKinds` are
|
||||
* eligible: "Config to Config" is real traffic but not an interesting row.
|
||||
*/
|
||||
interface ModuleGroupRow {
|
||||
source: string;
|
||||
target: string;
|
||||
kind: EdgeKind;
|
||||
from: string;
|
||||
to: string;
|
||||
count: number;
|
||||
declared: number;
|
||||
uncertain: number;
|
||||
}
|
||||
|
||||
interface ModuleLinkTotal {
|
||||
source: string;
|
||||
target: string;
|
||||
kind: EdgeKind;
|
||||
count: number;
|
||||
declared: number;
|
||||
uncertain: number;
|
||||
}
|
||||
|
||||
interface ModulePairTotal {
|
||||
source: string;
|
||||
target: string;
|
||||
from: string;
|
||||
to: string;
|
||||
count: number;
|
||||
declared: number;
|
||||
}
|
||||
|
||||
function foldModuleRows(
|
||||
rows: ReadonlyArray<ModuleGroupRow>,
|
||||
options: { topPairsPerLink: number; pairKinds: readonly EdgeKind[] }
|
||||
): { links: ModuleLinkTotal[]; pairs: ModulePairTotal[] } {
|
||||
// A module id is a path and may contain anything printable, so the key
|
||||
// separator has to be something a path cannot hold.
|
||||
const SEP = '\u0000';
|
||||
const links = new Map<string, ModuleLinkTotal>();
|
||||
const pairKinds = new Set(options.pairKinds);
|
||||
const wantPairs = options.topPairsPerLink > 0 && pairKinds.size > 0;
|
||||
const pairTotals = new Map<string, ModulePairTotal>();
|
||||
|
||||
for (const row of rows) {
|
||||
const linkKey = `${row.source}${SEP}${row.target}${SEP}${row.kind}`;
|
||||
const link = links.get(linkKey);
|
||||
if (link) {
|
||||
link.count += row.count;
|
||||
link.declared += row.declared;
|
||||
link.uncertain += row.uncertain;
|
||||
} else {
|
||||
links.set(linkKey, {
|
||||
source: row.source,
|
||||
target: row.target,
|
||||
kind: row.kind,
|
||||
count: row.count,
|
||||
declared: row.declared,
|
||||
uncertain: row.uncertain,
|
||||
});
|
||||
}
|
||||
|
||||
// Only the confident half of a row can be named: an uncertain edge is a
|
||||
// guess, and printing "a to b, 12" for twelve guesses is the map claiming
|
||||
// something it does not know.
|
||||
if (!wantPairs || row.count === 0 || !pairKinds.has(row.kind)) continue;
|
||||
const pairKey = `${row.source}${SEP}${row.target}${SEP}${row.from}${SEP}${row.to}`;
|
||||
const pair = pairTotals.get(pairKey);
|
||||
if (pair) {
|
||||
pair.count += row.count;
|
||||
pair.declared += row.declared;
|
||||
} else {
|
||||
pairTotals.set(pairKey, {
|
||||
source: row.source,
|
||||
target: row.target,
|
||||
from: row.from,
|
||||
to: row.to,
|
||||
count: row.count,
|
||||
declared: row.declared,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const byLink = new Map<string, ModulePairTotal[]>();
|
||||
for (const pair of pairTotals.values()) {
|
||||
const key = `${pair.source}${SEP}${pair.target}`;
|
||||
let list = byLink.get(key);
|
||||
if (!list) byLink.set(key, (list = []));
|
||||
list.push(pair);
|
||||
}
|
||||
const pairs: ModulePairTotal[] = [];
|
||||
for (const list of byLink.values()) {
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
b.declared - a.declared ||
|
||||
b.count - a.count ||
|
||||
a.from.localeCompare(b.from) ||
|
||||
a.to.localeCompare(b.to)
|
||||
);
|
||||
for (const pair of list.slice(0, options.topPairsPerLink)) pairs.push(pair);
|
||||
}
|
||||
|
||||
return { links: [...links.values()], pairs };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user