Files
codegraph/src/graph/named-symbol-flow.ts
T
aed046e5c6 fix: stop silent fuzzy symbol substitution (#1473) (#1809)
Adapt @uvmplus's PR #1481 (3ecf7479) to the shared symbol lookup and
named-symbol flow resolver on current main. Missing names return not found
with suggestions, and exact matches with no callers stay empty.

Preserve #1512 definition grouping and --file narrowing, #173 qualified
misses, and codegraph_node's intentional fuzzy file lookup. Port the
upstream regression suite and cover the moved shared lookup paths.

Validation on Linux with Node 22: project build, 74 requested tests,
47 related flow tests, and 16 same-fixture CLI/MCP checks pass. Baseline
captured 12 failing tests and 13 failing fixture checks.

Fixes #1473.
Supersedes #1481.

Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
2026-09-08 19:40:48 -05:00

627 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, 36× 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();
}