| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626 |
- /**
- * 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';
- import { lastQualifierPart, matchesSymbol } from './symbol-lookup';
- // Preserve the existing imports while sharing the matcher with the CLI and MCP.
- export { RUST_PATH_PREFIXES, lastQualifierPart, matchesSymbol } from './symbol-lookup';
- /**
- * 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).
- *
- * Exact matches only (#1473): a missing / mistyped name must NOT silently
- * resolve to the top fuzzy FTS hit under the caller's typed label. Closest
- * hits may appear in `note` as a did-you-mean hint when `nodes` is empty.
- */
- 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: '' };
- }
- }
- const isQualified = /[.\/]|::/.test(symbol);
- let exactNodes: Node[];
- if (!isQualified) {
- // Direct index — every exact-name overload, case-sensitive. Avoids FTS
- // ranking a differently-cased sibling above the real node (#1473 Fetch).
- exactNodes = cg.getNodesByName(symbol);
- } else {
- let results = cg.searchNodes(symbol, { limit: 50 });
- // Mirror findSymbolMatches — FTS strips colons, so re-search by bare tail.
- if (results.length === 0) {
- const tail = lastQualifierPart(symbol);
- if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
- }
- exactNodes = results
- .filter((r) => matchesSymbol(r.node, symbol))
- .map((r) => r.node);
- }
- if (exactNodes.length === 0) {
- const fuzzy = cg.searchNodes(symbol, { limit: 5 });
- const suggestions = [
- ...new Set(fuzzy.map((r) => r.node.name).filter((n) => n !== symbol)),
- ].slice(0, 3);
- const note =
- suggestions.length > 0
- ? `\n\n> **Note:** no symbol named "${symbol}". Did you mean: ${suggestions.join(', ')}?`
- : '';
- return { nodes: [], note };
- }
- if (exactNodes.length === 1) {
- return { nodes: exactNodes, 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(exactNodes.map((n) => n.filePath));
- const ranked = [...exactNodes].sort((a, b) => {
- const aGen = isGen(a.filePath) ? 1 : 0;
- const bGen = isGen(b.filePath) ? 1 : 0;
- return aGen - bGen;
- });
- const locations = ranked.map(
- (n) => `${n.kind} at ${n.filePath}:${n.startLine}`
- );
- const note = `\n\n> **Note:** Aggregated results across ${ranked.length} symbols named "${symbol}": ${locations.join(', ')}`;
- return { nodes: ranked, note };
- }
- /** Node kinds that can sit on a call chain. */
- export const FLOW_CALLABLE_KINDS: ReadonlySet<string> = new Set([
- 'method',
- 'function',
- 'component',
- 'constructor',
- 'route',
- ]);
- /**
- * Edge kinds a flow may ride. `navigates` is a screen transition (Expo Router
- * `router.push('/x')` → the route node) — a hop in the user's flow exactly as
- * a call is a hop in the program's.
- */
- export const FLOW_EDGE_KINDS: ReadonlySet<string> = new Set(['calls', 'navigates']);
- /**
- * 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);
- // RAW edges, not getCallers/getCallees: those return one row per NEIGHBOUR
- // (the #1086 de-dup), so when a pair is joined by BOTH a static and a
- // synthesized edge the static one wins and the synthesized one becomes
- // invisible — which is exactly what happens once a thunk's `dispatch(x)`
- // is walked statically. The question here is about the graph, not about
- // callers, so ask the edges directly.
- const hasHeuristicEdge = (id: string): boolean =>
- [...cg.getIncomingEdges(id), ...cg.getOutgoingEdges(id)].some(
- (e) => e.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 (!FLOW_EDGE_KINDS.has(c.edge.kind) || parent.has(c.node.id)) continue;
- // A route node is a connector, not a symbol the reader would have named:
- // crossing one costs no bridge budget.
- const newStreak = named.has(c.node.id) ? 0 : c.node.kind === 'route' ? streak : 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 (!FLOW_EDGE_KINDS.has(c.edge.kind) || 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 (!FLOW_EDGE_KINDS.has(c.edge.kind) || 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();
- }
|