feat(ui): the Flow strip — how one symbol reaches another, one card per hop (CG-50)
Ask "how does execute reach getFile" in the search box and the viewer draws the call path between them, left to right, opening every card at the exact line that makes the next call. Dynamic-dispatch hops are dashed and name the site they were wired at; "Read as flow" turns a trail walked by hand into the same strip. The path finder is NOT new. `codegraph_explore` already leads its answers with the longest call chain among the symbols an agent named, and a viewer that drew a different path would get the two quoted against each other in a review. So the search moved out of `ToolHandler` into `src/graph/named-symbol-flow.ts` and both callers ride it — same tokens, same overload rules, same synthesized edges. What stayed behind in `tools.ts` is the prose. A pinned from/to question is the same search with two options changed, because both ends being named is the evidence explore's one-unnamed-bridge cap stands in for: it bridges freely, keeps twelve candidates per endpoint instead of six (the CLI's own `main` sorts seventh of ten), and searches from both ends at once — identical paths to the one-way walk on twelve measured pairs, 3-6x faster. `/api/flow` is deliberately the one endpoint with no cache: its cards carry source read from disk, and a drift verdict changes without the index changing. Verified on this repo (`execute` to `rowToFileRecord`, 8 hops; `main` to `resolveOne`, 7) and on a fresh excalidraw index, where `mutateElement` to `renderStaticScene` crosses callback, react-render and jsx-child hops and lists exactly the hops `codegraph_explore` prints. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6d0f60f32c
commit
62e0a89b0e
@@ -1884,6 +1884,11 @@ Pick a symbol and you see who calls it on the left, its source in the middle,
|
||||
and what it calls on the right at the height of the line that calls it. Search
|
||||
with / (or Cmd-K), click a file path for the file's outline and its imports.
|
||||
|
||||
Ask "how does execute reach getFile" (or "execute -> getFile") in the search
|
||||
box for the flow between two symbols: one card per hop, opened at the line that
|
||||
makes the next call, with dynamic-dispatch hops drawn dashed and named. The Map
|
||||
tab draws the whole project by module, with dependencies pointing down.
|
||||
|
||||
The viewer listens on 127.0.0.1 only, so nothing on your network can reach it,
|
||||
and it is read-only: it opens an index that already exists and never changes
|
||||
your project or your graph. Requests from any other host are refused, and
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
/**
|
||||
* The call path among a bag of named symbols — the one path finder.
|
||||
*
|
||||
* `codegraph_explore` leads its answer with a "Flow" section: the longest call
|
||||
* chain among the symbols an agent named, riding synthesized dynamic-dispatch
|
||||
* edges so a controller reaches its implementation through the interface. The
|
||||
* viewer's Flow strip (`/api/flow`, design spec §3.5) draws the same thing as
|
||||
* cards. They must never disagree, so the search lives here once and both
|
||||
* callers ride it: same token parsing, same overload disambiguation, same
|
||||
* bridge budget, same edges.
|
||||
*
|
||||
* What differs between the two callers is expressed as OPTIONS, not as a second
|
||||
* implementation:
|
||||
*
|
||||
* - **`mode: 'named'`** is exactly what explore does. Every resolved symbol is
|
||||
* both a possible start and a possible end, at most ONE unnamed symbol may
|
||||
* bridge two named ones ({@link DEFAULT_MAX_BRIDGE}), and the LONGEST chain
|
||||
* wins. The bridge cap is what stops the search wandering a god-function's
|
||||
* fan-out: the agent's own naming is the evidence that a hop is on-topic.
|
||||
* - **`mode: 'directed'`** is "how does X reach Y", which the agent has no way
|
||||
* to ask and the viewer's search box does. Both ends are pinned, so the
|
||||
* evidence the bridge cap was standing in for is already there and the search
|
||||
* bridges freely — a two-token query under the named rules could never return
|
||||
* more than three cards. The SHORTEST path wins, because with both ends fixed
|
||||
* a longer route is a detour rather than a fuller answer.
|
||||
*
|
||||
* Overloads are handled differently for the same reason. A bare ambiguous name
|
||||
* in `named` mode is filtered by CO-NAMING (keep `list` only where the agent
|
||||
* also named its class); in `directed` mode every candidate for both endpoints
|
||||
* is tried and the pair that actually connects is the answer — which is a
|
||||
* better disambiguator than co-naming and the only one available when the
|
||||
* whole query is two words.
|
||||
*/
|
||||
|
||||
import type CodeGraph from '../index';
|
||||
import type { Node, Edge } from '../types';
|
||||
import { isTestFile } from '../search/query-utils';
|
||||
|
||||
/**
|
||||
* Rust path roots that have no file-system equivalent — `crate` is the
|
||||
* current crate, `super` is the parent module, `self` is the current
|
||||
* module. Used by `matchesSymbol` to strip these before file-path
|
||||
* matching so `crate::configurator::stage_apply::run` resolves the
|
||||
* same as `configurator::stage_apply::run`.
|
||||
*/
|
||||
export const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
|
||||
|
||||
/**
|
||||
* Last `::` / `.` / `/`-separated segment of a qualified symbol. An Erlang
|
||||
* arity tail (`mod::fn/3`, `fn/3`) is stripped first — the useful last segment
|
||||
* is the function name, never the digits (#1610).
|
||||
*/
|
||||
export function lastQualifierPart(symbol: string): string {
|
||||
const noArity = symbol.replace(/\/\d{1,3}$/, '') || symbol;
|
||||
const parts = noArity.split(/::|[./]/).filter((p) => p.length > 0);
|
||||
return parts[parts.length - 1] ?? symbol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node matches a symbol query.
|
||||
*
|
||||
* Accepts simple names (`run`) and three flavors of qualifier:
|
||||
* - dotted `Session.request` (TS/JS/Python)
|
||||
* - colon-pair `stage_apply::run` (Rust, C++, Ruby)
|
||||
* - slash `configurator/stage_apply` (path-ish)
|
||||
*
|
||||
* Multi-level qualifiers compose: `crate::configurator::stage_apply::run`
|
||||
* works. Rust path prefixes (`crate`, `super`, `self`) are stripped so
|
||||
* the canonical `crate::module::symbol` form resolves.
|
||||
*
|
||||
* Resolution order, last part must always equal `node.name`:
|
||||
* 1. Suffix-match against `qualifiedName` (handles class-scoped methods
|
||||
* where the extractor builds the qualified name from the AST stack)
|
||||
* 2. File-path containment (handles file-derived modules in Rust/
|
||||
* Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
|
||||
*/
|
||||
export function matchesSymbol(node: Node, symbol: string): boolean {
|
||||
// Erlang arity spelling (`fn/3`, `mod:fn/3` → normalized `mod.fn/3`): when
|
||||
// the node's qualifiedName carries an arity (`mod::fn/3`, #1610), the
|
||||
// written arity must match it exactly; the remaining comparison then runs
|
||||
// on the arity-less spelling. A node with no arity in its qualifiedName
|
||||
// keeps the original symbol (a `/` there means a path-ish name instead).
|
||||
const aritySpelling = /^(.+)\/(\d{1,3})$/.exec(symbol);
|
||||
if (aritySpelling) {
|
||||
const nodeArity = /\/(\d{1,3})$/.exec(node.qualifiedName ?? '')?.[1];
|
||||
if (nodeArity !== undefined) {
|
||||
if (nodeArity !== aritySpelling[2]) return false;
|
||||
symbol = aritySpelling[1]!;
|
||||
}
|
||||
}
|
||||
// Simple name match
|
||||
if (node.name === symbol) return true;
|
||||
// File basename match (e.g., "product-card" matches "product-card.liquid")
|
||||
if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
|
||||
|
||||
// Qualified-name lookups: split on any supported separator. `\w` keeps
|
||||
// identifier chars (incl. `_`) intact; everything else is treated as
|
||||
// a separator we tolerate.
|
||||
if (!/[.\/]|::/.test(symbol)) return false;
|
||||
const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
|
||||
if (parts.length < 2) return false;
|
||||
|
||||
const lastPart = parts[parts.length - 1]!;
|
||||
if (node.name !== lastPart) return false;
|
||||
|
||||
// Stage 1: qualified-name suffix match. The extractor joins the
|
||||
// semantic hierarchy with `::`, so `Session.request` and
|
||||
// `Session::request` both become `Session::request` here.
|
||||
const colonSuffix = parts.join('::');
|
||||
if (node.qualifiedName.includes(colonSuffix)) return true;
|
||||
|
||||
// Stage 2: file-path containment. Rust modules and Python packages
|
||||
// are not in `qualifiedName` — they're encoded in the file path. So
|
||||
// `stage_apply::run` matches a `run` in any file whose path
|
||||
// contains a `stage_apply` segment (with or without an extension).
|
||||
//
|
||||
// Filter out Rust path prefixes that have no file-system equivalent.
|
||||
const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
|
||||
if (containerHints.length === 0) return false;
|
||||
|
||||
const segments = node.filePath.split('/').filter((s) => s.length > 0);
|
||||
return containerHints.every((hint) =>
|
||||
segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
|
||||
* results across all matching symbols (e.g., multiple classes with an `execute` method).
|
||||
*/
|
||||
export function findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
|
||||
// Nix option paths: the declaration is stored as `options.<path>` and
|
||||
// config writes carry longer/quoted tails (`<path>."git/config".text`),
|
||||
// so a dotted option token (`xdg.configFile`, `launchd.user.agents`) has
|
||||
// no exact-name node and would degrade to bare-tail FTS soup — burying
|
||||
// the declaration hub the nix-option-path edges hang off. Resolve the
|
||||
// convention directly: declaration first, then the exact write, then a
|
||||
// capped prefix scan of write sites. Three index hits; non-nix graphs
|
||||
// fall straight through.
|
||||
if (/^[a-z][\w'-]*(?:\.[\w'-]+)+$/.test(symbol)) {
|
||||
const optionHits = [
|
||||
...cg.getNodesByName(`options.${symbol}`),
|
||||
...cg.getNodesByName(symbol),
|
||||
...cg.getNodesByNamePrefix(`${symbol}.`, 12),
|
||||
].filter((n) => n.language === 'nix');
|
||||
if (optionHits.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
const nodes = optionHits.filter((n) => !seen.has(n.id) && !!seen.add(n.id)).slice(0, 10);
|
||||
return { nodes, note: '' };
|
||||
}
|
||||
}
|
||||
let results = cg.searchNodes(symbol, { limit: 50 });
|
||||
|
||||
// Mirror the fallback in `findSymbol` for qualified queries — FTS
|
||||
// strips colons, so a module-qualified lookup needs a second pass
|
||||
// by the bare last part.
|
||||
if (results.length === 0 && /[.\/]|::/.test(symbol)) {
|
||||
const tail = lastQualifierPart(symbol);
|
||||
if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return { nodes: [], note: '' };
|
||||
}
|
||||
|
||||
const exactMatches = results.filter(r => matchesSymbol(r.node, symbol));
|
||||
|
||||
if (exactMatches.length <= 1) {
|
||||
const node = exactMatches[0]?.node ?? results[0]!.node;
|
||||
return { nodes: [node], note: '' };
|
||||
}
|
||||
|
||||
// Same generated-file down-rank as findSymbol — keeps callers/callees
|
||||
// /impact aggregation aligned (a query against "Send" returns the
|
||||
// hand-written implementations before the protobuf scaffold).
|
||||
const isGen = cg.generatedFilePredicate(exactMatches.map((r) => r.node.filePath));
|
||||
const ranked = [...exactMatches].sort((a, b) => {
|
||||
const aGen = isGen(a.node.filePath) ? 1 : 0;
|
||||
const bGen = isGen(b.node.filePath) ? 1 : 0;
|
||||
return aGen - bGen;
|
||||
});
|
||||
|
||||
const locations = ranked.map(r =>
|
||||
`${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`
|
||||
);
|
||||
const note = `\n\n> **Note:** Aggregated results across ${ranked.length} symbols named "${symbol}": ${locations.join(', ')}`;
|
||||
return { nodes: ranked.map(r => r.node), note };
|
||||
}
|
||||
|
||||
/** Node kinds that can sit on a call chain. */
|
||||
export const FLOW_CALLABLE_KINDS: ReadonlySet<string> = new Set([
|
||||
'method',
|
||||
'function',
|
||||
'component',
|
||||
'constructor',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Node kinds that can be an endpoint of a SYNTHESIZED edge without being
|
||||
* callable. An RTK thunk is `const X = createAsyncThunk(...)`, so a thunk →
|
||||
* thunk hop is constant → constant and the callable-only set cannot hold it.
|
||||
*/
|
||||
const DYN_KINDS: ReadonlySet<string> = new Set(['constant', 'variable', 'field', 'property']);
|
||||
|
||||
/** Only a REAL file extension is stripped from a token — `Class.method` is kept. */
|
||||
const FILE_EXT =
|
||||
/\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl)$/i;
|
||||
|
||||
/** Chain length ceiling, in NODES. Explore's Flow section has always used 7. */
|
||||
export const DEFAULT_MAX_HOPS = 7;
|
||||
|
||||
/**
|
||||
* Longer ceiling for a directed question.
|
||||
*
|
||||
* "How does X reach Y" is asked about two symbols that a reader believes are
|
||||
* connected, and a real call path between a CLI entry point and a storage
|
||||
* primitive runs deeper than seven frames. Explore's ceiling stays where it is:
|
||||
* there, a longer chain is a bigger guess, because nothing pins the far end.
|
||||
*/
|
||||
export const DIRECTED_MAX_HOPS = 12;
|
||||
|
||||
/** At most one consecutive UNNAMED hop may bridge two named symbols. */
|
||||
export const DEFAULT_MAX_BRIDGE = 1;
|
||||
|
||||
/** Seeds a `named` search starts from, and candidates an ambiguous token keeps. */
|
||||
const MAX_SEEDS = 8;
|
||||
const MAX_CANDIDATES_PER_TOKEN = 6;
|
||||
|
||||
/**
|
||||
* Candidates a DIRECTED endpoint keeps, and the seeds it therefore walks from.
|
||||
*
|
||||
* Higher than the `named` cap, and the reason is a real failure: `main` has ten
|
||||
* definitions in this repository — a Python asset script, a Rust build script,
|
||||
* four `scripts/*.mjs` one-offs, a Go fixture — and the CLI's own `main`, the
|
||||
* one anybody asking "how does main reach X" means, sorts SEVENTH. A cap of six
|
||||
* silently answered "these two symbols are not connected". Both endpoints are
|
||||
* pinned here, so an extra candidate costs one bounded walk that ends the
|
||||
* moment it reaches the destination, and the pair that connects is the answer.
|
||||
*/
|
||||
const MAX_CANDIDATES_DIRECTED = 12;
|
||||
const MAX_TOKENS = 16;
|
||||
const MAX_NAMED = 40;
|
||||
|
||||
export interface FlowStep {
|
||||
node: Node;
|
||||
/** The edge INTO this node from the previous step; null on the first. */
|
||||
edge: Edge | null;
|
||||
}
|
||||
|
||||
export interface FlowChain {
|
||||
steps: FlowStep[];
|
||||
/** For each node on the chain, the line where it calls the NEXT one. */
|
||||
callSites: Map<string, number>;
|
||||
}
|
||||
|
||||
export interface NamedSymbolFlowOptions {
|
||||
/** `named` = explore's rules; `directed` = a pinned from → to question. */
|
||||
mode?: 'named' | 'directed';
|
||||
/** Required in `directed` mode: the token the path must start at. */
|
||||
from?: string;
|
||||
/** Required in `directed` mode: the token the path must end at. */
|
||||
to?: string;
|
||||
maxHops?: number;
|
||||
/** Consecutive unnamed hops allowed. `Infinity` in directed mode. */
|
||||
maxBridge?: number;
|
||||
/** Distinct chains to return. Explore only ever looks at the first. */
|
||||
maxChains?: number;
|
||||
}
|
||||
|
||||
export interface NamedSymbolFlow {
|
||||
/** The query's symbol tokens, in the order they were written. */
|
||||
tokens: string[];
|
||||
/** Every CALLABLE the tokens resolved to, by node id. */
|
||||
named: Map<string, Node>;
|
||||
/** Non-callable endpoints of synthesized edges (RTK thunks and friends). */
|
||||
dynNamed: Map<string, Node>;
|
||||
/** token → the node ids it resolved to. */
|
||||
tokenNodes: Map<string, string[]>;
|
||||
/** token → its whole same-name callable family, before the container filter. */
|
||||
tokenFamily: Map<string, Node[]>;
|
||||
/** Ids whose token was a (near-)unique callable name — at most 3 defs. */
|
||||
uniqueNamedNodeIds: Set<string>;
|
||||
/** Ids resolved from a shape-precise token (camelCase, dotted, PascalCase…). */
|
||||
preciseNamedIds: Set<string>;
|
||||
/** Chains found, best first. Empty when nothing connects. */
|
||||
chains: FlowChain[];
|
||||
}
|
||||
|
||||
const EMPTY_FLOW = (): NamedSymbolFlow => ({
|
||||
tokens: [],
|
||||
named: new Map(),
|
||||
dynNamed: new Map(),
|
||||
tokenNodes: new Map(),
|
||||
tokenFamily: new Map(),
|
||||
uniqueNamedNodeIds: new Set(),
|
||||
preciseNamedIds: new Set(),
|
||||
chains: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* Production code before test and fixture code, otherwise the order the index
|
||||
* ranked them in.
|
||||
*
|
||||
* Only used for a directed question, where the candidates are the two ends of
|
||||
* "how does X reach Y" and a fixture's `main` is never what was meant. In
|
||||
* `named` mode the agent's own co-naming does this job and re-ranking would
|
||||
* change what `codegraph_explore` answers.
|
||||
*/
|
||||
function rankForDirected(nodes: readonly Node[]): Node[] {
|
||||
return [...nodes].sort(
|
||||
(a, b) => (isTestFile(a.filePath) ? 1 : 0) - (isTestFile(b.filePath) ? 1 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A token is shape-precise when it looks like a symbol reference rather than an
|
||||
* English word that happened to exact-match a callable.
|
||||
*/
|
||||
function isPreciseToken(token: string): boolean {
|
||||
return /[._$]|::|\//.test(token) || /[a-z][A-Z]/.test(token) || /^[A-Z]/.test(token);
|
||||
}
|
||||
|
||||
/** The symbol-shaped tokens of a query, deduped and capped. */
|
||||
export function flowTokens(query: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
query
|
||||
.split(/[\s,()[\]]+/)
|
||||
.map((t) => t.replace(FILE_EXT, '').trim())
|
||||
.filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t))
|
||||
),
|
||||
].slice(0, MAX_TOKENS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a query's tokens to nodes, with the overload rules described in the
|
||||
* module header. No graph traversal happens here.
|
||||
*/
|
||||
export function resolveNamedTokens(
|
||||
cg: CodeGraph,
|
||||
query: string,
|
||||
opts: NamedSymbolFlowOptions = {}
|
||||
): NamedSymbolFlow {
|
||||
const directed = opts.mode === 'directed';
|
||||
const out = EMPTY_FLOW();
|
||||
const tokens = flowTokens(query);
|
||||
out.tokens = tokens;
|
||||
if (tokens.length < 2) return out;
|
||||
|
||||
// Pool of name SEGMENTS (Class + method from every token), used to keep an
|
||||
// ambiguous simple name only where its CONTAINER class is itself named.
|
||||
const segPool = new Set<string>();
|
||||
for (const t of tokens) for (const s of t.toLowerCase().split(/::|\./)) if (s) segPool.add(s);
|
||||
|
||||
const hasHeuristicEdge = (id: string): boolean =>
|
||||
[...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic');
|
||||
|
||||
for (const t of tokens) {
|
||||
const hits = findAllSymbols(cg, t).nodes;
|
||||
const cands = hits.filter((n) => FLOW_CALLABLE_KINDS.has(n.kind));
|
||||
out.tokenFamily.set(t, cands);
|
||||
// A qualified or otherwise-specific name (<=3 hits) keeps all of them.
|
||||
const specific = cands.length <= 3;
|
||||
// In directed mode every candidate is kept and the search decides: the pair
|
||||
// of overloads that actually connects IS the disambiguation, and co-naming
|
||||
// has nothing to work with when the whole query is two words.
|
||||
const pick =
|
||||
specific || directed
|
||||
? cands
|
||||
: cands.filter((n) => {
|
||||
const segs = (n.qualifiedName || '').toLowerCase().split(/::|\./).filter(Boolean);
|
||||
const container = segs.length >= 2 ? segs[segs.length - 2] : '';
|
||||
return !!container && segPool.has(container);
|
||||
});
|
||||
const kept = directed
|
||||
? rankForDirected(pick).slice(0, MAX_CANDIDATES_DIRECTED)
|
||||
: pick.slice(0, MAX_CANDIDATES_PER_TOKEN);
|
||||
out.tokenNodes.set(
|
||||
t,
|
||||
kept.map((n) => n.id)
|
||||
);
|
||||
const precise = isPreciseToken(t);
|
||||
for (const n of kept) {
|
||||
out.named.set(n.id, n);
|
||||
if (specific) out.uniqueNamedNodeIds.add(n.id);
|
||||
if (precise) out.preciseNamedIds.add(n.id);
|
||||
}
|
||||
// Same token, non-callable synthesized endpoints. Capped per token so one
|
||||
// token's many endpoints cannot fill the pool before later tokens get a slot,
|
||||
// and gated on an actual heuristic edge so plain constants never qualify.
|
||||
if (out.dynNamed.size < 12) {
|
||||
let tokenDyn = 0;
|
||||
for (const n of hits) {
|
||||
if (FLOW_CALLABLE_KINDS.has(n.kind) || !DYN_KINDS.has(n.kind) || out.dynNamed.has(n.id)) {
|
||||
continue;
|
||||
}
|
||||
if (hasHeuristicEdge(n.id)) {
|
||||
out.dynNamed.set(n.id, n);
|
||||
if (precise) out.preciseNamedIds.add(n.id);
|
||||
tokenDyn++;
|
||||
}
|
||||
if (out.dynNamed.size >= 12 || tokenDyn >= 4) break;
|
||||
}
|
||||
}
|
||||
if (out.named.size > MAX_NAMED) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Where each node on a chain calls the next one. */
|
||||
function callSitesOf(steps: readonly FlowStep[]): Map<string, number> {
|
||||
const sites = new Map<string, number>();
|
||||
for (let i = 0; i < steps.length - 1; i++) {
|
||||
const line = steps[i + 1]?.edge?.line;
|
||||
const id = steps[i]?.node.id;
|
||||
if (id && line && line > 0 && !sites.has(id)) sites.set(id, line);
|
||||
}
|
||||
return sites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nodes one side of a search may visit before it gives up.
|
||||
*
|
||||
* The `named` cap is explore's own, unchanged: with at most one unnamed bridge
|
||||
* between named symbols the frontier cannot run away, so 1 500 is generous.
|
||||
* A directed search bridges freely and needs far more room — but it spends it
|
||||
* from two ends at once, so a side that blows past this has genuinely fanned
|
||||
* out rather than merely gone deep.
|
||||
*/
|
||||
const NAMED_VISIT_CAP = 1500;
|
||||
const DIRECTED_VISIT_CAP = 12_000;
|
||||
|
||||
/**
|
||||
* Breadth-first over `calls` edges — synthesized ones included, which is what
|
||||
* carries a flow across a callback, a re-render or a JSX child.
|
||||
*
|
||||
* This is the `named` walk: every named symbol is a possible destination, and
|
||||
* at most `maxBridge` unnamed symbols may sit between two of them. That cap is
|
||||
* what bounds the frontier, so {@link NAMED_VISIT_CAP} is generous.
|
||||
*
|
||||
* Returns the parent map, so a caller can reconstruct any reached node's path.
|
||||
*/
|
||||
function walkCalls(
|
||||
cg: CodeGraph,
|
||||
seed: Node,
|
||||
named: ReadonlySet<string>,
|
||||
maxHops: number,
|
||||
maxBridge: number
|
||||
): { parent: Map<string, { prev: string | null; edge: Edge | null; node: Node }>; reached: string[] } {
|
||||
const parent = new Map<string, { prev: string | null; edge: Edge | null; node: Node }>();
|
||||
parent.set(seed.id, { prev: null, edge: null, node: seed });
|
||||
const queue: Array<{ id: string; depth: number; streak: number }> = [
|
||||
{ id: seed.id, depth: 0, streak: 0 },
|
||||
];
|
||||
const reached: string[] = [];
|
||||
for (let head = 0; head < queue.length && parent.size < NAMED_VISIT_CAP; head++) {
|
||||
const { id, depth, streak } = queue[head]!;
|
||||
if (id !== seed.id && named.has(id)) reached.push(id);
|
||||
if (depth >= maxHops - 1) continue;
|
||||
for (const c of cg.getCallees(id)) {
|
||||
if (c.edge.kind !== 'calls' || parent.has(c.node.id)) continue;
|
||||
const newStreak = named.has(c.node.id) ? 0 : streak + 1;
|
||||
if (newStreak > maxBridge) continue;
|
||||
parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
|
||||
queue.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
|
||||
}
|
||||
}
|
||||
return { parent, reached };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A short call path from `seed` to any of `sinks`, searched from BOTH ends.
|
||||
*
|
||||
* A directed question bridges freely — nothing in the middle is "named" to keep
|
||||
* the frontier small — so a one-way walk from an entry point balloons: `main`
|
||||
* on this repository touches hundreds of symbols within four hops of a
|
||||
* twelve-hop budget. Coming in from both ends halves the depth each side has to
|
||||
* cover, and the destination end is nearly always the cheap one: a leaf has a
|
||||
* handful of callers where an entry point has an enormous fan-out.
|
||||
*
|
||||
* Measured against the one-way walk on twelve pairs from this repository's own
|
||||
* index: **identical paths, 3–6× faster** (`main -> resolveOne` 40 ms → 11 ms,
|
||||
* `main -> scanDynamicDispatch` 33 ms → 7 ms). The one-way search never
|
||||
* actually exhausted its visit cap here, so the reachability headroom below is
|
||||
* insurance for a graph much larger than this one, not a fix for a bug that was
|
||||
* observed.
|
||||
*
|
||||
* It alternates a level at a time, always expanding the SMALLER frontier, and
|
||||
* stops the moment the two sides share a node. Alternating levels this way can
|
||||
* return a path one hop longer than the true shortest — which is why nothing in
|
||||
* the payload claims to be shortest, only to be a path the graph records.
|
||||
*/
|
||||
function walkBidirectional(
|
||||
cg: CodeGraph,
|
||||
seed: Node,
|
||||
sinks: ReadonlySet<string>,
|
||||
maxHops: number
|
||||
): FlowStep[] | null {
|
||||
if (sinks.has(seed.id)) return null;
|
||||
|
||||
const forward = new Map<string, { prev: string | null; edge: Edge | null; node: Node }>();
|
||||
/** id → the edge OUT of it towards the destination; null AT the destination. */
|
||||
const backward = new Map<string, { next: string; edge: Edge } | null>();
|
||||
const backNodes = new Map<string, Node>();
|
||||
|
||||
forward.set(seed.id, { prev: null, edge: null, node: seed });
|
||||
let frontF: Node[] = [seed];
|
||||
let frontB: Node[] = [];
|
||||
for (const id of sinks) {
|
||||
const node = cg.getNode(id);
|
||||
if (!node) continue;
|
||||
backward.set(id, null);
|
||||
backNodes.set(id, node);
|
||||
frontB.push(node);
|
||||
}
|
||||
if (frontB.length === 0) return null;
|
||||
|
||||
const meetAt = (): string | null => {
|
||||
// The forward side is the one that is walked in full, so scanning it is the
|
||||
// cheaper direction of the check.
|
||||
for (const id of forward.keys()) if (backward.has(id)) return id;
|
||||
return null;
|
||||
};
|
||||
|
||||
const maxEdges = Math.max(1, maxHops - 1);
|
||||
for (let laid = 0; laid < maxEdges; laid++) {
|
||||
if (frontF.length <= frontB.length) {
|
||||
if (forward.size > DIRECTED_VISIT_CAP) break;
|
||||
const next: Node[] = [];
|
||||
for (const node of frontF) {
|
||||
for (const c of cg.getCallees(node.id)) {
|
||||
if (c.edge.kind !== 'calls' || forward.has(c.node.id)) continue;
|
||||
forward.set(c.node.id, { prev: node.id, edge: c.edge, node: c.node });
|
||||
next.push(c.node);
|
||||
}
|
||||
}
|
||||
if (next.length === 0) break;
|
||||
frontF = next;
|
||||
} else {
|
||||
if (backward.size > DIRECTED_VISIT_CAP) break;
|
||||
const next: Node[] = [];
|
||||
for (const node of frontB) {
|
||||
for (const c of cg.getCallers(node.id)) {
|
||||
if (c.edge.kind !== 'calls' || backward.has(c.node.id)) continue;
|
||||
backward.set(c.node.id, { next: node.id, edge: c.edge });
|
||||
backNodes.set(c.node.id, c.node);
|
||||
next.push(c.node);
|
||||
}
|
||||
}
|
||||
if (next.length === 0) break;
|
||||
frontB = next;
|
||||
}
|
||||
|
||||
const meet = meetAt();
|
||||
if (meet === null) continue;
|
||||
|
||||
// Forward half: seed → meet, walking the forward parents back.
|
||||
const steps: FlowStep[] = [];
|
||||
let cur: string | null = meet;
|
||||
while (cur) {
|
||||
const at = forward.get(cur);
|
||||
if (!at) break;
|
||||
steps.push({ node: at.node, edge: at.edge });
|
||||
cur = at.prev;
|
||||
}
|
||||
steps.reverse();
|
||||
// Backward half: meet → sink. An entry holds the edge OUT of its node, so
|
||||
// it is the edge INTO the step after it, which is the shape a step wants.
|
||||
let link = backward.get(meet);
|
||||
while (link) {
|
||||
const node = backNodes.get(link.next);
|
||||
if (!node) break;
|
||||
steps.push({ node, edge: link.edge });
|
||||
link = backward.get(link.next);
|
||||
}
|
||||
|
||||
const last = steps[steps.length - 1];
|
||||
if (steps.length < 2 || !last || !sinks.has(last.node.id)) return null;
|
||||
return steps.length <= maxHops ? steps : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function chainTo(
|
||||
parent: Map<string, { prev: string | null; edge: Edge | null; node: Node }>,
|
||||
target: string
|
||||
): FlowStep[] {
|
||||
const steps: FlowStep[] = [];
|
||||
let cur: string | null = target;
|
||||
while (cur) {
|
||||
const at = parent.get(cur);
|
||||
if (!at) break;
|
||||
steps.push({ node: at.node, edge: at.edge });
|
||||
cur = at.prev;
|
||||
}
|
||||
steps.reverse();
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* The call path among a query's named symbols. See the module header for what
|
||||
* the two modes mean and why they differ.
|
||||
*/
|
||||
export function resolveNamedSymbolFlow(
|
||||
cg: CodeGraph,
|
||||
query: string,
|
||||
opts: NamedSymbolFlowOptions = {}
|
||||
): NamedSymbolFlow {
|
||||
try {
|
||||
const directed = opts.mode === 'directed';
|
||||
const flow = resolveNamedTokens(cg, query, opts);
|
||||
if (flow.named.size < 2) return flow;
|
||||
|
||||
const maxHops = opts.maxHops ?? (directed ? DIRECTED_MAX_HOPS : DEFAULT_MAX_HOPS);
|
||||
const maxBridge = opts.maxBridge ?? (directed ? Number.POSITIVE_INFINITY : DEFAULT_MAX_BRIDGE);
|
||||
const maxChains = Math.max(1, opts.maxChains ?? 1);
|
||||
const namedIds = new Set(flow.named.keys());
|
||||
|
||||
const found: FlowStep[][] = [];
|
||||
if (directed) {
|
||||
const fromIds = flow.tokenNodes.get(normalizeToken(opts.from ?? '')) ?? [];
|
||||
const toIds = flow.tokenNodes.get(normalizeToken(opts.to ?? '')) ?? [];
|
||||
if (fromIds.length === 0 || toIds.length === 0) return flow;
|
||||
const sinks = new Set(toIds);
|
||||
// Every candidate start is searched: each is a bounded two-ended walk that
|
||||
// ends the moment the frontiers meet, and the start that actually connects
|
||||
// IS the answer to which overload was meant.
|
||||
for (const id of fromIds) {
|
||||
const seed = flow.named.get(id);
|
||||
if (!seed) continue;
|
||||
const steps = walkBidirectional(cg, seed, sinks, maxHops);
|
||||
if (steps) found.push(steps);
|
||||
}
|
||||
} else {
|
||||
for (const seed of [...flow.named.values()].slice(0, MAX_SEEDS)) {
|
||||
const { parent, reached } = walkCalls(cg, seed, namedIds, maxHops, maxBridge);
|
||||
// Explore's rule: the DEEPEST named sink this seed can reach.
|
||||
let deepest: FlowStep[] | null = null;
|
||||
for (const id of reached) {
|
||||
const steps = chainTo(parent, id);
|
||||
if (!deepest || steps.length > deepest.length) deepest = steps;
|
||||
}
|
||||
if (deepest) found.push(deepest);
|
||||
}
|
||||
}
|
||||
|
||||
if (found.length === 0) return flow;
|
||||
found.sort((a, b) => (directed ? a.length - b.length : b.length - a.length));
|
||||
|
||||
// Identical chains, and chains that are just a shorter run along one
|
||||
// already kept, are the same answer twice: `a → b → c` and `b → c` differ
|
||||
// only in where the seed happened to be. Alternatives are for genuinely
|
||||
// different routes — a second overload, a different intermediate.
|
||||
const kept: string[] = [];
|
||||
for (const steps of found) {
|
||||
const key = steps.map((s) => s.node.id).join('>');
|
||||
if (kept.some((other) => other === key || other.includes(key))) continue;
|
||||
kept.push(key);
|
||||
flow.chains.push({ steps, callSites: callSitesOf(steps) });
|
||||
if (flow.chains.length >= maxChains) break;
|
||||
}
|
||||
return flow;
|
||||
} catch {
|
||||
return EMPTY_FLOW();
|
||||
}
|
||||
}
|
||||
|
||||
/** The token spelling {@link flowTokens} would have produced for one word. */
|
||||
export function normalizeToken(token: string): string {
|
||||
return token.replace(FILE_EXT, '').trim();
|
||||
}
|
||||
+28
-268
@@ -41,6 +41,12 @@ import {
|
||||
import { createHash } from 'crypto';
|
||||
import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils';
|
||||
import { scanDynamicDispatch } from './dynamic-boundaries';
|
||||
import {
|
||||
lastQualifierPart,
|
||||
matchesSymbol,
|
||||
findAllSymbols,
|
||||
resolveNamedSymbolFlow,
|
||||
} from '../graph/named-symbol-flow';
|
||||
import { getUpdateNotice } from '../upgrade/update-check';
|
||||
import { ExploreDiagnostics } from './explore-diagnostics';
|
||||
import {
|
||||
@@ -108,14 +114,6 @@ const MAX_INPUT_LENGTH = 10_000;
|
||||
*/
|
||||
const MAX_PATH_LENGTH = 4_096;
|
||||
|
||||
/**
|
||||
* Rust path roots that have no file-system equivalent — `crate` is the
|
||||
* current crate, `super` is the parent module, `self` is the current
|
||||
* module. Used by `matchesSymbol` to strip these before file-path
|
||||
* matching so `crate::configurator::stage_apply::run` resolves the
|
||||
* same as `configurator::stage_apply::run`.
|
||||
*/
|
||||
const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
|
||||
|
||||
/**
|
||||
* Node kinds that contain other symbols. For these, `codegraph_node` with
|
||||
@@ -127,16 +125,6 @@ const CONTAINER_NODE_KINDS = new Set<NodeKind>([
|
||||
'class', 'struct', 'union', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Last `::` / `.` / `/`-separated segment of a qualified symbol. An Erlang
|
||||
* arity tail (`mod::fn/3`, `fn/3`) is stripped first — the useful last segment
|
||||
* is the function name, never the digits (#1610).
|
||||
*/
|
||||
function lastQualifierPart(symbol: string): string {
|
||||
const noArity = symbol.replace(/\/\d{1,3}$/, '') || symbol;
|
||||
const parts = noArity.split(/::|[./]/).filter((p) => p.length > 0);
|
||||
return parts[parts.length - 1] ?? symbol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Erlang-native symbol spellings in an explore query into the shapes
|
||||
@@ -2557,98 +2545,13 @@ export class ToolHandler {
|
||||
// processRunExecutionData) to the call site instead of dumping the whole body.
|
||||
const EMPTY = { text: '', pathNodeIds: new Set<string>(), namedNodeIds: new Set<string>(), uniqueNamedNodeIds: new Set<string>(), spineCallSites: new Map<string, number>() };
|
||||
try {
|
||||
const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
|
||||
// Strip only a REAL file extension (Create.cs → Create); KEEP qualified
|
||||
// names (Class.method / Class::method) — the agent's most precise input,
|
||||
// resolved exactly by findAllSymbols. (The old strip mangled Class.method
|
||||
// into Class, throwing the method away.)
|
||||
const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl)$/i;
|
||||
const tokens = [...new Set(
|
||||
query.split(/[\s,()[\]]+/)
|
||||
.map((t) => t.replace(FILE_EXT, '').trim())
|
||||
.filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t))
|
||||
)].slice(0, 16);
|
||||
if (tokens.length < 2) return EMPTY;
|
||||
// Pool of name SEGMENTS (Class + method from every token) used to
|
||||
// disambiguate an ambiguous SIMPLE name: keep a candidate only if its
|
||||
// CONTAINER class is itself named in the query.
|
||||
const segPool = new Set<string>();
|
||||
for (const t of tokens) for (const s of t.toLowerCase().split(/::|\./)) if (s) segPool.add(s);
|
||||
const named = new Map<string, Node>();
|
||||
// Nodes whose token is SPECIFIC — a (near-)unique callable name (<=3 defs in
|
||||
// the whole graph). These are safe to SPARE a file on: the agent named THIS
|
||||
// method (`getResponseWithInterceptorChain`, 1 def). A hyper-polymorphic name
|
||||
// (`as_sql`, 110 defs across every Expression/Compiler subclass) is NOT here,
|
||||
// so naming it doesn't keep every backend variant full and flood the budget.
|
||||
const uniqueNamedNodeIds = new Set<string>();
|
||||
// token → resolved node ids: drives the token-coverage check that gates
|
||||
// the dynamic-boundary scan (a token is covered when ANY of its nodes
|
||||
// lands on the main chain — overloads off the chain don't count against).
|
||||
const tokenNodes = new Map<string, string[]>();
|
||||
// token → its full same-name callable family (before the container filter).
|
||||
// A LARGE family that fails to connect on the chain is a polymorphic
|
||||
// interface/registry dispatch — surfaced by buildPolymorphicBoundaries below.
|
||||
const tokenFamily = new Map<string, Node[]>();
|
||||
// Non-callable endpoints (CONSTANT/VARIABLE/FIELD) connected by a SYNTHESIZED
|
||||
// edge. RTK thunks are `const X = createAsyncThunk(...)`, so a thunk→thunk hop
|
||||
// is constant→constant — the CALLABLE-only `named` set can't hold it, and
|
||||
// without this the hop is invisible to the Flow path at every tier (the
|
||||
// Relationships section catches it only on repos ≥500 files). Kept SEPARATE
|
||||
// from `named` (which drives the call-chain + source sizing, callable-only);
|
||||
// fed only to the dynamic-dispatch-links scan below.
|
||||
const dynNamed = new Map<string, Node>();
|
||||
const DYN_KINDS = new Set(['constant', 'variable', 'field', 'property']);
|
||||
// Nodes resolved from a SHAPE-PRECISE token (camelCase / PascalCase /
|
||||
// snake_case / qualified) — the same test the gather path uses. It is the
|
||||
// difference between "the agent named this symbol" and "an ordinary English
|
||||
// word in a prose question collided with a callable", and it is what makes
|
||||
// the narrative-less return below safe (see `identityOnly`).
|
||||
const isPreciseToken = (x: string) =>
|
||||
/[._$]|::|\//.test(x) || /[a-z][A-Z]/.test(x) || /^[A-Z]/.test(x);
|
||||
const preciseNamedIds = new Set<string>();
|
||||
const hasHeuristicEdge = (id: string): boolean =>
|
||||
[...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic');
|
||||
for (const t of tokens) {
|
||||
const hits = this.findAllSymbols(cg, t).nodes;
|
||||
const cands = hits.filter((n) => CALLABLE.has(n.kind));
|
||||
tokenFamily.set(t, cands);
|
||||
// A qualified or otherwise-specific name (<=3 hits) keeps all; an
|
||||
// ambiguous simple name keeps only candidates whose container is named.
|
||||
const specific = cands.length <= 3;
|
||||
const pick = specific
|
||||
? cands
|
||||
: cands.filter((n) => {
|
||||
const segs = (n.qualifiedName || '').toLowerCase().split(/::|\./).filter(Boolean);
|
||||
const container = segs.length >= 2 ? segs[segs.length - 2] : '';
|
||||
return !!container && segPool.has(container);
|
||||
});
|
||||
const kept = pick.slice(0, 6);
|
||||
tokenNodes.set(t, kept.map((n) => n.id));
|
||||
const precise = isPreciseToken(t);
|
||||
for (const n of kept) {
|
||||
named.set(n.id, n);
|
||||
if (specific) uniqueNamedNodeIds.add(n.id);
|
||||
if (precise) preciseNamedIds.add(n.id);
|
||||
}
|
||||
// Same token, non-callable synth endpoints (capped, precision-gated on an
|
||||
// actual heuristic edge so plain config constants never qualify).
|
||||
// Per-token sub-cap so one token's many endpoints (10 nix option writes
|
||||
// of `programs.git.enable` across test configs) can't fill the pool
|
||||
// before later tokens (`home.file`) get a slot.
|
||||
if (dynNamed.size < 12) {
|
||||
let tokenDyn = 0;
|
||||
for (const n of hits) {
|
||||
if (CALLABLE.has(n.kind) || !DYN_KINDS.has(n.kind) || dynNamed.has(n.id)) continue;
|
||||
if (hasHeuristicEdge(n.id)) {
|
||||
dynNamed.set(n.id, n);
|
||||
if (precise) preciseNamedIds.add(n.id);
|
||||
tokenDyn++;
|
||||
}
|
||||
if (dynNamed.size >= 12 || tokenDyn >= 4) break;
|
||||
}
|
||||
}
|
||||
if (named.size > 40) break;
|
||||
}
|
||||
// Token resolution — parsing, overload disambiguation, the CONSTANT/
|
||||
// VARIABLE synth endpoints — is shared with `/api/flow`, so a name written
|
||||
// in the viewer's search box resolves to the same nodes it does here.
|
||||
const flow = resolveNamedSymbolFlow(cg, query);
|
||||
const { named, dynNamed, tokenNodes, tokenFamily, uniqueNamedNodeIds, preciseNamedIds } =
|
||||
flow;
|
||||
if (flow.tokens.length < 2) return EMPTY;
|
||||
// Surface synthesized (heuristic) edges incident to a named symbol — INCLUDING
|
||||
// the non-callable CONSTANT endpoints in `dynNamed`. `skipInChain` drops a hop
|
||||
// already shown in the rendered main chain (a 2-node chain renders nothing, so a
|
||||
@@ -2720,47 +2623,16 @@ export class ToolHandler {
|
||||
out.push('> Full source for these symbols is below.\n');
|
||||
return { text: out.join('\n'), pathNodeIds: new Set(), namedNodeIds: new Set<string>([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites: new Map<string, number>() };
|
||||
}
|
||||
const MAX_HOPS = 7;
|
||||
let best: Array<{ node: Node; edge: Edge | null }> | null = null;
|
||||
// BFS the full call graph (incl. synth edges) from each named seed, but
|
||||
// only ACCEPT a sink that is also named — both ends anchored to symbols the
|
||||
// agent named, so the chain stays on-topic while bridging intermediates
|
||||
// (e.g. the exact interface overload) that the token resolution missed.
|
||||
for (const seed of [...named.values()].slice(0, 8)) {
|
||||
const parent = new Map<string, { prev: string | null; edge: Edge | null; node: Node }>();
|
||||
parent.set(seed.id, { prev: null, edge: null, node: seed });
|
||||
const q: Array<{ id: string; depth: number; streak: number }> = [{ id: seed.id, depth: 0, streak: 0 }];
|
||||
let deep: string | null = null, deepDepth = 0;
|
||||
const MAX_BRIDGE = 1; // ≤1 consecutive UNNAMED hop: bridge one missing intermediate, never wander a god-function's fan-out
|
||||
for (let h = 0; h < q.length && parent.size < 1500; h++) {
|
||||
const { id, depth, streak } = q[h]!;
|
||||
if (id !== seed.id && named.has(id) && depth > deepDepth) { deep = id; deepDepth = depth; }
|
||||
if (depth >= MAX_HOPS - 1) continue;
|
||||
for (const c of cg.getCallees(id)) {
|
||||
if (c.edge.kind !== 'calls' || parent.has(c.node.id)) continue;
|
||||
const newStreak = named.has(c.node.id) ? 0 : streak + 1;
|
||||
if (newStreak > MAX_BRIDGE) continue;
|
||||
parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
|
||||
q.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
|
||||
}
|
||||
}
|
||||
if (!deep) continue;
|
||||
const chain: Array<{ node: Node; edge: Edge | null }> = [];
|
||||
let cur: string | null = deep;
|
||||
while (cur) { const p = parent.get(cur); if (!p) break; chain.push({ node: p.node, edge: p.edge }); cur = p.prev; }
|
||||
chain.reverse();
|
||||
if (!best || chain.length > best.length) best = chain;
|
||||
}
|
||||
// The search itself lives in `../graph/named-symbol-flow`, so the viewer's
|
||||
// Flow strip rides exactly this path finder rather than a second one that
|
||||
// could disagree with it. What stays here is the PROSE — the narrative,
|
||||
// the dynamic-dispatch links, the boundary announcements.
|
||||
const best = flow.chains[0]?.steps ?? null;
|
||||
const hasMain = !!best && best.length >= 3;
|
||||
const pathIds = new Set((best ?? []).map((s) => s.node.id));
|
||||
// Where each spine node calls the NEXT hop (best[i+1].edge is the edge from
|
||||
// best[i] → best[i+1]; its line is the call site inside best[i]'s body). Lets
|
||||
// the assembler window an oversize spine method to the call instead of dumping it.
|
||||
const spineCallSites = new Map<string, number>();
|
||||
if (best) for (let i = 0; i < best.length - 1; i++) {
|
||||
const ln = best[i + 1]?.edge?.line;
|
||||
if (ln && ln > 0 && !spineCallSites.has(best[i]!.node.id)) spineCallSites.set(best[i]!.node.id, ln);
|
||||
}
|
||||
// Where each spine node calls the NEXT hop — lets the assembler window an
|
||||
// oversize spine method to the call instead of dumping the whole body.
|
||||
const spineCallSites = flow.chains[0]?.callSites ?? new Map<string, number>();
|
||||
|
||||
// Dynamic-boundary scan (#687) — fires ONLY when the flow the agent
|
||||
// asked about did not fully connect: some token resolved to nodes but
|
||||
@@ -6715,71 +6587,11 @@ export class ToolHandler {
|
||||
* Returns the best match and a note about alternatives if any.
|
||||
*/
|
||||
/**
|
||||
* Check if a node matches a symbol query.
|
||||
*
|
||||
* Accepts simple names (`run`) and three flavors of qualifier:
|
||||
* - dotted `Session.request` (TS/JS/Python)
|
||||
* - colon-pair `stage_apply::run` (Rust, C++, Ruby)
|
||||
* - slash `configurator/stage_apply` (path-ish)
|
||||
*
|
||||
* Multi-level qualifiers compose: `crate::configurator::stage_apply::run`
|
||||
* works. Rust path prefixes (`crate`, `super`, `self`) are stripped so
|
||||
* the canonical `crate::module::symbol` form resolves.
|
||||
*
|
||||
* Resolution order, last part must always equal `node.name`:
|
||||
* 1. Suffix-match against `qualifiedName` (handles class-scoped methods
|
||||
* where the extractor builds the qualified name from the AST stack)
|
||||
* 2. File-path containment (handles file-derived modules in Rust/
|
||||
* Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
|
||||
* Check if a node matches a symbol query — see `matchesSymbol` in
|
||||
* `../graph/named-symbol-flow`, which owns the rules.
|
||||
*/
|
||||
private matchesSymbol(node: Node, symbol: string): boolean {
|
||||
// Erlang arity spelling (`fn/3`, `mod:fn/3` → normalized `mod.fn/3`): when
|
||||
// the node's qualifiedName carries an arity (`mod::fn/3`, #1610), the
|
||||
// written arity must match it exactly; the remaining comparison then runs
|
||||
// on the arity-less spelling. A node with no arity in its qualifiedName
|
||||
// keeps the original symbol (a `/` there means a path-ish name instead).
|
||||
const aritySpelling = /^(.+)\/(\d{1,3})$/.exec(symbol);
|
||||
if (aritySpelling) {
|
||||
const nodeArity = /\/(\d{1,3})$/.exec(node.qualifiedName ?? '')?.[1];
|
||||
if (nodeArity !== undefined) {
|
||||
if (nodeArity !== aritySpelling[2]) return false;
|
||||
symbol = aritySpelling[1]!;
|
||||
}
|
||||
}
|
||||
// Simple name match
|
||||
if (node.name === symbol) return true;
|
||||
// File basename match (e.g., "product-card" matches "product-card.liquid")
|
||||
if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
|
||||
|
||||
// Qualified-name lookups: split on any supported separator. `\w` keeps
|
||||
// identifier chars (incl. `_`) intact; everything else is treated as
|
||||
// a separator we tolerate.
|
||||
if (!/[.\/]|::/.test(symbol)) return false;
|
||||
const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
|
||||
if (parts.length < 2) return false;
|
||||
|
||||
const lastPart = parts[parts.length - 1]!;
|
||||
if (node.name !== lastPart) return false;
|
||||
|
||||
// Stage 1: qualified-name suffix match. The extractor joins the
|
||||
// semantic hierarchy with `::`, so `Session.request` and
|
||||
// `Session::request` both become `Session::request` here.
|
||||
const colonSuffix = parts.join('::');
|
||||
if (node.qualifiedName.includes(colonSuffix)) return true;
|
||||
|
||||
// Stage 2: file-path containment. Rust modules and Python packages
|
||||
// are not in `qualifiedName` — they're encoded in the file path. So
|
||||
// `stage_apply::run` matches a `run` in any file whose path
|
||||
// contains a `stage_apply` segment (with or without an extension).
|
||||
//
|
||||
// Filter out Rust path prefixes that have no file-system equivalent.
|
||||
const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
|
||||
if (containerHints.length === 0) return false;
|
||||
|
||||
const segments = node.filePath.split('/').filter((s) => s.length > 0);
|
||||
return containerHints.every((hint) =>
|
||||
segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
|
||||
);
|
||||
return matchesSymbol(node, symbol);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6843,64 +6655,12 @@ export class ToolHandler {
|
||||
/**
|
||||
* Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
|
||||
* results across all matching symbols (e.g., multiple classes with an `execute` method).
|
||||
*
|
||||
* The resolution itself lives in `../graph/named-symbol-flow`, so the Flow
|
||||
* strip and `codegraph_explore` resolve a written name to the same nodes.
|
||||
*/
|
||||
private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
|
||||
// Nix option paths: the declaration is stored as `options.<path>` and
|
||||
// config writes carry longer/quoted tails (`<path>."git/config".text`),
|
||||
// so a dotted option token (`xdg.configFile`, `launchd.user.agents`) has
|
||||
// no exact-name node and would degrade to bare-tail FTS soup — burying
|
||||
// the declaration hub the nix-option-path edges hang off. Resolve the
|
||||
// convention directly: declaration first, then the exact write, then a
|
||||
// capped prefix scan of write sites. Three index hits; non-nix graphs
|
||||
// fall straight through.
|
||||
if (/^[a-z][\w'-]*(?:\.[\w'-]+)+$/.test(symbol)) {
|
||||
const optionHits = [
|
||||
...cg.getNodesByName(`options.${symbol}`),
|
||||
...cg.getNodesByName(symbol),
|
||||
...cg.getNodesByNamePrefix(`${symbol}.`, 12),
|
||||
].filter((n) => n.language === 'nix');
|
||||
if (optionHits.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
const nodes = optionHits.filter((n) => !seen.has(n.id) && !!seen.add(n.id)).slice(0, 10);
|
||||
return { nodes, note: '' };
|
||||
}
|
||||
}
|
||||
let results = cg.searchNodes(symbol, { limit: 50 });
|
||||
|
||||
// Mirror the fallback in `findSymbol` for qualified queries — FTS
|
||||
// strips colons, so a module-qualified lookup needs a second pass
|
||||
// by the bare last part.
|
||||
if (results.length === 0 && /[.\/]|::/.test(symbol)) {
|
||||
const tail = lastQualifierPart(symbol);
|
||||
if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return { nodes: [], note: '' };
|
||||
}
|
||||
|
||||
const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
|
||||
|
||||
if (exactMatches.length <= 1) {
|
||||
const node = exactMatches[0]?.node ?? results[0]!.node;
|
||||
return { nodes: [node], note: '' };
|
||||
}
|
||||
|
||||
// Same generated-file down-rank as findSymbol — keeps callers/callees
|
||||
// /impact aggregation aligned (a query against "Send" returns the
|
||||
// hand-written implementations before the protobuf scaffold).
|
||||
const isGen = cg.generatedFilePredicate(exactMatches.map((r) => r.node.filePath));
|
||||
const ranked = [...exactMatches].sort((a, b) => {
|
||||
const aGen = isGen(a.node.filePath) ? 1 : 0;
|
||||
const bGen = isGen(b.node.filePath) ? 1 : 0;
|
||||
return aGen - bGen;
|
||||
});
|
||||
|
||||
const locations = ranked.map(r =>
|
||||
`${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`
|
||||
);
|
||||
const note = `\n\n> **Note:** Aggregated results across ${ranked.length} symbols named "${symbol}": ${locations.join(', ')}`;
|
||||
return { nodes: ranked.map(r => r.node), note };
|
||||
return findAllSymbols(cg, symbol);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* `GET /api/flow` — the call path between two symbols, as cards.
|
||||
*
|
||||
* The Flow strip answers "how does A reach B" (design spec §3.5): one card per
|
||||
* hop, each opened at the exact line that makes the next call, with the
|
||||
* synthesized dynamic-dispatch hops drawn dashed and carrying the site they
|
||||
* were wired at. This endpoint produces that path and the source windows for
|
||||
* it; the geometry is a pure function in the viewer (`ui/src/lib/flow-model.ts`).
|
||||
*
|
||||
* **The path finder is not ours.** It is `resolveNamedSymbolFlow` in
|
||||
* `src/graph/named-symbol-flow.ts` — literally the search `codegraph_explore`
|
||||
* leads its answer with, extracted so both callers ride one implementation.
|
||||
* A viewer that drew a different path from the one the MCP tool describes would
|
||||
* be worse than no viewer: the two would be quoted against each other in a code
|
||||
* review and one of them would be wrong.
|
||||
*
|
||||
* Three questions arrive here, and they are one question with different
|
||||
* bindings:
|
||||
*
|
||||
* - `?from=&to=` — a directed question, from the search box's flow grammar.
|
||||
* Both ends pinned, shortest path wins.
|
||||
* - `?symbols=a,b,c` — explore's own question, verbatim. Longest chain among
|
||||
* the named symbols wins, at most one unnamed bridge.
|
||||
* - `?hop=s<id>&hop=d<id>…` — the trail, read as a flow. Nothing is searched:
|
||||
* the hops are the ones the reader walked, and the work is finding the edge
|
||||
* that already connects each consecutive pair.
|
||||
*
|
||||
* **Nothing here is cached.** Every other multi-symbol endpoint memoises on the
|
||||
* index version, and this one deliberately does not: a flow card carries source
|
||||
* read from disk, and the drift verdict on it changes without the index
|
||||
* changing. A cached "no drift" is exactly the failure `/api/source` exists to
|
||||
* prevent. The search itself costs tens of milliseconds; the windows are seven
|
||||
* lines each.
|
||||
*/
|
||||
|
||||
import type CodeGraph from '../../index';
|
||||
import type { Edge, Node } from '../../types';
|
||||
import {
|
||||
resolveNamedSymbolFlow,
|
||||
normalizeToken,
|
||||
DIRECTED_MAX_HOPS,
|
||||
} from '../../graph/named-symbol-flow';
|
||||
import { highlightLines, type HighlightResult } from '../highlight';
|
||||
import { badRequest, intParam } from './respond';
|
||||
import { findIndexedFile, hasDriftedOnDisk, splitLines, toRequestPath } from './source';
|
||||
import { resolveProjectFile } from '../security';
|
||||
import { toNodeRef, toWireEdge, UNCERTAIN_BELOW, type WireEdge, type WireNodeRef } from './wire';
|
||||
import * as fs from 'fs';
|
||||
|
||||
/** Lines shown either side of the call site on a card (design spec §3.5). */
|
||||
export const SOURCE_WINDOW = 3;
|
||||
|
||||
/**
|
||||
* How far above a window the highlighter is allowed to start reading.
|
||||
*
|
||||
* Seven lines tokenised on their own do not know they are inside a block
|
||||
* comment or a template literal, and a window that opens under a JSDoc would
|
||||
* render the prose as code. Leading in from the enclosing symbol's first line
|
||||
* fixes that for every ordinary body; the cap stops a thousand-line god
|
||||
* function from costing a full-file tokenisation for one card. Past it a window
|
||||
* can still open mid-construct — rare, and cheaper than the alternative.
|
||||
*/
|
||||
const HIGHLIGHT_LEAD_MAX = 200;
|
||||
|
||||
/** Distinct paths returned. The header's flow picker is a short list or nothing. */
|
||||
export const MAX_FLOWS = 4;
|
||||
|
||||
/** Hops accepted from a trail. The trail bar itself is not much longer than this. */
|
||||
const MAX_TRAIL_HOPS = 24;
|
||||
|
||||
// =============================================================================
|
||||
// Wire shapes
|
||||
// =============================================================================
|
||||
|
||||
export interface WireFlowEdge extends WireEdge {
|
||||
/** The link's label: "calls", "via callback · registered at file:line". */
|
||||
label: string;
|
||||
/** This hop reads callee → caller — the reader stepped UP into it. */
|
||||
upward: boolean;
|
||||
/** `metadata.confidence` below {@link UNCERTAIN_BELOW}: dashed `2 3`. */
|
||||
uncertain: boolean;
|
||||
/** A synthesized dynamic-dispatch bridge: dashed `5 3`. */
|
||||
synthesized: boolean;
|
||||
}
|
||||
|
||||
export interface WireFlowSource {
|
||||
file: string;
|
||||
language: string;
|
||||
from: number;
|
||||
to: number;
|
||||
/** Absent when `drift` — a mis-sliced window is worse than an empty card. */
|
||||
lines?: string[];
|
||||
highlight?: HighlightResult;
|
||||
drift: boolean;
|
||||
/** Why there are no lines, when there are none. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The call site this card is opened at — the identifier the strip draws as a
|
||||
* link, and the line the source window is centred on.
|
||||
*
|
||||
* It is not always a call to the NEXT card. Reading a trail backwards steps
|
||||
* from a callee up to its caller, and the line that connects them then lives in
|
||||
* the caller's body and names the symbol on the PREVIOUS card. Either way the
|
||||
* rule is the same: a card opens at the line that ties it to its neighbour.
|
||||
*/
|
||||
export interface WireFlowCallRef {
|
||||
line: number;
|
||||
/** 0-based column the edge recorded, or null when it carries none. */
|
||||
col: number | null;
|
||||
/** The identifier as the graph names it — what the token must match. */
|
||||
name: string;
|
||||
/** The symbol at the other end of the edge. */
|
||||
targetId: string;
|
||||
/** The link points back at the previous card, not on to the next one. */
|
||||
backwards: boolean;
|
||||
}
|
||||
|
||||
export interface WireFlowHop {
|
||||
node: WireNodeRef;
|
||||
/** The edge from the PREVIOUS hop into this one; null on the first. */
|
||||
edge: WireFlowEdge | null;
|
||||
/** Where this card is opened, and what it links to. Null when it is neither. */
|
||||
callRef: WireFlowCallRef | null;
|
||||
/** The window this card shows, centred on `callRef` or the definition. */
|
||||
source: WireFlowSource | null;
|
||||
}
|
||||
|
||||
export interface WireFlow {
|
||||
/** Stable within a payload: the hop ids joined. Used as the picker's value. */
|
||||
id: string;
|
||||
/** "execute → rowToFileRecord", for the header's flow picker. */
|
||||
label: string;
|
||||
hops: WireFlowHop[];
|
||||
}
|
||||
|
||||
/** An endpoint that named more than one definition, and which one was taken. */
|
||||
export interface WireFlowAmbiguity {
|
||||
token: string;
|
||||
chosen: WireNodeRef | null;
|
||||
others: WireNodeRef[];
|
||||
}
|
||||
|
||||
export interface WireFlowPayload {
|
||||
query: {
|
||||
kind: 'directed' | 'symbols' | 'trail';
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
/** The tokens the search actually used. */
|
||||
symbols: string[];
|
||||
};
|
||||
flows: WireFlow[];
|
||||
ambiguous: WireFlowAmbiguity[];
|
||||
/** Tokens that named nothing in this index. */
|
||||
unresolved: string[];
|
||||
/** Why there is no flow, when there is none. Null when there is one. */
|
||||
reason: string | null;
|
||||
index: { lastIndexedAt: number | null; edges: number; files: number };
|
||||
timing: { elapsedMs: number };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Query
|
||||
// =============================================================================
|
||||
|
||||
export type FlowQuery =
|
||||
| { kind: 'directed'; from: string; to: string }
|
||||
| { kind: 'symbols'; text: string }
|
||||
| { kind: 'trail'; hops: Array<{ id: string; dir: 'start' | 'down' | 'up' }> };
|
||||
|
||||
const DIR_CHARS: Record<string, 'start' | 'down' | 'up'> = { s: 'start', d: 'down', u: 'up' };
|
||||
|
||||
/**
|
||||
* Read the question out of the query string.
|
||||
*
|
||||
* A trail hop arrives as its own `hop` parameter rather than in one joined
|
||||
* list, for the same reason `/api/nodes` repeats `id`: a node id can be a file
|
||||
* path and a file path can contain a comma. The one-character direction prefix
|
||||
* mirrors `ui/src/lib/trail-codec.ts`, which owns the format.
|
||||
*/
|
||||
export function parseFlowQuery(query: URLSearchParams): FlowQuery {
|
||||
const rawHops = query.getAll('hop').filter((h) => h.length > 1);
|
||||
if (rawHops.length > 0) {
|
||||
if (rawHops.length > MAX_TRAIL_HOPS) {
|
||||
throw badRequest(
|
||||
`A trail of ${rawHops.length} hops is longer than this endpoint reads (${MAX_TRAIL_HOPS}).`
|
||||
);
|
||||
}
|
||||
const hops = rawHops.map((raw) => ({
|
||||
id: raw.slice(1),
|
||||
dir: DIR_CHARS[raw[0] as string] ?? ('down' as const),
|
||||
}));
|
||||
if (hops.length < 2) {
|
||||
throw badRequest('A trail needs at least two hops to be read as a flow.');
|
||||
}
|
||||
return { kind: 'trail', hops };
|
||||
}
|
||||
|
||||
const from = (query.get('from') ?? '').trim();
|
||||
const to = (query.get('to') ?? '').trim();
|
||||
if (from && to) {
|
||||
if (normalizeToken(from) === normalizeToken(to)) {
|
||||
throw badRequest('"from" and "to" name the same symbol, so there is no path to draw.');
|
||||
}
|
||||
return { kind: 'directed', from, to };
|
||||
}
|
||||
|
||||
const symbols = (query.get('symbols') ?? '').trim();
|
||||
if (symbols) return { kind: 'symbols', text: symbols };
|
||||
|
||||
throw badRequest(
|
||||
'No flow was asked for.',
|
||||
'Use /api/flow?from=<symbol>&to=<symbol>, ?symbols=a,b,c, or ?hop=s<id>&hop=d<id>.'
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Edges
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* The sentence under a link.
|
||||
*
|
||||
* A synthesized hop must never read as a plain `calls`: it is a bridge the
|
||||
* resolver inferred, and the wiring site is the evidence for it. Design spec
|
||||
* §3.5 fixes the phrasing — "via callback · registered at file:line".
|
||||
*/
|
||||
export function flowEdgeLabel(edge: Edge, upward: boolean): string {
|
||||
const meta = (edge.metadata ?? {}) as Record<string, unknown>;
|
||||
const parts: string[] = [];
|
||||
if (edge.provenance === 'heuristic' && typeof meta.synthesizedBy === 'string') {
|
||||
const mechanism = meta.synthesizedBy.replace(/-/g, ' ');
|
||||
parts.push(`via ${mechanism}`);
|
||||
if (typeof meta.via === 'string' && meta.via) parts.push(meta.via);
|
||||
if (typeof meta.registeredAt === 'string' && meta.registeredAt) {
|
||||
parts.push(`registered at ${meta.registeredAt}`);
|
||||
}
|
||||
} else {
|
||||
parts.push(upward ? 'called by' : edge.kind);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function toFlowEdge(edge: Edge, upward: boolean): WireFlowEdge {
|
||||
const meta = (edge.metadata ?? {}) as Record<string, unknown>;
|
||||
const confidence = typeof meta.confidence === 'number' ? meta.confidence : null;
|
||||
return {
|
||||
...toWireEdge(edge),
|
||||
label: flowEdgeLabel(edge, upward),
|
||||
upward,
|
||||
uncertain: confidence !== null && confidence < UNCERTAIN_BELOW,
|
||||
synthesized: edge.provenance === 'heuristic',
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Source windows
|
||||
// =============================================================================
|
||||
|
||||
/** One read + one hash per file, however many cards land in it. */
|
||||
interface FileCache {
|
||||
lines: string[] | null;
|
||||
language: string;
|
||||
drift: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function loadFile(
|
||||
cg: CodeGraph,
|
||||
projectRoot: string,
|
||||
cache: Map<string, FileCache>,
|
||||
filePath: string
|
||||
): FileCache | null {
|
||||
const posix = toRequestPath(filePath);
|
||||
const hit = cache.get(posix);
|
||||
if (hit) return hit;
|
||||
|
||||
const found = findIndexedFile(cg, posix);
|
||||
if (!found) return null;
|
||||
|
||||
let entry: FileCache;
|
||||
if (hasDriftedOnDisk(projectRoot, found.storedPath, found.record)) {
|
||||
entry = {
|
||||
lines: null,
|
||||
language: found.record.language,
|
||||
drift: true,
|
||||
reason:
|
||||
'This file changed on disk after the last index sync, so the recorded call ' +
|
||||
'line no longer reliably points at this call. The window returns after the next sync.',
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
// The chokepoint, before anything is opened — see `source.ts`.
|
||||
const absolute = resolveProjectFile(projectRoot, found.storedPath);
|
||||
entry = {
|
||||
lines: splitLines(fs.readFileSync(absolute, 'utf-8')),
|
||||
language: found.record.language,
|
||||
drift: false,
|
||||
};
|
||||
} catch {
|
||||
entry = {
|
||||
lines: null,
|
||||
language: found.record.language,
|
||||
drift: false,
|
||||
reason: 'This file is in the index but could not be read.',
|
||||
};
|
||||
}
|
||||
}
|
||||
cache.set(posix, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ±{@link SOURCE_WINDOW} lines a card shows.
|
||||
*
|
||||
* Anchored on the line that makes the next call. The last card has no next
|
||||
* call, so it anchors on the definition instead — a reader who followed seven
|
||||
* hops to get there wants to see what they arrived at.
|
||||
*/
|
||||
async function windowFor(
|
||||
cg: CodeGraph,
|
||||
projectRoot: string,
|
||||
cache: Map<string, FileCache>,
|
||||
node: Node,
|
||||
anchor: number
|
||||
): Promise<WireFlowSource | null> {
|
||||
const file = loadFile(cg, projectRoot, cache, node.filePath);
|
||||
if (!file) return null;
|
||||
const posix = toRequestPath(node.filePath);
|
||||
if (file.lines === null) {
|
||||
return {
|
||||
file: posix,
|
||||
language: file.language,
|
||||
from: anchor,
|
||||
to: anchor,
|
||||
drift: file.drift,
|
||||
...(file.reason ? { reason: file.reason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const total = file.lines.length;
|
||||
const from = Math.max(1, Math.min(anchor - SOURCE_WINDOW, total));
|
||||
const to = Math.max(from, Math.min(anchor + SOURCE_WINDOW, total));
|
||||
// Tokenise with the lead-in, then keep only the window — see HIGHLIGHT_LEAD_MAX.
|
||||
const leadFrom = Math.max(1, Math.min(from, Math.max(node.startLine, from - HIGHLIGHT_LEAD_MAX)));
|
||||
const highlighted = await highlightLines(file.lines.slice(leadFrom - 1, to), {
|
||||
language: file.language,
|
||||
cacheKey: `${posix}:${leadFrom}:${to}`,
|
||||
});
|
||||
return {
|
||||
file: posix,
|
||||
language: file.language,
|
||||
from,
|
||||
to,
|
||||
lines: file.lines.slice(from - 1, to),
|
||||
highlight: {
|
||||
...highlighted,
|
||||
lines: highlighted.lines.slice(from - leadFrom),
|
||||
},
|
||||
drift: false,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Building the flows
|
||||
// =============================================================================
|
||||
|
||||
/** The steps of one chain, plus the edge that brought the reader into each. */
|
||||
interface RawHop {
|
||||
node: Node;
|
||||
edge: Edge | null;
|
||||
upward: boolean;
|
||||
}
|
||||
|
||||
async function toWireFlow(
|
||||
cg: CodeGraph,
|
||||
projectRoot: string,
|
||||
cache: Map<string, FileCache>,
|
||||
raw: readonly RawHop[]
|
||||
): Promise<WireFlow> {
|
||||
const hops: WireFlowHop[] = [];
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const step = raw[i] as RawHop;
|
||||
const previous = raw[i - 1];
|
||||
const next = raw[i + 1];
|
||||
// Forward: the edge into the NEXT hop was recorded at the line inside THIS
|
||||
// body that makes the call. Backwards (a trail read from a callee up to its
|
||||
// caller): this card IS the caller, and its own incoming edge carries the
|
||||
// line where it calls the card before it.
|
||||
let callRef: WireFlowCallRef | null = null;
|
||||
if (next !== undefined && !next.upward && next.edge?.line) {
|
||||
callRef = {
|
||||
line: next.edge.line,
|
||||
col: typeof next.edge.column === 'number' ? next.edge.column : null,
|
||||
name: next.node.name,
|
||||
targetId: next.node.id,
|
||||
backwards: false,
|
||||
};
|
||||
} else if (step.upward && previous !== undefined && step.edge?.line) {
|
||||
callRef = {
|
||||
line: step.edge.line,
|
||||
col: typeof step.edge.column === 'number' ? step.edge.column : null,
|
||||
name: previous.node.name,
|
||||
targetId: previous.node.id,
|
||||
backwards: true,
|
||||
};
|
||||
}
|
||||
hops.push({
|
||||
node: toNodeRef(step.node),
|
||||
edge: step.edge === null ? null : toFlowEdge(step.edge, step.upward),
|
||||
callRef,
|
||||
source: await windowFor(
|
||||
cg,
|
||||
projectRoot,
|
||||
cache,
|
||||
step.node,
|
||||
callRef?.line ?? step.node.startLine
|
||||
),
|
||||
});
|
||||
}
|
||||
const first = raw[0]?.node.name ?? '?';
|
||||
const last = raw[raw.length - 1]?.node.name ?? '?';
|
||||
return {
|
||||
id: raw.map((h) => h.node.id).join('>'),
|
||||
label: `${first} → ${last}`,
|
||||
hops,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The edge that already connects two symbols the reader walked between.
|
||||
*
|
||||
* A trail is not searched — the hops are given — so all that is missing is
|
||||
* which recorded edge the reader crossed. A `down` hop is a call out of the
|
||||
* previous symbol; an `up` hop is the same edge read backwards, which is why
|
||||
* `upward` exists and why the link says "called by" rather than "calls".
|
||||
*/
|
||||
function edgeBetween(
|
||||
cg: CodeGraph,
|
||||
from: Node,
|
||||
to: Node
|
||||
): { edge: Edge; upward: boolean } | null {
|
||||
let best: Edge | null = null;
|
||||
for (const { node, edge } of cg.getCallees(from.id)) {
|
||||
if (node.id !== to.id) continue;
|
||||
if (best === null || (edge.kind === 'calls' && best.kind !== 'calls')) best = edge;
|
||||
}
|
||||
if (best) return { edge: best, upward: false };
|
||||
for (const { node, edge } of cg.getCallers(from.id)) {
|
||||
if (node.id !== to.id) continue;
|
||||
if (best === null || (edge.kind === 'calls' && best.kind !== 'calls')) best = edge;
|
||||
}
|
||||
return best ? { edge: best, upward: true } : null;
|
||||
}
|
||||
|
||||
function ambiguitiesOf(
|
||||
tokenNodes: ReadonlyMap<string, string[]>,
|
||||
named: ReadonlyMap<string, Node>,
|
||||
chosen: ReadonlySet<string>,
|
||||
tokens: readonly string[]
|
||||
): WireFlowAmbiguity[] {
|
||||
const out: WireFlowAmbiguity[] = [];
|
||||
for (const token of tokens) {
|
||||
const ids = tokenNodes.get(token) ?? [];
|
||||
if (ids.length < 2) continue;
|
||||
const picked = ids.find((id) => chosen.has(id)) ?? null;
|
||||
out.push({
|
||||
token,
|
||||
chosen: picked ? toNodeRef(named.get(picked) as Node) : null,
|
||||
others: ids
|
||||
.filter((id) => id !== picked)
|
||||
.map((id) => named.get(id))
|
||||
.filter((n): n is Node => !!n)
|
||||
.map(toNodeRef),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function buildFlow(
|
||||
cg: CodeGraph,
|
||||
projectRoot: string,
|
||||
query: URLSearchParams
|
||||
): Promise<WireFlowPayload> {
|
||||
const started = Date.now();
|
||||
const parsed = parseFlowQuery(query);
|
||||
const maxFlows = intParam(query, 'limit', { min: 1, max: MAX_FLOWS, default: MAX_FLOWS });
|
||||
const stats = cg.getStats();
|
||||
const cache = new Map<string, FileCache>();
|
||||
|
||||
const base = {
|
||||
flows: [] as WireFlow[],
|
||||
ambiguous: [] as WireFlowAmbiguity[],
|
||||
unresolved: [] as string[],
|
||||
reason: null as string | null,
|
||||
index: {
|
||||
lastIndexedAt: cg.getLastIndexedAt() ?? null,
|
||||
edges: stats.edgeCount,
|
||||
files: stats.fileCount,
|
||||
},
|
||||
};
|
||||
|
||||
if (parsed.kind === 'trail') {
|
||||
const byId = cg.getNodesByIds(parsed.hops.map((h) => h.id));
|
||||
const raw: RawHop[] = [];
|
||||
const missing: string[] = [];
|
||||
for (const hop of parsed.hops) {
|
||||
const node = byId.get(hop.id);
|
||||
if (!node) {
|
||||
missing.push(hop.id);
|
||||
continue;
|
||||
}
|
||||
const previous = raw[raw.length - 1];
|
||||
const link = previous ? edgeBetween(cg, previous.node, node) : null;
|
||||
raw.push({ node, edge: link?.edge ?? null, upward: link?.upward ?? hop.dir === 'up' });
|
||||
}
|
||||
const flows = raw.length >= 2 ? [await toWireFlow(cg, projectRoot, cache, raw)] : [];
|
||||
return {
|
||||
...base,
|
||||
query: { kind: 'trail', from: null, to: null, symbols: [] },
|
||||
flows,
|
||||
unresolved: missing,
|
||||
reason:
|
||||
flows.length > 0
|
||||
? null
|
||||
: 'None of the symbols on this trail are still in the index. Re-index, or start a new trail.',
|
||||
timing: { elapsedMs: Date.now() - started },
|
||||
};
|
||||
}
|
||||
|
||||
const directed = parsed.kind === 'directed';
|
||||
const text = directed ? `${parsed.from} ${parsed.to}` : parsed.text;
|
||||
const flow = resolveNamedSymbolFlow(
|
||||
cg,
|
||||
text,
|
||||
directed
|
||||
? { mode: 'directed', from: parsed.from, to: parsed.to, maxChains: maxFlows }
|
||||
: { mode: 'named', maxChains: maxFlows }
|
||||
);
|
||||
|
||||
const unresolved = flow.tokens.filter((t) => (flow.tokenNodes.get(t) ?? []).length === 0);
|
||||
const chosen = new Set(flow.chains.flatMap((c) => c.steps.map((s) => s.node.id)));
|
||||
const flows: WireFlow[] = [];
|
||||
for (const chain of flow.chains) {
|
||||
flows.push(
|
||||
await toWireFlow(
|
||||
cg,
|
||||
projectRoot,
|
||||
cache,
|
||||
chain.steps.map((s) => ({ node: s.node, edge: s.edge, upward: false }))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
query: {
|
||||
kind: parsed.kind,
|
||||
from: directed ? parsed.from : null,
|
||||
to: directed ? parsed.to : null,
|
||||
symbols: flow.tokens,
|
||||
},
|
||||
flows,
|
||||
ambiguous: ambiguitiesOf(flow.tokenNodes, flow.named, chosen, flow.tokens),
|
||||
unresolved,
|
||||
reason: flows.length > 0 ? null : noFlowReason(parsed, flow.tokens.length, unresolved),
|
||||
timing: { elapsedMs: Date.now() - started },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Why there is no strip, in the words that say what to do next.
|
||||
*
|
||||
* "Not connected" is a real answer about this index, not a failure — a flow
|
||||
* that runs through a dynamic dispatch the resolver could not bridge genuinely
|
||||
* has no static path, and saying so is the honest end of the search. CG-51
|
||||
* turns this sentence into the boundary end cap that names the dispatch site.
|
||||
*/
|
||||
function noFlowReason(
|
||||
parsed: FlowQuery,
|
||||
tokenCount: number,
|
||||
unresolved: readonly string[]
|
||||
): string {
|
||||
if (unresolved.length > 0) {
|
||||
return `${unresolved.join(' and ')} ${unresolved.length > 1 ? 'name' : 'names'} nothing in this index.`;
|
||||
}
|
||||
if (parsed.kind === 'directed') {
|
||||
return (
|
||||
`No chain of calls reaches ${parsed.to} from ${parsed.from} within ${DIRECTED_MAX_HOPS} hops. ` +
|
||||
'The path may run through a dynamic dispatch — a callback, a registry, a reflective ' +
|
||||
'call — that no static edge records.'
|
||||
);
|
||||
}
|
||||
if (tokenCount < 2) {
|
||||
return 'Name at least two symbols: a flow is a path between them.';
|
||||
}
|
||||
return 'Those symbols do not call one another, directly or through one intermediate.';
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The read-only JSON API the viewer reads its screens from.
|
||||
*
|
||||
* Nine endpoints, one per screen, each answering in a single round-trip — the
|
||||
* Ten endpoints, one per screen, each answering in a single round-trip — the
|
||||
* same principle as `codegraph_explore`: return enough that the caller does not
|
||||
* have to ask a follow-up question. Everything here is a *reader* of the
|
||||
* existing schema; nothing indexes, resolves, or writes.
|
||||
@@ -16,6 +16,7 @@
|
||||
* GET /api/routes the URL to handler map, when there is one
|
||||
* GET /api/entrypoints where to start reading: routes, roots, hubs
|
||||
* GET /api/map?root=&depth= the module map: modules, links, cycles
|
||||
* GET /api/flow?from=&to= the flow strip: one card per hop
|
||||
* ```
|
||||
*
|
||||
* It mounts on the `api` seam of `startUiServer`, which means it sits *behind*
|
||||
@@ -39,12 +40,22 @@ import { buildRoutes } from './routes';
|
||||
import { buildEntryPoints } from './entrypoints';
|
||||
import { buildNodeRefs } from './nodes';
|
||||
import { buildMap } from './map';
|
||||
import { buildFlow } from './flow';
|
||||
|
||||
export { GraphSession } from './session';
|
||||
export { ApiError } from './respond';
|
||||
export * from './wire';
|
||||
export type { WireEntryPoints, WireEntryFile, WireEntryHub } from './entrypoints';
|
||||
export type { WireNodeRefs } from './nodes';
|
||||
export type {
|
||||
WireFlowPayload,
|
||||
WireFlow,
|
||||
WireFlowHop,
|
||||
WireFlowEdge,
|
||||
WireFlowSource,
|
||||
WireFlowCallRef,
|
||||
WireFlowAmbiguity,
|
||||
} from './flow';
|
||||
export type {
|
||||
WireMapPayload,
|
||||
WireMapModule,
|
||||
@@ -89,6 +100,11 @@ const API_INDEX = {
|
||||
description: 'The repository at module granularity: modules, cross-module links, cycles.',
|
||||
params: ['root', 'depth'],
|
||||
},
|
||||
{
|
||||
path: '/api/flow',
|
||||
description: 'The call path between symbols: one hop per card, opened at the calling line.',
|
||||
params: ['from', 'to', 'symbols', 'hop', 'limit'],
|
||||
},
|
||||
{
|
||||
path: '/api/entrypoints',
|
||||
description: 'Where to start reading: routes, files that run something, and hubs.',
|
||||
@@ -122,6 +138,8 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
|
||||
return ok(res, buildNodeRefs(session.acquire(), ctx.query), ctx.method);
|
||||
case '/api/source':
|
||||
return ok(res, await buildSource(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
|
||||
case '/api/flow':
|
||||
return ok(res, await buildFlow(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
|
||||
default:
|
||||
return dispatchPathRoutes(route, res, ctx, session);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user