named-symbol-flow.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626
  1. /**
  2. * The call path among a bag of named symbols — the one path finder.
  3. *
  4. * `codegraph_explore` leads its answer with a "Flow" section: the longest call
  5. * chain among the symbols an agent named, riding synthesized dynamic-dispatch
  6. * edges so a controller reaches its implementation through the interface. The
  7. * viewer's Flow strip (`/api/flow`, design spec §3.5) draws the same thing as
  8. * cards. They must never disagree, so the search lives here once and both
  9. * callers ride it: same token parsing, same overload disambiguation, same
  10. * bridge budget, same edges.
  11. *
  12. * What differs between the two callers is expressed as OPTIONS, not as a second
  13. * implementation:
  14. *
  15. * - **`mode: 'named'`** is exactly what explore does. Every resolved symbol is
  16. * both a possible start and a possible end, at most ONE unnamed symbol may
  17. * bridge two named ones ({@link DEFAULT_MAX_BRIDGE}), and the LONGEST chain
  18. * wins. The bridge cap is what stops the search wandering a god-function's
  19. * fan-out: the agent's own naming is the evidence that a hop is on-topic.
  20. * - **`mode: 'directed'`** is "how does X reach Y", which the agent has no way
  21. * to ask and the viewer's search box does. Both ends are pinned, so the
  22. * evidence the bridge cap was standing in for is already there and the search
  23. * bridges freely — a two-token query under the named rules could never return
  24. * more than three cards. The SHORTEST path wins, because with both ends fixed
  25. * a longer route is a detour rather than a fuller answer.
  26. *
  27. * Overloads are handled differently for the same reason. A bare ambiguous name
  28. * in `named` mode is filtered by CO-NAMING (keep `list` only where the agent
  29. * also named its class); in `directed` mode every candidate for both endpoints
  30. * is tried and the pair that actually connects is the answer — which is a
  31. * better disambiguator than co-naming and the only one available when the
  32. * whole query is two words.
  33. */
  34. import type CodeGraph from '../index';
  35. import type { Node, Edge } from '../types';
  36. import { isTestFile } from '../search/query-utils';
  37. import { lastQualifierPart, matchesSymbol } from './symbol-lookup';
  38. // Preserve the existing imports while sharing the matcher with the CLI and MCP.
  39. export { RUST_PATH_PREFIXES, lastQualifierPart, matchesSymbol } from './symbol-lookup';
  40. /**
  41. * Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
  42. * results across all matching symbols (e.g., multiple classes with an `execute` method).
  43. *
  44. * Exact matches only (#1473): a missing / mistyped name must NOT silently
  45. * resolve to the top fuzzy FTS hit under the caller's typed label. Closest
  46. * hits may appear in `note` as a did-you-mean hint when `nodes` is empty.
  47. */
  48. export function findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
  49. // Nix option paths: the declaration is stored as `options.<path>` and
  50. // config writes carry longer/quoted tails (`<path>."git/config".text`),
  51. // so a dotted option token (`xdg.configFile`, `launchd.user.agents`) has
  52. // no exact-name node and would degrade to bare-tail FTS soup — burying
  53. // the declaration hub the nix-option-path edges hang off. Resolve the
  54. // convention directly: declaration first, then the exact write, then a
  55. // capped prefix scan of write sites. Three index hits; non-nix graphs
  56. // fall straight through.
  57. if (/^[a-z][\w'-]*(?:\.[\w'-]+)+$/.test(symbol)) {
  58. const optionHits = [
  59. ...cg.getNodesByName(`options.${symbol}`),
  60. ...cg.getNodesByName(symbol),
  61. ...cg.getNodesByNamePrefix(`${symbol}.`, 12),
  62. ].filter((n) => n.language === 'nix');
  63. if (optionHits.length > 0) {
  64. const seen = new Set<string>();
  65. const nodes = optionHits.filter((n) => !seen.has(n.id) && !!seen.add(n.id)).slice(0, 10);
  66. return { nodes, note: '' };
  67. }
  68. }
  69. const isQualified = /[.\/]|::/.test(symbol);
  70. let exactNodes: Node[];
  71. if (!isQualified) {
  72. // Direct index — every exact-name overload, case-sensitive. Avoids FTS
  73. // ranking a differently-cased sibling above the real node (#1473 Fetch).
  74. exactNodes = cg.getNodesByName(symbol);
  75. } else {
  76. let results = cg.searchNodes(symbol, { limit: 50 });
  77. // Mirror findSymbolMatches — FTS strips colons, so re-search by bare tail.
  78. if (results.length === 0) {
  79. const tail = lastQualifierPart(symbol);
  80. if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
  81. }
  82. exactNodes = results
  83. .filter((r) => matchesSymbol(r.node, symbol))
  84. .map((r) => r.node);
  85. }
  86. if (exactNodes.length === 0) {
  87. const fuzzy = cg.searchNodes(symbol, { limit: 5 });
  88. const suggestions = [
  89. ...new Set(fuzzy.map((r) => r.node.name).filter((n) => n !== symbol)),
  90. ].slice(0, 3);
  91. const note =
  92. suggestions.length > 0
  93. ? `\n\n> **Note:** no symbol named "${symbol}". Did you mean: ${suggestions.join(', ')}?`
  94. : '';
  95. return { nodes: [], note };
  96. }
  97. if (exactNodes.length === 1) {
  98. return { nodes: exactNodes, note: '' };
  99. }
  100. // Same generated-file down-rank as findSymbol — keeps callers/callees
  101. // /impact aggregation aligned (a query against "Send" returns the
  102. // hand-written implementations before the protobuf scaffold).
  103. const isGen = cg.generatedFilePredicate(exactNodes.map((n) => n.filePath));
  104. const ranked = [...exactNodes].sort((a, b) => {
  105. const aGen = isGen(a.filePath) ? 1 : 0;
  106. const bGen = isGen(b.filePath) ? 1 : 0;
  107. return aGen - bGen;
  108. });
  109. const locations = ranked.map(
  110. (n) => `${n.kind} at ${n.filePath}:${n.startLine}`
  111. );
  112. const note = `\n\n> **Note:** Aggregated results across ${ranked.length} symbols named "${symbol}": ${locations.join(', ')}`;
  113. return { nodes: ranked, note };
  114. }
  115. /** Node kinds that can sit on a call chain. */
  116. export const FLOW_CALLABLE_KINDS: ReadonlySet<string> = new Set([
  117. 'method',
  118. 'function',
  119. 'component',
  120. 'constructor',
  121. 'route',
  122. ]);
  123. /**
  124. * Edge kinds a flow may ride. `navigates` is a screen transition (Expo Router
  125. * `router.push('/x')` → the route node) — a hop in the user's flow exactly as
  126. * a call is a hop in the program's.
  127. */
  128. export const FLOW_EDGE_KINDS: ReadonlySet<string> = new Set(['calls', 'navigates']);
  129. /**
  130. * Node kinds that can be an endpoint of a SYNTHESIZED edge without being
  131. * callable. An RTK thunk is `const X = createAsyncThunk(...)`, so a thunk →
  132. * thunk hop is constant → constant and the callable-only set cannot hold it.
  133. */
  134. const DYN_KINDS: ReadonlySet<string> = new Set(['constant', 'variable', 'field', 'property']);
  135. /** Only a REAL file extension is stripped from a token — `Class.method` is kept. */
  136. const FILE_EXT =
  137. /\.(?: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;
  138. /** Chain length ceiling, in NODES. Explore's Flow section has always used 7. */
  139. export const DEFAULT_MAX_HOPS = 7;
  140. /**
  141. * Longer ceiling for a directed question.
  142. *
  143. * "How does X reach Y" is asked about two symbols that a reader believes are
  144. * connected, and a real call path between a CLI entry point and a storage
  145. * primitive runs deeper than seven frames. Explore's ceiling stays where it is:
  146. * there, a longer chain is a bigger guess, because nothing pins the far end.
  147. */
  148. export const DIRECTED_MAX_HOPS = 12;
  149. /** At most one consecutive UNNAMED hop may bridge two named symbols. */
  150. export const DEFAULT_MAX_BRIDGE = 1;
  151. /** Seeds a `named` search starts from, and candidates an ambiguous token keeps. */
  152. const MAX_SEEDS = 8;
  153. const MAX_CANDIDATES_PER_TOKEN = 6;
  154. /**
  155. * Candidates a DIRECTED endpoint keeps, and the seeds it therefore walks from.
  156. *
  157. * Higher than the `named` cap, and the reason is a real failure: `main` has ten
  158. * definitions in this repository — a Python asset script, a Rust build script,
  159. * four `scripts/*.mjs` one-offs, a Go fixture — and the CLI's own `main`, the
  160. * one anybody asking "how does main reach X" means, sorts SEVENTH. A cap of six
  161. * silently answered "these two symbols are not connected". Both endpoints are
  162. * pinned here, so an extra candidate costs one bounded walk that ends the
  163. * moment it reaches the destination, and the pair that connects is the answer.
  164. */
  165. const MAX_CANDIDATES_DIRECTED = 12;
  166. const MAX_TOKENS = 16;
  167. const MAX_NAMED = 40;
  168. export interface FlowStep {
  169. node: Node;
  170. /** The edge INTO this node from the previous step; null on the first. */
  171. edge: Edge | null;
  172. }
  173. export interface FlowChain {
  174. steps: FlowStep[];
  175. /** For each node on the chain, the line where it calls the NEXT one. */
  176. callSites: Map<string, number>;
  177. }
  178. export interface NamedSymbolFlowOptions {
  179. /** `named` = explore's rules; `directed` = a pinned from → to question. */
  180. mode?: 'named' | 'directed';
  181. /** Required in `directed` mode: the token the path must start at. */
  182. from?: string;
  183. /** Required in `directed` mode: the token the path must end at. */
  184. to?: string;
  185. maxHops?: number;
  186. /** Consecutive unnamed hops allowed. `Infinity` in directed mode. */
  187. maxBridge?: number;
  188. /** Distinct chains to return. Explore only ever looks at the first. */
  189. maxChains?: number;
  190. }
  191. export interface NamedSymbolFlow {
  192. /** The query's symbol tokens, in the order they were written. */
  193. tokens: string[];
  194. /** Every CALLABLE the tokens resolved to, by node id. */
  195. named: Map<string, Node>;
  196. /** Non-callable endpoints of synthesized edges (RTK thunks and friends). */
  197. dynNamed: Map<string, Node>;
  198. /** token → the node ids it resolved to. */
  199. tokenNodes: Map<string, string[]>;
  200. /** token → its whole same-name callable family, before the container filter. */
  201. tokenFamily: Map<string, Node[]>;
  202. /** Ids whose token was a (near-)unique callable name — at most 3 defs. */
  203. uniqueNamedNodeIds: Set<string>;
  204. /** Ids resolved from a shape-precise token (camelCase, dotted, PascalCase…). */
  205. preciseNamedIds: Set<string>;
  206. /** Chains found, best first. Empty when nothing connects. */
  207. chains: FlowChain[];
  208. }
  209. const EMPTY_FLOW = (): NamedSymbolFlow => ({
  210. tokens: [],
  211. named: new Map(),
  212. dynNamed: new Map(),
  213. tokenNodes: new Map(),
  214. tokenFamily: new Map(),
  215. uniqueNamedNodeIds: new Set(),
  216. preciseNamedIds: new Set(),
  217. chains: [],
  218. });
  219. /**
  220. * Production code before test and fixture code, otherwise the order the index
  221. * ranked them in.
  222. *
  223. * Only used for a directed question, where the candidates are the two ends of
  224. * "how does X reach Y" and a fixture's `main` is never what was meant. In
  225. * `named` mode the agent's own co-naming does this job and re-ranking would
  226. * change what `codegraph_explore` answers.
  227. */
  228. function rankForDirected(nodes: readonly Node[]): Node[] {
  229. return [...nodes].sort(
  230. (a, b) => (isTestFile(a.filePath) ? 1 : 0) - (isTestFile(b.filePath) ? 1 : 0)
  231. );
  232. }
  233. /**
  234. * A token is shape-precise when it looks like a symbol reference rather than an
  235. * English word that happened to exact-match a callable.
  236. */
  237. function isPreciseToken(token: string): boolean {
  238. return /[._$]|::|\//.test(token) || /[a-z][A-Z]/.test(token) || /^[A-Z]/.test(token);
  239. }
  240. /** The symbol-shaped tokens of a query, deduped and capped. */
  241. export function flowTokens(query: string): string[] {
  242. return [
  243. ...new Set(
  244. query
  245. .split(/[\s,()[\]]+/)
  246. .map((t) => t.replace(FILE_EXT, '').trim())
  247. .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t))
  248. ),
  249. ].slice(0, MAX_TOKENS);
  250. }
  251. /**
  252. * Resolve a query's tokens to nodes, with the overload rules described in the
  253. * module header. No graph traversal happens here.
  254. */
  255. export function resolveNamedTokens(
  256. cg: CodeGraph,
  257. query: string,
  258. opts: NamedSymbolFlowOptions = {}
  259. ): NamedSymbolFlow {
  260. const directed = opts.mode === 'directed';
  261. const out = EMPTY_FLOW();
  262. const tokens = flowTokens(query);
  263. out.tokens = tokens;
  264. if (tokens.length < 2) return out;
  265. // Pool of name SEGMENTS (Class + method from every token), used to keep an
  266. // ambiguous simple name only where its CONTAINER class is itself named.
  267. const segPool = new Set<string>();
  268. for (const t of tokens) for (const s of t.toLowerCase().split(/::|\./)) if (s) segPool.add(s);
  269. // RAW edges, not getCallers/getCallees: those return one row per NEIGHBOUR
  270. // (the #1086 de-dup), so when a pair is joined by BOTH a static and a
  271. // synthesized edge the static one wins and the synthesized one becomes
  272. // invisible — which is exactly what happens once a thunk's `dispatch(x)`
  273. // is walked statically. The question here is about the graph, not about
  274. // callers, so ask the edges directly.
  275. const hasHeuristicEdge = (id: string): boolean =>
  276. [...cg.getIncomingEdges(id), ...cg.getOutgoingEdges(id)].some(
  277. (e) => e.provenance === 'heuristic'
  278. );
  279. for (const t of tokens) {
  280. const hits = findAllSymbols(cg, t).nodes;
  281. const cands = hits.filter((n) => FLOW_CALLABLE_KINDS.has(n.kind));
  282. out.tokenFamily.set(t, cands);
  283. // A qualified or otherwise-specific name (<=3 hits) keeps all of them.
  284. const specific = cands.length <= 3;
  285. // In directed mode every candidate is kept and the search decides: the pair
  286. // of overloads that actually connects IS the disambiguation, and co-naming
  287. // has nothing to work with when the whole query is two words.
  288. const pick =
  289. specific || directed
  290. ? cands
  291. : cands.filter((n) => {
  292. const segs = (n.qualifiedName || '').toLowerCase().split(/::|\./).filter(Boolean);
  293. const container = segs.length >= 2 ? segs[segs.length - 2] : '';
  294. return !!container && segPool.has(container);
  295. });
  296. const kept = directed
  297. ? rankForDirected(pick).slice(0, MAX_CANDIDATES_DIRECTED)
  298. : pick.slice(0, MAX_CANDIDATES_PER_TOKEN);
  299. out.tokenNodes.set(
  300. t,
  301. kept.map((n) => n.id)
  302. );
  303. const precise = isPreciseToken(t);
  304. for (const n of kept) {
  305. out.named.set(n.id, n);
  306. if (specific) out.uniqueNamedNodeIds.add(n.id);
  307. if (precise) out.preciseNamedIds.add(n.id);
  308. }
  309. // Same token, non-callable synthesized endpoints. Capped per token so one
  310. // token's many endpoints cannot fill the pool before later tokens get a slot,
  311. // and gated on an actual heuristic edge so plain constants never qualify.
  312. if (out.dynNamed.size < 12) {
  313. let tokenDyn = 0;
  314. for (const n of hits) {
  315. if (FLOW_CALLABLE_KINDS.has(n.kind) || !DYN_KINDS.has(n.kind) || out.dynNamed.has(n.id)) {
  316. continue;
  317. }
  318. if (hasHeuristicEdge(n.id)) {
  319. out.dynNamed.set(n.id, n);
  320. if (precise) out.preciseNamedIds.add(n.id);
  321. tokenDyn++;
  322. }
  323. if (out.dynNamed.size >= 12 || tokenDyn >= 4) break;
  324. }
  325. }
  326. if (out.named.size > MAX_NAMED) break;
  327. }
  328. return out;
  329. }
  330. /** Where each node on a chain calls the next one. */
  331. function callSitesOf(steps: readonly FlowStep[]): Map<string, number> {
  332. const sites = new Map<string, number>();
  333. for (let i = 0; i < steps.length - 1; i++) {
  334. const line = steps[i + 1]?.edge?.line;
  335. const id = steps[i]?.node.id;
  336. if (id && line && line > 0 && !sites.has(id)) sites.set(id, line);
  337. }
  338. return sites;
  339. }
  340. /**
  341. * Nodes one side of a search may visit before it gives up.
  342. *
  343. * The `named` cap is explore's own, unchanged: with at most one unnamed bridge
  344. * between named symbols the frontier cannot run away, so 1 500 is generous.
  345. * A directed search bridges freely and needs far more room — but it spends it
  346. * from two ends at once, so a side that blows past this has genuinely fanned
  347. * out rather than merely gone deep.
  348. */
  349. const NAMED_VISIT_CAP = 1500;
  350. const DIRECTED_VISIT_CAP = 12_000;
  351. /**
  352. * Breadth-first over `calls` edges — synthesized ones included, which is what
  353. * carries a flow across a callback, a re-render or a JSX child.
  354. *
  355. * This is the `named` walk: every named symbol is a possible destination, and
  356. * at most `maxBridge` unnamed symbols may sit between two of them. That cap is
  357. * what bounds the frontier, so {@link NAMED_VISIT_CAP} is generous.
  358. *
  359. * Returns the parent map, so a caller can reconstruct any reached node's path.
  360. */
  361. function walkCalls(
  362. cg: CodeGraph,
  363. seed: Node,
  364. named: ReadonlySet<string>,
  365. maxHops: number,
  366. maxBridge: number
  367. ): { parent: Map<string, { prev: string | null; edge: Edge | null; node: Node }>; reached: string[] } {
  368. const parent = new Map<string, { prev: string | null; edge: Edge | null; node: Node }>();
  369. parent.set(seed.id, { prev: null, edge: null, node: seed });
  370. const queue: Array<{ id: string; depth: number; streak: number }> = [
  371. { id: seed.id, depth: 0, streak: 0 },
  372. ];
  373. const reached: string[] = [];
  374. for (let head = 0; head < queue.length && parent.size < NAMED_VISIT_CAP; head++) {
  375. const { id, depth, streak } = queue[head]!;
  376. if (id !== seed.id && named.has(id)) reached.push(id);
  377. if (depth >= maxHops - 1) continue;
  378. for (const c of cg.getCallees(id)) {
  379. if (!FLOW_EDGE_KINDS.has(c.edge.kind) || parent.has(c.node.id)) continue;
  380. // A route node is a connector, not a symbol the reader would have named:
  381. // crossing one costs no bridge budget.
  382. const newStreak = named.has(c.node.id) ? 0 : c.node.kind === 'route' ? streak : streak + 1;
  383. if (newStreak > maxBridge) continue;
  384. parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
  385. queue.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
  386. }
  387. }
  388. return { parent, reached };
  389. }
  390. /**
  391. * A short call path from `seed` to any of `sinks`, searched from BOTH ends.
  392. *
  393. * A directed question bridges freely — nothing in the middle is "named" to keep
  394. * the frontier small — so a one-way walk from an entry point balloons: `main`
  395. * on this repository touches hundreds of symbols within four hops of a
  396. * twelve-hop budget. Coming in from both ends halves the depth each side has to
  397. * cover, and the destination end is nearly always the cheap one: a leaf has a
  398. * handful of callers where an entry point has an enormous fan-out.
  399. *
  400. * Measured against the one-way walk on twelve pairs from this repository's own
  401. * index: **identical paths, 3–6× faster** (`main -> resolveOne` 40 ms → 11 ms,
  402. * `main -> scanDynamicDispatch` 33 ms → 7 ms). The one-way search never
  403. * actually exhausted its visit cap here, so the reachability headroom below is
  404. * insurance for a graph much larger than this one, not a fix for a bug that was
  405. * observed.
  406. *
  407. * It alternates a level at a time, always expanding the SMALLER frontier, and
  408. * stops the moment the two sides share a node. Alternating levels this way can
  409. * return a path one hop longer than the true shortest — which is why nothing in
  410. * the payload claims to be shortest, only to be a path the graph records.
  411. */
  412. function walkBidirectional(
  413. cg: CodeGraph,
  414. seed: Node,
  415. sinks: ReadonlySet<string>,
  416. maxHops: number
  417. ): FlowStep[] | null {
  418. if (sinks.has(seed.id)) return null;
  419. const forward = new Map<string, { prev: string | null; edge: Edge | null; node: Node }>();
  420. /** id → the edge OUT of it towards the destination; null AT the destination. */
  421. const backward = new Map<string, { next: string; edge: Edge } | null>();
  422. const backNodes = new Map<string, Node>();
  423. forward.set(seed.id, { prev: null, edge: null, node: seed });
  424. let frontF: Node[] = [seed];
  425. let frontB: Node[] = [];
  426. for (const id of sinks) {
  427. const node = cg.getNode(id);
  428. if (!node) continue;
  429. backward.set(id, null);
  430. backNodes.set(id, node);
  431. frontB.push(node);
  432. }
  433. if (frontB.length === 0) return null;
  434. const meetAt = (): string | null => {
  435. // The forward side is the one that is walked in full, so scanning it is the
  436. // cheaper direction of the check.
  437. for (const id of forward.keys()) if (backward.has(id)) return id;
  438. return null;
  439. };
  440. const maxEdges = Math.max(1, maxHops - 1);
  441. for (let laid = 0; laid < maxEdges; laid++) {
  442. if (frontF.length <= frontB.length) {
  443. if (forward.size > DIRECTED_VISIT_CAP) break;
  444. const next: Node[] = [];
  445. for (const node of frontF) {
  446. for (const c of cg.getCallees(node.id)) {
  447. if (!FLOW_EDGE_KINDS.has(c.edge.kind) || forward.has(c.node.id)) continue;
  448. forward.set(c.node.id, { prev: node.id, edge: c.edge, node: c.node });
  449. next.push(c.node);
  450. }
  451. }
  452. if (next.length === 0) break;
  453. frontF = next;
  454. } else {
  455. if (backward.size > DIRECTED_VISIT_CAP) break;
  456. const next: Node[] = [];
  457. for (const node of frontB) {
  458. for (const c of cg.getCallers(node.id)) {
  459. if (!FLOW_EDGE_KINDS.has(c.edge.kind) || backward.has(c.node.id)) continue;
  460. backward.set(c.node.id, { next: node.id, edge: c.edge });
  461. backNodes.set(c.node.id, c.node);
  462. next.push(c.node);
  463. }
  464. }
  465. if (next.length === 0) break;
  466. frontB = next;
  467. }
  468. const meet = meetAt();
  469. if (meet === null) continue;
  470. // Forward half: seed → meet, walking the forward parents back.
  471. const steps: FlowStep[] = [];
  472. let cur: string | null = meet;
  473. while (cur) {
  474. const at = forward.get(cur);
  475. if (!at) break;
  476. steps.push({ node: at.node, edge: at.edge });
  477. cur = at.prev;
  478. }
  479. steps.reverse();
  480. // Backward half: meet → sink. An entry holds the edge OUT of its node, so
  481. // it is the edge INTO the step after it, which is the shape a step wants.
  482. let link = backward.get(meet);
  483. while (link) {
  484. const node = backNodes.get(link.next);
  485. if (!node) break;
  486. steps.push({ node, edge: link.edge });
  487. link = backward.get(link.next);
  488. }
  489. const last = steps[steps.length - 1];
  490. if (steps.length < 2 || !last || !sinks.has(last.node.id)) return null;
  491. return steps.length <= maxHops ? steps : null;
  492. }
  493. return null;
  494. }
  495. function chainTo(
  496. parent: Map<string, { prev: string | null; edge: Edge | null; node: Node }>,
  497. target: string
  498. ): FlowStep[] {
  499. const steps: FlowStep[] = [];
  500. let cur: string | null = target;
  501. while (cur) {
  502. const at = parent.get(cur);
  503. if (!at) break;
  504. steps.push({ node: at.node, edge: at.edge });
  505. cur = at.prev;
  506. }
  507. steps.reverse();
  508. return steps;
  509. }
  510. /**
  511. * The call path among a query's named symbols. See the module header for what
  512. * the two modes mean and why they differ.
  513. */
  514. export function resolveNamedSymbolFlow(
  515. cg: CodeGraph,
  516. query: string,
  517. opts: NamedSymbolFlowOptions = {}
  518. ): NamedSymbolFlow {
  519. try {
  520. const directed = opts.mode === 'directed';
  521. const flow = resolveNamedTokens(cg, query, opts);
  522. if (flow.named.size < 2) return flow;
  523. const maxHops = opts.maxHops ?? (directed ? DIRECTED_MAX_HOPS : DEFAULT_MAX_HOPS);
  524. const maxBridge = opts.maxBridge ?? (directed ? Number.POSITIVE_INFINITY : DEFAULT_MAX_BRIDGE);
  525. const maxChains = Math.max(1, opts.maxChains ?? 1);
  526. const namedIds = new Set(flow.named.keys());
  527. const found: FlowStep[][] = [];
  528. if (directed) {
  529. const fromIds = flow.tokenNodes.get(normalizeToken(opts.from ?? '')) ?? [];
  530. const toIds = flow.tokenNodes.get(normalizeToken(opts.to ?? '')) ?? [];
  531. if (fromIds.length === 0 || toIds.length === 0) return flow;
  532. const sinks = new Set(toIds);
  533. // Every candidate start is searched: each is a bounded two-ended walk that
  534. // ends the moment the frontiers meet, and the start that actually connects
  535. // IS the answer to which overload was meant.
  536. for (const id of fromIds) {
  537. const seed = flow.named.get(id);
  538. if (!seed) continue;
  539. const steps = walkBidirectional(cg, seed, sinks, maxHops);
  540. if (steps) found.push(steps);
  541. }
  542. } else {
  543. for (const seed of [...flow.named.values()].slice(0, MAX_SEEDS)) {
  544. const { parent, reached } = walkCalls(cg, seed, namedIds, maxHops, maxBridge);
  545. // Explore's rule: the DEEPEST named sink this seed can reach.
  546. let deepest: FlowStep[] | null = null;
  547. for (const id of reached) {
  548. const steps = chainTo(parent, id);
  549. if (!deepest || steps.length > deepest.length) deepest = steps;
  550. }
  551. if (deepest) found.push(deepest);
  552. }
  553. }
  554. if (found.length === 0) return flow;
  555. found.sort((a, b) => (directed ? a.length - b.length : b.length - a.length));
  556. // Identical chains, and chains that are just a shorter run along one
  557. // already kept, are the same answer twice: `a → b → c` and `b → c` differ
  558. // only in where the seed happened to be. Alternatives are for genuinely
  559. // different routes — a second overload, a different intermediate.
  560. const kept: string[] = [];
  561. for (const steps of found) {
  562. const key = steps.map((s) => s.node.id).join('>');
  563. if (kept.some((other) => other === key || other.includes(key))) continue;
  564. kept.push(key);
  565. flow.chains.push({ steps, callSites: callSitesOf(steps) });
  566. if (flow.chains.length >= maxChains) break;
  567. }
  568. return flow;
  569. } catch {
  570. return EMPTY_FLOW();
  571. }
  572. }
  573. /** The token spelling {@link flowTokens} would have produced for one word. */
  574. export function normalizeToken(token: string): string {
  575. return token.replace(FILE_EXT, '').trim();
  576. }