feat(ui): where the graph stops — the Flow strip's dynamic-dispatch end cap (CG-51)
A flow that does not reach what it was asked about now ends in a cap instead of in silence: the dispatch form that ended it, the line, the static key when the source spells one out, the candidate runtime targets as clickable rows, and the name-only matches under 0.6 the search refused to follow. A flow that does reach its destination never shows one. The verdict is lifted out of `ToolHandler` into `src/graph/dynamic-boundary-report.ts` and both callers render it — `codegraph_explore`'s prose and `/api/flow`'s `WireFlowBoundary` — the same move `named-symbol-flow.ts` made for the path finder, and for the same reason: a reader holding the strip and the MCP answer must not be told two different things. The explore prose is unchanged, byte for byte. When nothing connects at all and a dispatch site explains why, the strip is that site: one card opened at the line where the static path ends, plus the cap. When nothing explains it, no stopping point is invented.
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Where the graph stops — the boundary report, as data.
|
||||
*
|
||||
* When a flow does not connect, the honest answer is not "no path": it is the
|
||||
* dispatch site where the static path ends. `src/mcp/dynamic-boundaries.ts`
|
||||
* finds those sites in a body with deterministic regex; this module is the
|
||||
* graph-aware layer on top of it — it reads the bodies off disk, shortlists the
|
||||
* candidate runtime targets for a statically-visible dispatch key, and collects
|
||||
* the continuations out of the stopping symbol that the search did not follow.
|
||||
*
|
||||
* It exists for the same reason `named-symbol-flow.ts` does. `codegraph_explore`
|
||||
* announces boundaries in prose ("**Dynamic boundaries** … candidates for key
|
||||
* `save`: …") and the viewer's Flow strip draws the same verdict as an end cap
|
||||
* (design spec §3.5). Two derivations of "where does this stop" would eventually
|
||||
* disagree, and a reader who had both on screen would have no way to tell which
|
||||
* one was lying. So the *verdict* lives here once, and each caller renders it:
|
||||
* `ToolHandler.buildDynamicBoundaries` turns it into markdown, `/api/flow` turns
|
||||
* it into `WireFlowBoundary`.
|
||||
*
|
||||
* Everything here is query-time and read-only. The graph is never mutated, no
|
||||
* edge is ever guessed, and a fully connected flow never reaches this module —
|
||||
* silence beats a wrong edge (#687).
|
||||
*/
|
||||
|
||||
import type CodeGraph from '../index';
|
||||
import type { Edge, Node } from '../types';
|
||||
import { scanDynamicDispatch, type BoundaryMatch } from '../mcp/dynamic-boundaries';
|
||||
import { validatePathWithinRoot } from '../utils';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
|
||||
/** Below this resolution confidence an edge is a name-only guess, not a call. */
|
||||
export const UNCERTAIN_BELOW = 0.6;
|
||||
|
||||
/** Dispatch sites reported across one scan. Matches explore's bullet budget. */
|
||||
export const MAX_BOUNDARY_SITES = 4;
|
||||
|
||||
/** Bodies read off disk per scan, however many symbols were handed in. */
|
||||
const MAX_SCAN = 8;
|
||||
|
||||
/** Total body characters read per scan — a god-function tail must not stall a request. */
|
||||
const MAX_TOTAL_CHARS = 200_000;
|
||||
|
||||
/** Candidate runtime targets shortlisted for one dispatch key. */
|
||||
const MAX_CANDIDATES = 4;
|
||||
|
||||
/** FTS rows inspected while shortlisting; also the "too generic" threshold. */
|
||||
const CANDIDATE_SEARCH_LIMIT = 12;
|
||||
|
||||
/** Kinds that can be the runtime target of a dispatch. */
|
||||
const CALLABLE_KINDS = new Set(['method', 'function', 'component', 'constructor', 'class']);
|
||||
|
||||
/**
|
||||
* A conventional handler method on a typed-bus target class — MediatR's
|
||||
* `Handle`, a consumer's `Consume`, PHP's `__invoke`.
|
||||
*/
|
||||
const HANDLER_METHODS = /^(handle|handleAsync|execute|executeAsync|consume|consumeAsync|run|__invoke)$/i;
|
||||
|
||||
// =============================================================================
|
||||
// Shapes
|
||||
// =============================================================================
|
||||
|
||||
/** One plausible runtime target of a keyed dispatch. */
|
||||
export interface BoundaryCandidate {
|
||||
node: Node;
|
||||
/**
|
||||
* How the candidate should be named. Usually `qualifiedName`, but a typed-bus
|
||||
* key resolves to a CLASS whose real target is its handler method, so the
|
||||
* display names that method (`CreateTodoCommandHandler.Handle`) and `node` is
|
||||
* the method too — a row the reader clicks must open what it claims.
|
||||
*/
|
||||
display: string;
|
||||
/** The reader already named this symbol: "you were right, here's the wiring". */
|
||||
named: boolean;
|
||||
}
|
||||
|
||||
/** A dispatch site: the detector's verdict plus what the graph knows about it. */
|
||||
export interface BoundarySite extends BoundaryMatch {
|
||||
/** Runtime targets for {@link BoundaryMatch.key}. Empty when the key is a runtime value. */
|
||||
candidates: BoundaryCandidate[];
|
||||
/**
|
||||
* Why there is no shortlist, when a key was visible but nothing could be
|
||||
* narrowed down: "key `id` is too generic to shortlist (12+ matches)".
|
||||
*/
|
||||
candidateNote: string | null;
|
||||
}
|
||||
|
||||
/** Every dispatch site found in one symbol's body. */
|
||||
export interface NodeBoundary {
|
||||
node: Node;
|
||||
sites: BoundarySite[];
|
||||
}
|
||||
|
||||
/** One call out of the stopping symbol, and how sure the resolver was of it. */
|
||||
export interface BoundaryContinuation {
|
||||
node: Node;
|
||||
line: number | null;
|
||||
confidence: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The calls recorded out of a symbol, split by whether the resolver believed
|
||||
* them. `uncertain` is the part a flow search deliberately does not follow.
|
||||
*/
|
||||
export interface BoundaryContinuations {
|
||||
resolved: BoundaryContinuation[];
|
||||
uncertain: BoundaryContinuation[];
|
||||
}
|
||||
|
||||
export interface BoundaryScanOptions {
|
||||
/** Dispatch sites returned in total. Default {@link MAX_BOUNDARY_SITES}. */
|
||||
maxSites?: number;
|
||||
/** Symbols the reader named — candidates matching one are marked and sort first. */
|
||||
named?: ReadonlyMap<string, Node>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// The scan
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Scan the given symbols' bodies for dynamic-dispatch sites, in order.
|
||||
*
|
||||
* `scanList` is a priority order, not a set: the caller puts the place the flow
|
||||
* actually stopped first (the chain's dead end), then the symbols that were
|
||||
* asked for and never reached. Scanning stops at the first of three budgets —
|
||||
* sites found, bodies read, characters read — so a question about a god
|
||||
* function costs the same as any other.
|
||||
*
|
||||
* Returns one entry per symbol that yielded at least one site; a symbol with a
|
||||
* clean body is simply absent, because "nothing dynamic here" is not a finding.
|
||||
*/
|
||||
export function findDynamicBoundaries(
|
||||
cg: CodeGraph,
|
||||
scanList: readonly Node[],
|
||||
opts: BoundaryScanOptions = {}
|
||||
): NodeBoundary[] {
|
||||
const maxSites = opts.maxSites ?? MAX_BOUNDARY_SITES;
|
||||
const named = opts.named ?? new Map<string, Node>();
|
||||
let projectRoot: string;
|
||||
try {
|
||||
projectRoot = cg.getProjectRoot();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const out: NodeBoundary[] = [];
|
||||
const seenNode = new Set<string>();
|
||||
const seenSite = new Set<string>();
|
||||
let sites = 0;
|
||||
let scanned = 0;
|
||||
let charsScanned = 0;
|
||||
|
||||
for (const node of scanList) {
|
||||
if (sites >= maxSites || scanned >= MAX_SCAN || charsScanned > MAX_TOTAL_CHARS) break;
|
||||
if (seenNode.has(node.id) || !node.startLine || !node.endLine) continue;
|
||||
seenNode.add(node.id);
|
||||
const absPath = validatePathWithinRoot(projectRoot, node.filePath);
|
||||
if (!absPath || !existsSync(absPath)) continue;
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(absPath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const body = content.split('\n').slice(node.startLine - 1, node.endLine).join('\n');
|
||||
scanned++;
|
||||
charsScanned += body.length;
|
||||
|
||||
const found: BoundarySite[] = [];
|
||||
for (const match of scanDynamicDispatch(body, node.language || '', node.startLine)) {
|
||||
if (sites >= maxSites) break;
|
||||
const siteKey = `${node.filePath}:${match.line}:${match.form}`;
|
||||
if (seenSite.has(siteKey)) continue;
|
||||
seenSite.add(siteKey);
|
||||
const shortlist = match.key
|
||||
? shortlistBoundaryCandidates(cg, match.key, !!match.keyIsType, named, node.id)
|
||||
: { candidates: [], note: null };
|
||||
found.push({ ...match, candidates: shortlist.candidates, candidateNote: shortlist.note });
|
||||
sites++;
|
||||
}
|
||||
if (found.length > 0) out.push({ node, sites: found });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Candidates
|
||||
// =============================================================================
|
||||
|
||||
const normalizeName = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
|
||||
/**
|
||||
* Shortlist the runtime targets a dispatch key could reach.
|
||||
*
|
||||
* Exact conventional names first (`save` → `onSave` / `handleSave`;
|
||||
* `CreateCmd` → `CreateCmdHandler`), then FTS, with a normalized-containment
|
||||
* post-filter — FTS camel-splitting is fuzzier than a candidate list should be,
|
||||
* and a shortlist that is mostly wrong is worse than none. Symbols the caller
|
||||
* already named sort first and are marked.
|
||||
*
|
||||
* A key too short or too common to narrow down returns no candidates and a
|
||||
* `note` saying so, rather than four arbitrary rows.
|
||||
*/
|
||||
export function shortlistBoundaryCandidates(
|
||||
cg: CodeGraph,
|
||||
key: string,
|
||||
keyIsType: boolean,
|
||||
named: ReadonlyMap<string, Node>,
|
||||
selfId: string
|
||||
): { candidates: BoundaryCandidate[]; note: string | null } {
|
||||
const keyNorm = normalizeName(key);
|
||||
if (keyNorm.length < 3) return { candidates: [], note: null };
|
||||
|
||||
const cands = new Map<string, Node>();
|
||||
const consider = (n: Node | undefined | null): void => {
|
||||
if (!n || n.id === selfId || !CALLABLE_KINDS.has(n.kind) || cands.has(n.id)) return;
|
||||
const nameNorm = normalizeName(n.name || '');
|
||||
if (nameNorm.length < 3) return;
|
||||
if (!nameNorm.includes(keyNorm) && !keyNorm.includes(nameNorm)) return;
|
||||
cands.set(n.id, n);
|
||||
};
|
||||
|
||||
const cap = key.charAt(0).toUpperCase() + key.slice(1);
|
||||
const probes = keyIsType
|
||||
? [`${key}Handler`, key]
|
||||
: [key, `on${cap}`, `handle${cap}`, `${key}Handler`, `handle_${key}`];
|
||||
for (const probe of probes) {
|
||||
try {
|
||||
for (const n of cg.getNodesByName(probe)) consider(n);
|
||||
} catch {
|
||||
/* an exact probe that misses is the normal case */
|
||||
}
|
||||
}
|
||||
|
||||
let raw = 0;
|
||||
try {
|
||||
const results = cg.searchNodes(key, { limit: CANDIDATE_SEARCH_LIMIT });
|
||||
raw = results.length;
|
||||
for (const r of results) consider(r.node);
|
||||
} catch {
|
||||
/* FTS syntax edge — the exact probes already ran */
|
||||
}
|
||||
|
||||
if (cands.size === 0) {
|
||||
const generic = raw >= CANDIDATE_SEARCH_LIMIT && key.length < 5;
|
||||
return {
|
||||
candidates: [],
|
||||
note: generic ? `key \`${key}\` is too generic to shortlist (${raw}+ matches)` : null,
|
||||
};
|
||||
}
|
||||
|
||||
// A constructor candidate duplicates its class: extractors emit constructors
|
||||
// as METHOD nodes named like the class (C#/Java `Foo::Foo`) — keep the class.
|
||||
const all = [...cands.values()];
|
||||
const classKey = new Set(
|
||||
all.filter((n) => n.kind === 'class').map((n) => `${n.name}|${n.filePath}`)
|
||||
);
|
||||
// The flow's named set holds callables only, so a class whose METHOD the
|
||||
// reader named still counts as named — transfer the mark by name.
|
||||
const namedNames = new Set([...named.values()].map((n) => n.name));
|
||||
const isNamed = (n: Node): boolean => named.has(n.id) || namedNames.has(n.name);
|
||||
|
||||
const candidates = all
|
||||
.filter((n) => !(n.kind !== 'class' && classKey.has(`${n.name}|${n.filePath}`)))
|
||||
.sort((a, b) => (isNamed(b) ? 1 : 0) - (isNamed(a) ? 1 : 0))
|
||||
.slice(0, MAX_CANDIDATES)
|
||||
.map((n): BoundaryCandidate => {
|
||||
// Typed-bus convention: the runtime target is the candidate class's
|
||||
// Handle/Execute/Consume method — name the exact node, not just the class.
|
||||
if (keyIsType && n.kind === 'class') {
|
||||
const method = handlerMethodOf(cg, n);
|
||||
if (method) {
|
||||
return { node: method, display: `${n.name}.${method.name}`, named: isNamed(n) };
|
||||
}
|
||||
}
|
||||
return { node: n, display: n.qualifiedName || n.name, named: isNamed(n) };
|
||||
});
|
||||
|
||||
return { candidates, note: null };
|
||||
}
|
||||
|
||||
function handlerMethodOf(cg: CodeGraph, cls: Node): Node | null {
|
||||
try {
|
||||
return (
|
||||
cg
|
||||
.getOutgoingEdges(cls.id)
|
||||
.filter((e) => e.kind === 'contains')
|
||||
.map((e) => {
|
||||
try {
|
||||
return cg.getNode(e.target);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.find((c): c is Node => !!c && c.kind === 'method' && HANDLER_METHODS.test(c.name)) ?? null
|
||||
);
|
||||
} catch {
|
||||
return null; // a class whose members do not resolve — show the class itself
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Continuations
|
||||
// =============================================================================
|
||||
|
||||
const CONTINUATION_KINDS = new Set(['calls', 'instantiates']);
|
||||
|
||||
/**
|
||||
* The calls recorded out of a symbol, minus the ones already on the path.
|
||||
*
|
||||
* This is the other half of an honest end cap. A flow that stops somewhere has
|
||||
* two kinds of unexplored exit: calls the resolver was sure of and the path
|
||||
* simply did not need, and name-only matches under {@link UNCERTAIN_BELOW} that
|
||||
* the search deliberately refused to follow. Listing the second kind is the
|
||||
* point — an unfollowed guess that stays invisible reads as "there is nothing
|
||||
* here", which is the one thing it does not mean.
|
||||
*
|
||||
* Deduped by target, keeping the first line each was recorded at.
|
||||
*/
|
||||
export function continuationsFrom(
|
||||
cg: CodeGraph,
|
||||
node: Node,
|
||||
exclude: ReadonlySet<string> = new Set()
|
||||
): BoundaryContinuations {
|
||||
const resolved = new Map<string, BoundaryContinuation>();
|
||||
const uncertain = new Map<string, BoundaryContinuation>();
|
||||
let edges: Edge[];
|
||||
try {
|
||||
edges = cg.getOutgoingEdges(node.id);
|
||||
} catch {
|
||||
return { resolved: [], uncertain: [] };
|
||||
}
|
||||
for (const edge of edges) {
|
||||
if (!CONTINUATION_KINDS.has(edge.kind)) continue;
|
||||
if (edge.target === node.id || exclude.has(edge.target)) continue;
|
||||
const meta = (edge.metadata ?? {}) as Record<string, unknown>;
|
||||
const confidence = typeof meta.confidence === 'number' ? meta.confidence : null;
|
||||
const bucket = confidence !== null && confidence < UNCERTAIN_BELOW ? uncertain : resolved;
|
||||
if (bucket.has(edge.target)) continue;
|
||||
let target: Node | null;
|
||||
try {
|
||||
target = cg.getNode(edge.target);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!target) continue;
|
||||
bucket.set(edge.target, {
|
||||
node: target,
|
||||
line: typeof edge.line === 'number' ? edge.line : null,
|
||||
confidence,
|
||||
});
|
||||
}
|
||||
const byLine = (a: BoundaryContinuation, b: BoundaryContinuation): number =>
|
||||
(a.line ?? 0) - (b.line ?? 0);
|
||||
return {
|
||||
resolved: [...resolved.values()].sort(byLine),
|
||||
uncertain: [...uncertain.values()].sort(byLine),
|
||||
};
|
||||
}
|
||||
+28
-93
@@ -40,7 +40,7 @@ import {
|
||||
} from 'fs';
|
||||
import { createHash } from 'crypto';
|
||||
import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils';
|
||||
import { scanDynamicDispatch } from './dynamic-boundaries';
|
||||
import { findDynamicBoundaries, type BoundarySite } from '../graph/dynamic-boundary-report';
|
||||
import {
|
||||
lastQualifierPart,
|
||||
matchesSymbol,
|
||||
@@ -2743,37 +2743,22 @@ export class ToolHandler {
|
||||
* connected flow never reaches this method.
|
||||
*/
|
||||
private buildDynamicBoundaries(cg: CodeGraph, scanList: Node[], named: Map<string, Node>): string {
|
||||
const MAX_NOTES = 4; // boundary bullets per explore
|
||||
const MAX_SCAN = 8; // bodies scanned
|
||||
const MAX_TOTAL_CHARS = 200_000;
|
||||
let projectRoot: string;
|
||||
try { projectRoot = cg.getProjectRoot(); } catch { return ''; }
|
||||
const MAX_NOTES = 4; // boundary bullets per explore
|
||||
// The verdict is not derived here — `findDynamicBoundaries` produces it and
|
||||
// the viewer's end cap renders the same object, so the two can never
|
||||
// disagree about where a flow stops. What is left here is the prose.
|
||||
const reports = findDynamicBoundaries(cg, scanList, { named, maxSites: MAX_NOTES });
|
||||
const notes: string[] = [];
|
||||
const seenNode = new Set<string>();
|
||||
const seenSite = new Set<string>();
|
||||
let scanned = 0, charsScanned = 0;
|
||||
for (const node of scanList) {
|
||||
if (notes.length >= MAX_NOTES || scanned >= MAX_SCAN || charsScanned > MAX_TOTAL_CHARS) break;
|
||||
if (seenNode.has(node.id) || !node.startLine || !node.endLine) continue;
|
||||
seenNode.add(node.id);
|
||||
const absPath = validatePathWithinRoot(projectRoot, node.filePath);
|
||||
if (!absPath || !existsSync(absPath)) continue;
|
||||
let content: string;
|
||||
try { content = readFileSync(absPath, 'utf-8'); } catch { continue; }
|
||||
const body = content.split('\n').slice(node.startLine - 1, node.endLine).join('\n');
|
||||
scanned++;
|
||||
charsScanned += body.length;
|
||||
for (const m of scanDynamicDispatch(body, node.language || '', node.startLine)) {
|
||||
for (const report of reports) {
|
||||
if (notes.length >= MAX_NOTES) break;
|
||||
for (const site of report.sites) {
|
||||
if (notes.length >= MAX_NOTES) break;
|
||||
const siteKey = `${node.filePath}:${m.line}:${m.form}`;
|
||||
if (seenSite.has(siteKey)) continue;
|
||||
seenSite.add(siteKey);
|
||||
const more = m.moreSites ? ` (+${m.moreSites} more such site${m.moreSites > 1 ? 's' : ''} in this body)` : '';
|
||||
notes.push(`- \`${node.name}\` (${node.filePath}:${m.line}) — ${m.label}: \`${m.snippet}\`${more}`);
|
||||
if (m.key) {
|
||||
const cand = this.boundaryCandidates(cg, m.key, !!m.keyIsType, named, node.id);
|
||||
if (cand) notes.push(` ${cand}`);
|
||||
}
|
||||
const more = site.moreSites
|
||||
? ` (+${site.moreSites} more such site${site.moreSites > 1 ? 's' : ''} in this body)`
|
||||
: '';
|
||||
notes.push(`- \`${report.node.name}\` (${report.node.filePath}:${site.line}) — ${site.label}: \`${site.snippet}\`${more}`);
|
||||
const cand = this.boundaryCandidates(site);
|
||||
if (cand) notes.push(` ${cand}`);
|
||||
}
|
||||
}
|
||||
if (notes.length === 0) return '';
|
||||
@@ -2875,70 +2860,20 @@ export class ToolHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortlist candidate runtime targets for a dispatch key surfaced by
|
||||
* {@link buildDynamicBoundaries}. Exact conventional names first (`save` →
|
||||
* `onSave`/`handleSave`; `CreateCmd` → `CreateCmdHandler`), then FTS, with a
|
||||
* normalized-containment post-filter (FTS camel-splitting is fuzzier than a
|
||||
* candidate list should be). Symbols the agent already named sort first and
|
||||
* are marked — that's the "you were right, here's the wiring" case.
|
||||
* Render the candidate shortlist for a dispatch site as one line.
|
||||
*
|
||||
* The shortlist itself is `shortlistBoundaryCandidates` in
|
||||
* `../graph/dynamic-boundary-report` — shared with the viewer's end cap, so
|
||||
* "candidates for key `save`" names the same symbols in both places. Symbols
|
||||
* the agent already named are marked: that is the "you were right, here's the
|
||||
* wiring" case.
|
||||
*/
|
||||
private boundaryCandidates(cg: CodeGraph, key: string, keyIsType: boolean, named: Map<string, Node>, selfId: string): string {
|
||||
const CALLABLE = new Set(['method', 'function', 'component', 'constructor', 'class']);
|
||||
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const keyNorm = norm(key);
|
||||
if (keyNorm.length < 3) return '';
|
||||
const cands = new Map<string, Node>();
|
||||
const consider = (n: Node | undefined | null) => {
|
||||
if (!n || n.id === selfId || !CALLABLE.has(n.kind) || cands.has(n.id)) return;
|
||||
const nameNorm = norm(n.name || '');
|
||||
if (nameNorm.length < 3) return;
|
||||
if (!nameNorm.includes(keyNorm) && !keyNorm.includes(nameNorm)) return;
|
||||
cands.set(n.id, n);
|
||||
};
|
||||
const cap = key.charAt(0).toUpperCase() + key.slice(1);
|
||||
const probes = keyIsType
|
||||
? [`${key}Handler`, key]
|
||||
: [key, `on${cap}`, `handle${cap}`, `${key}Handler`, `handle_${key}`];
|
||||
for (const p of probes) {
|
||||
try { for (const n of cg.getNodesByName(p)) consider(n); } catch { /* exact probe miss is fine */ }
|
||||
}
|
||||
let raw = 0;
|
||||
try {
|
||||
const results = cg.searchNodes(key, { limit: 12 });
|
||||
raw = results.length;
|
||||
for (const r of results) consider(r.node);
|
||||
} catch { /* FTS syntax edge — exact probes already ran */ }
|
||||
if (cands.size === 0) {
|
||||
return raw >= 12 && key.length < 5 ? `key \`${key}\` is too generic to shortlist (${raw}+ matches)` : '';
|
||||
}
|
||||
// A constructor candidate duplicates its class: extractors emit ctors as
|
||||
// METHOD nodes named like the class (C#/Java `Foo::Foo`) — keep the class.
|
||||
const all = [...cands.values()];
|
||||
const classKey = new Set(all.filter((n) => n.kind === 'class').map((n) => `${n.name}|${n.filePath}`));
|
||||
const namedNames = new Set([...named.values()].map((n) => n.name));
|
||||
const isNamed = (n: Node) => named.has(n.id) || namedNames.has(n.name); // the flow's named set holds callables only — transfer the mark to the class
|
||||
const list = all
|
||||
.filter((n) => !(n.kind !== 'class' && classKey.has(`${n.name}|${n.filePath}`)))
|
||||
.sort((a, b) => (isNamed(b) ? 1 : 0) - (isNamed(a) ? 1 : 0))
|
||||
.slice(0, 4)
|
||||
.map((n) => {
|
||||
// Typed-bus convention: the runtime target is the candidate class's
|
||||
// Handle/Execute/Consume method — name the exact node, not just the class.
|
||||
let display = n.qualifiedName || n.name;
|
||||
let at = `${n.filePath}:${n.startLine}`;
|
||||
if (keyIsType && n.kind === 'class') {
|
||||
try {
|
||||
const HANDLER_METHODS = /^(handle|handleAsync|execute|executeAsync|consume|consumeAsync|run|__invoke)$/i;
|
||||
const method = cg.getOutgoingEdges(n.id)
|
||||
.filter((e) => e.kind === 'contains')
|
||||
.map((e) => { try { return cg.getNode(e.target); } catch { return null; } })
|
||||
.find((c): c is Node => !!c && c.kind === 'method' && HANDLER_METHODS.test(c.name));
|
||||
if (method) { display = `${n.name}.${method.name}`; at = `${method.filePath}:${method.startLine}`; }
|
||||
} catch { /* class without resolvable members — show the class itself */ }
|
||||
}
|
||||
return `\`${display}\` (${at})${isNamed(n) ? ' ← you named this' : ''}`;
|
||||
});
|
||||
return `candidates for key \`${key}\`: ${list.join(', ')}`;
|
||||
private boundaryCandidates(site: BoundarySite): string {
|
||||
if (site.candidates.length === 0) return site.candidateNote ?? '';
|
||||
const list = site.candidates.map((c) =>
|
||||
`\`${c.display}\` (${c.node.filePath}:${c.node.startLine})${c.named ? ' ← you named this' : ''}`
|
||||
);
|
||||
return `candidates for key \`${site.key}\`: ${list.join(', ')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+273
-6
@@ -40,11 +40,25 @@ import {
|
||||
normalizeToken,
|
||||
DIRECTED_MAX_HOPS,
|
||||
} from '../../graph/named-symbol-flow';
|
||||
import {
|
||||
continuationsFrom,
|
||||
findDynamicBoundaries,
|
||||
type BoundaryContinuation,
|
||||
type NodeBoundary,
|
||||
} from '../../graph/dynamic-boundary-report';
|
||||
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 {
|
||||
toNodeRef,
|
||||
toWireEdge,
|
||||
wireList,
|
||||
UNCERTAIN_BELOW,
|
||||
type WireEdge,
|
||||
type WireList,
|
||||
type WireNodeRef,
|
||||
} from './wire';
|
||||
import * as fs from 'fs';
|
||||
|
||||
/** Lines shown either side of the call site on a card (design spec §3.5). */
|
||||
@@ -127,12 +141,80 @@ export interface WireFlowHop {
|
||||
source: WireFlowSource | null;
|
||||
}
|
||||
|
||||
/** One plausible runtime target of a keyed dispatch — a clickable cap row. */
|
||||
export interface WireBoundaryCandidate {
|
||||
node: WireNodeRef;
|
||||
/** How to name it: usually the qualified name, or `Class.handlerMethod`. */
|
||||
display: string;
|
||||
/** The question already named this symbol — "you were right, here's the wiring". */
|
||||
named: boolean;
|
||||
}
|
||||
|
||||
/** A dynamic-dispatch site: the form, the key when it is visible, the targets. */
|
||||
export interface WireBoundarySite {
|
||||
/** Stable form id, e.g. `computed-call`. */
|
||||
form: string;
|
||||
/** What to call it on screen: "computed member call", "getattr dispatch". */
|
||||
label: string;
|
||||
/** The source line of the site, trimmed. */
|
||||
snippet: string;
|
||||
line: number;
|
||||
/** The statically visible key (`handlers['save']` → `save`), or null. */
|
||||
key: string | null;
|
||||
/** The key is a TYPE name, so the target is `<Type>Handler` by convention. */
|
||||
keyIsType: boolean;
|
||||
/** Further sites of the same form and key in this body. */
|
||||
moreSites: number;
|
||||
candidates: WireBoundaryCandidate[];
|
||||
/** Why there is no shortlist, when a key was visible but too generic. */
|
||||
candidateNote: string | null;
|
||||
}
|
||||
|
||||
/** A call out of the stopping symbol, and how sure the resolver was. */
|
||||
export interface WireFlowContinuation {
|
||||
node: WireNodeRef;
|
||||
line: number | null;
|
||||
confidence: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the graph stops (design spec §3.5).
|
||||
*
|
||||
* Attached to a flow that does not reach everything the question named. It is
|
||||
* the same verdict `codegraph_explore` announces in prose — both render
|
||||
* `findDynamicBoundaries` — so the strip's end cap and the MCP answer can never
|
||||
* disagree about where a path ends or what could continue it.
|
||||
*/
|
||||
export interface WireFlowBoundary {
|
||||
/** The last symbol the static path reached. The cap hangs off this card. */
|
||||
node: WireNodeRef;
|
||||
/** Dispatch sites in that symbol's body. Empty when none was detected. */
|
||||
sites: WireBoundarySite[];
|
||||
/** Name-only matches under 0.6 the search did NOT follow. */
|
||||
uncertain: WireList<WireFlowContinuation>;
|
||||
/** Calls the resolver was sure of that this path does not need. */
|
||||
further: WireList<WireFlowContinuation>;
|
||||
/** Symbols the question named that this path never reaches. */
|
||||
missed: WireNodeRef[];
|
||||
}
|
||||
|
||||
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[];
|
||||
/**
|
||||
* The end cap, when this path stops short of the question. Null on a flow
|
||||
* that reaches everything it was asked about — a connected answer has no
|
||||
* boundary to announce, and saying otherwise would be noise.
|
||||
*/
|
||||
boundary: WireFlowBoundary | null;
|
||||
/**
|
||||
* This strip is not an answer to the question, it is where the answer ran
|
||||
* out: one card at the dispatch site rather than a path.
|
||||
*/
|
||||
partial: boolean;
|
||||
}
|
||||
|
||||
/** An endpoint that named more than one definition, and which one was taken. */
|
||||
@@ -371,13 +453,19 @@ interface RawHop {
|
||||
node: Node;
|
||||
edge: Edge | null;
|
||||
upward: boolean;
|
||||
/**
|
||||
* Open the card here instead of at the call site or the definition. Set on a
|
||||
* boundary-only strip, whose single card exists to show the dispatch line.
|
||||
*/
|
||||
anchor?: number;
|
||||
}
|
||||
|
||||
async function toWireFlow(
|
||||
cg: CodeGraph,
|
||||
projectRoot: string,
|
||||
cache: Map<string, FileCache>,
|
||||
raw: readonly RawHop[]
|
||||
raw: readonly RawHop[],
|
||||
extra: { boundary?: WireFlowBoundary | null; partial?: boolean } = {}
|
||||
): Promise<WireFlow> {
|
||||
const hops: WireFlowHop[] = [];
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
@@ -415,7 +503,7 @@ async function toWireFlow(
|
||||
projectRoot,
|
||||
cache,
|
||||
step.node,
|
||||
callRef?.line ?? step.node.startLine
|
||||
step.anchor ?? callRef?.line ?? step.node.startLine
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -423,8 +511,75 @@ async function toWireFlow(
|
||||
const last = raw[raw.length - 1]?.node.name ?? '?';
|
||||
return {
|
||||
id: raw.map((h) => h.node.id).join('>'),
|
||||
label: `${first} → ${last}`,
|
||||
label: extra.partial ? `${first} → stops here` : `${first} → ${last}`,
|
||||
hops,
|
||||
boundary: extra.boundary ?? null,
|
||||
partial: extra.partial === true,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Where the graph stops
|
||||
// =============================================================================
|
||||
|
||||
/** Continuations listed in an end cap before it just counts the rest. */
|
||||
const MAX_CONTINUATIONS = 6;
|
||||
|
||||
/** Symbols named and never reached, listed in an end cap. */
|
||||
const MAX_MISSED = 4;
|
||||
|
||||
/** Dispatch sites reported per strip. One cap is a card, not a report. */
|
||||
const MAX_SITES_PER_FLOW = 3;
|
||||
|
||||
function toContinuation(c: BoundaryContinuation): WireFlowContinuation {
|
||||
return { node: toNodeRef(c.node), line: c.line, confidence: c.confidence };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the end cap for a path that stopped short.
|
||||
*
|
||||
* `reports` comes from the shared detector, so the form, the key and the
|
||||
* candidate targets are the ones `codegraph_explore` would print. Everything
|
||||
* else on the cap is graph state around the stopping symbol: the calls it makes
|
||||
* that this path did not need, and the name-only matches under 0.6 that the
|
||||
* search refused to follow. That last list is the honest half — an unfollowed
|
||||
* guess left invisible reads as "there is nothing here".
|
||||
*/
|
||||
function buildBoundary(
|
||||
cg: CodeGraph,
|
||||
stop: Node,
|
||||
reports: readonly NodeBoundary[],
|
||||
missed: readonly Node[],
|
||||
onPath: ReadonlySet<string>
|
||||
): WireFlowBoundary {
|
||||
const sites: WireBoundarySite[] = [];
|
||||
for (const report of reports) {
|
||||
for (const site of report.sites) {
|
||||
if (sites.length >= MAX_SITES_PER_FLOW) break;
|
||||
sites.push({
|
||||
form: site.form,
|
||||
label: site.label,
|
||||
snippet: site.snippet,
|
||||
line: site.line,
|
||||
key: site.key ?? null,
|
||||
keyIsType: site.keyIsType === true,
|
||||
moreSites: site.moreSites ?? 0,
|
||||
candidates: site.candidates.map((c) => ({
|
||||
node: toNodeRef(c.node),
|
||||
display: c.display,
|
||||
named: c.named,
|
||||
})),
|
||||
candidateNote: site.candidateNote,
|
||||
});
|
||||
}
|
||||
}
|
||||
const { resolved, uncertain } = continuationsFrom(cg, stop, onPath);
|
||||
return {
|
||||
node: toNodeRef(stop),
|
||||
sites,
|
||||
uncertain: wireList(uncertain.slice(0, MAX_CONTINUATIONS).map(toContinuation), uncertain.length),
|
||||
further: wireList(resolved.slice(0, MAX_CONTINUATIONS).map(toContinuation), resolved.length),
|
||||
missed: missed.slice(0, MAX_MISSED).map(toNodeRef),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -543,16 +698,75 @@ export async function buildFlow(
|
||||
const chosen = new Set(flow.chains.flatMap((c) => c.steps.map((s) => s.node.id)));
|
||||
const flows: WireFlow[] = [];
|
||||
for (const chain of flow.chains) {
|
||||
const onPath = new Set(chain.steps.map((s) => s.node.id));
|
||||
// A path that reaches everything the question named is connected, and a
|
||||
// connected answer gets no cap — this is the gate the whole feature turns
|
||||
// on. In directed mode a chain ends at `to` by construction, so it is
|
||||
// always connected and this is always empty.
|
||||
const missed = uncoveredNamed(flow, onPath);
|
||||
let boundary: WireFlowBoundary | null = null;
|
||||
if (missed.length > 0) {
|
||||
const stop = (chain.steps[chain.steps.length - 1] as { node: Node }).node;
|
||||
// Scan order is explore's: the dead end first (that IS where the partial
|
||||
// flow stopped), then the symbols it never reached.
|
||||
const reports = findDynamicBoundaries(cg, [stop, ...missed], {
|
||||
named: flow.named,
|
||||
maxSites: MAX_SITES_PER_FLOW,
|
||||
});
|
||||
boundary = buildBoundary(cg, stop, reports, missed, onPath);
|
||||
}
|
||||
// The last card opens at the dispatch line rather than at its definition,
|
||||
// so the window shows the site the cap beside it is describing. Without
|
||||
// this a long body puts them hundreds of lines apart and the cap reads as a
|
||||
// claim about code the reader cannot see.
|
||||
const stopLine = boundary?.sites[0]?.line;
|
||||
flows.push(
|
||||
await toWireFlow(
|
||||
cg,
|
||||
projectRoot,
|
||||
cache,
|
||||
chain.steps.map((s) => ({ node: s.node, edge: s.edge, upward: false }))
|
||||
chain.steps.map((s, i) => ({
|
||||
node: s.node,
|
||||
edge: s.edge,
|
||||
upward: false,
|
||||
...(stopLine !== undefined && i === chain.steps.length - 1 ? { anchor: stopLine } : {}),
|
||||
})),
|
||||
{ boundary }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// No path at all. If a dispatch site explains why, the strip is that site:
|
||||
// one card opened at the line where the static path ends, and the cap. Saying
|
||||
// "not connected" while the answer sits three lines into the body would be
|
||||
// the same silence this whole feature exists to break. With nothing detected
|
||||
// we do NOT invent a stopping point — the search covered a whole region, and
|
||||
// pinning "the graph stops here" on the seed would be a claim, not a finding.
|
||||
if (flows.length === 0 && flow.named.size > 0) {
|
||||
const seeds = boundarySeeds(flow, directed ? parsed.from : null, directed ? parsed.to : null);
|
||||
const reports = findDynamicBoundaries(cg, seeds, {
|
||||
named: flow.named,
|
||||
maxSites: MAX_SITES_PER_FLOW,
|
||||
});
|
||||
const first = reports[0];
|
||||
if (first && first.sites[0]) {
|
||||
const stop = first.node;
|
||||
const missed = uncoveredNamed(flow, new Set([stop.id]));
|
||||
flows.push(
|
||||
await toWireFlow(
|
||||
cg,
|
||||
projectRoot,
|
||||
cache,
|
||||
[{ node: stop, edge: null, upward: false, anchor: first.sites[0].line }],
|
||||
{
|
||||
boundary: buildBoundary(cg, stop, reports, missed, new Set([stop.id])),
|
||||
partial: true,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
query: {
|
||||
@@ -564,11 +778,64 @@ export async function buildFlow(
|
||||
flows,
|
||||
ambiguous: ambiguitiesOf(flow.tokenNodes, flow.named, chosen, flow.tokens),
|
||||
unresolved,
|
||||
reason: flows.length > 0 ? null : noFlowReason(parsed, flow.tokens.length, unresolved),
|
||||
// A boundary strip is not a path, so the reason still stands: it says what
|
||||
// was not found, and the cap says where the looking stopped.
|
||||
reason: flow.chains.length > 0 ? null : noFlowReason(parsed, flow.tokens.length, unresolved),
|
||||
timing: { elapsedMs: Date.now() - started },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The named symbols this path never reaches, deduped by name.
|
||||
*
|
||||
* Per TOKEN, not per node: a token whose overloads are all off the path is
|
||||
* genuinely unreached, but a token with one overload on it is answered — which
|
||||
* is exactly how `codegraph_explore` decides whether to announce a boundary.
|
||||
* The reader's own vocabulary (`uniqueNamedNodeIds`) sorts first, because a
|
||||
* symbol only they named is the one they are actually asking about.
|
||||
*/
|
||||
function uncoveredNamed(
|
||||
flow: ReturnType<typeof resolveNamedSymbolFlow>,
|
||||
onPath: ReadonlySet<string>
|
||||
): Node[] {
|
||||
const out: Node[] = [];
|
||||
const seenName = new Set<string>();
|
||||
for (const ids of flow.tokenNodes.values()) {
|
||||
if (ids.length === 0 || ids.some((id) => onPath.has(id))) continue;
|
||||
for (const id of ids) {
|
||||
const node = flow.named.get(id);
|
||||
if (!node || seenName.has(node.name)) continue;
|
||||
seenName.add(node.name);
|
||||
out.push(node);
|
||||
}
|
||||
}
|
||||
return out.sort(
|
||||
(a, b) =>
|
||||
(flow.uniqueNamedNodeIds.has(b.id) ? 1 : 0) - (flow.uniqueNamedNodeIds.has(a.id) ? 1 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bodies to scan when nothing connected, in the order worth scanning them.
|
||||
*
|
||||
* The outward walk starts at `from`, so a dispatch in `from`'s body is the one
|
||||
* that stopped it; `to`'s body is scanned after, because a flow can equally
|
||||
* break on the far side (the handler is reached by a bus nobody calls
|
||||
* directly). A `?symbols=` question has no direction and scans what it named.
|
||||
*/
|
||||
function boundarySeeds(
|
||||
flow: ReturnType<typeof resolveNamedSymbolFlow>,
|
||||
from: string | null,
|
||||
to: string | null
|
||||
): Node[] {
|
||||
if (from === null || to === null) return [...flow.named.values()];
|
||||
const pick = (token: string): Node[] =>
|
||||
(flow.tokenNodes.get(normalizeToken(token)) ?? [])
|
||||
.map((id) => flow.named.get(id))
|
||||
.filter((n): n is Node => !!n);
|
||||
return [...pick(from), ...pick(to)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Why there is no strip, in the words that say what to do next.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user