node.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. /**
  2. * `GET /api/node/<id>` — everything the Symbol view draws, in one round-trip.
  3. *
  4. * The Symbol view is three panes and a strip: callers on the left, the verbatim
  5. * body in the middle with a port per call site, callees on the right anchored
  6. * to those lines, and a blast-radius summary underneath. Splitting that across
  7. * five endpoints would mean five waterfalls before the screen settles, and the
  8. * screen is the product. So this endpoint answers all of it.
  9. *
  10. * Two properties it has to hold, and the reasons they are not obvious:
  11. *
  12. * **No N+1, anywhere.** The engine's own busiest symbol has 545 incoming edges.
  13. * Resolving those one `getNode` at a time is 545 queries and blows the budget on
  14. * its own; so every edge list is resolved with one batched `getNodesByIds`, and
  15. * fan-in for the rail pills comes from one batched `getFanIn`.
  16. *
  17. * **Capped lists that still tell the truth.** 545 callers cannot all be rows,
  18. * but the payload must never suggest there are fewer. Every list carries the
  19. * true `total` beside the `shown` slice, and the ordering is chosen so the
  20. * slice is the useful end: same file first, then production code, then tests.
  21. */
  22. import type { CodeGraph } from '../../index';
  23. import type { Edge, Node, NodeKind } from '../../types';
  24. import { isTestFile } from '../../search/query-utils';
  25. import { buildHierarchy, type WireOverride } from './hierarchy';
  26. import { notFound } from './respond';
  27. import { findIndexedFile, hasDriftedOnDisk } from './source';
  28. import { annotateWhen } from './when';
  29. import {
  30. BLAST_DEPTH,
  31. CALLER_EDGE_KINDS,
  32. CONTAINER_KINDS,
  33. HUB_THRESHOLD,
  34. MAX_INCOMING_GROUPS,
  35. MAX_OUTGOING_GROUPS,
  36. MAX_OUTLINE_NODES,
  37. MAX_OUTSIDE_INDEX_SAMPLES,
  38. MAX_TEST_FILES,
  39. TEST_CALLER_BUDGET,
  40. TEST_CALLER_HOPS,
  41. TYPE_KINDS,
  42. firstLine,
  43. groupRelations,
  44. toNodeDetail,
  45. toNodeRef,
  46. toPosixPath,
  47. wireList,
  48. type WireNodeRef,
  49. } from './wire';
  50. /** A member row in the focal symbol's outline, with its place in the tree. */
  51. export interface WireMember extends WireNodeRef {
  52. /** The container this member belongs to — the focal node, or one of its children. */
  53. parentId: string;
  54. /** 1 = direct member, 2 = a member of a member (a class's method inside a file). */
  55. depth: number;
  56. /**
  57. * Edges in and out of this member — the outline's `← in → out` columns.
  58. *
  59. * A container's own fan-out is usually zero (a class calls nothing; its
  60. * methods do), so without these an outline of a 700-line class says nothing
  61. * about which member is load-bearing and which is a getter. Edge counts, not
  62. * distinct counterparts: the column is a weight, and it sits beside a
  63. * signature rather than beside a caller list it could contradict.
  64. */
  65. fanIn: number;
  66. fanOut: number;
  67. /**
  68. * This member redeclares one an ancestor type declares — a name match inside
  69. * a chain the graph already links, not an `overrides` edge (nothing emits
  70. * one). Absent for every member that declares something new.
  71. */
  72. overrides?: WireOverride;
  73. }
  74. export async function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): Promise<unknown> {
  75. const node = cg.getNode(nodeId);
  76. if (!node) {
  77. throw notFound(
  78. 'No symbol with that id is in this index.',
  79. 'Symbol ids change whenever the file is re-indexed — search for the symbol by ' +
  80. 'name instead of reusing an id from an older session.'
  81. );
  82. }
  83. const incomingAll = cg.getIncomingEdges(nodeId);
  84. const outgoingAll = cg.getOutgoingEdges(nodeId);
  85. // `contains` is structure, not dependency: upward it is the parent (already in
  86. // `ancestors`), downward it is the members outline. Leaving it in the rails
  87. // would put a symbol's own class in its caller list.
  88. const incoming = incomingAll.filter((e) => e.kind !== 'contains');
  89. const outgoingRest: Edge[] = [];
  90. const containsOut: Edge[] = [];
  91. for (const edge of outgoingAll) {
  92. if (edge.kind === 'contains') containsOut.push(edge);
  93. else outgoingRest.push(edge);
  94. }
  95. const ancestors = cg.getAncestors(nodeId);
  96. // ---------------------------------------------------------------------------
  97. // One batched resolve for every endpoint this payload names.
  98. // ---------------------------------------------------------------------------
  99. const endpointIds = new Set<string>();
  100. for (const edge of incoming) endpointIds.add(edge.source);
  101. for (const edge of outgoingRest) endpointIds.add(edge.target);
  102. for (const edge of containsOut) endpointIds.add(edge.target);
  103. const endpoints = cg.getNodesByIds([...endpointIds]);
  104. // A `references` edge into a type is "uses type X", not "calls X" — the
  105. // header shows those as chips rather than as callee rows. Split at the EDGE
  106. // level so a class that is both instantiated and named as a type appears in
  107. // both places, which is what the source actually says.
  108. const calleeEdges: Edge[] = [];
  109. const typeRefs: Edge[] = [];
  110. for (const edge of outgoingRest) {
  111. const target = endpoints.get(edge.target);
  112. if (edge.kind === 'references' && target && TYPE_KINDS.has(target.kind)) typeRefs.push(edge);
  113. else calleeEdges.push(edge);
  114. }
  115. // ---------------------------------------------------------------------------
  116. // Rails
  117. // ---------------------------------------------------------------------------
  118. const focalFile = toPosixPath(node.filePath);
  119. const incomingGroups = groupRelations(incoming, (e) => e.source, endpoints);
  120. incomingGroups.sort((a, b) => {
  121. // The symbol's own file first ("same file" in the left rail), then
  122. // production code, then tests — so a cap trims the least useful end.
  123. const aSame = a.node.file === focalFile ? 0 : 1;
  124. const bSame = b.node.file === focalFile ? 0 : 1;
  125. if (aSame !== bSame) return aSame - bSame;
  126. if (a.node.test !== b.node.test) return a.node.test ? 1 : -1;
  127. return a.node.file.localeCompare(b.node.file) || firstLine(a) - firstLine(b);
  128. });
  129. const outgoingGroups = groupRelations(calleeEdges, (e) => e.target, endpoints);
  130. // The right rail is line-anchored: rows sit beside the line that calls them.
  131. outgoingGroups.sort((a, b) => firstLine(a) - firstLine(b) || a.node.name.localeCompare(b.node.name));
  132. const typeGroups = groupRelations(typeRefs, (e) => e.target, endpoints);
  133. typeGroups.sort((a, b) => firstLine(a) - firstLine(b) || a.node.name.localeCompare(b.node.name));
  134. const shownIncoming = incomingGroups.slice(0, MAX_INCOMING_GROUPS);
  135. const shownOutgoing = outgoingGroups.slice(0, MAX_OUTGOING_GROUPS);
  136. // Branch conditions per call site: the right rail's sites are all in this
  137. // file; the left rail's are in each caller's own file.
  138. await annotateWhen(cg, projectRoot, [
  139. { file: focalFile, edges: shownOutgoing.flatMap((r) => r.edges) },
  140. ...shownIncoming.map((r) => ({ file: r.node.file, edges: r.edges })),
  141. ]);
  142. // Fan-in for the rail pills ("hub · N"), for the rows actually returned —
  143. // one query, not one per row.
  144. const fanInOf = cg.getFanIn([
  145. ...shownIncoming.map((r) => r.node.id),
  146. ...shownOutgoing.map((r) => r.node.id),
  147. ...typeGroups.map((r) => r.node.id),
  148. ]);
  149. for (const relation of [...shownIncoming, ...shownOutgoing, ...typeGroups]) {
  150. const count = fanInOf.get(relation.node.id) ?? 0;
  151. relation.fanIn = count;
  152. relation.hub = count >= HUB_THRESHOLD;
  153. }
  154. // ---------------------------------------------------------------------------
  155. // Members outline
  156. // ---------------------------------------------------------------------------
  157. // The type hierarchy, and the override marks it puts on the outline. Gated
  158. // to types inside `buildHierarchy`, so a function costs one kind test.
  159. const hierarchy = buildHierarchy(cg, node);
  160. const members = buildMembers(cg, node, containsOut, endpoints, hierarchy?.overrides);
  161. // ---------------------------------------------------------------------------
  162. // Counts, tests, what leaves the index, blast radius
  163. // ---------------------------------------------------------------------------
  164. const directCallers: Node[] = [];
  165. const seenCaller = new Set<string>();
  166. for (const edge of incoming) {
  167. if (!CALLER_EDGE_KINDS.has(edge.kind) || seenCaller.has(edge.source)) continue;
  168. seenCaller.add(edge.source);
  169. const source = endpoints.get(edge.source);
  170. if (source) directCallers.push(source);
  171. }
  172. const drift = driftFor(cg, projectRoot, node.filePath);
  173. return {
  174. node: toNodeDetail(node),
  175. /** Outermost first: file, then module/class, then the symbol's own parent. */
  176. ancestors: [...ancestors].reverse().map(toNodeRef),
  177. members: wireList(members.items, members.total),
  178. /**
  179. * Ancestors, subtypes and the dispatch fan — `null` for anything that is
  180. * not a type, and for a type with no hierarchy at all.
  181. */
  182. hierarchy: hierarchy?.wire ?? null,
  183. incoming: wireList(shownIncoming, incomingGroups.length),
  184. outgoing: wireList(shownOutgoing, outgoingGroups.length),
  185. /** `references` edges into a type — the header's "uses types …" chips. */
  186. typesUsed: typeGroups,
  187. counts: {
  188. // Every count below is the length of a list this payload also returns, so
  189. // a badge and the rail beneath it can never disagree.
  190. /** Distinct symbols that reach this one — `incoming.total`. Drives `hub`. */
  191. callers: incomingGroups.length,
  192. /** Distinct symbols this one calls — `outgoing.total`. Types are counted separately. */
  193. callees: outgoingGroups.length,
  194. /** Distinct types this symbol names — `typesUsed.length`. */
  195. typesUsed: typeGroups.length,
  196. /** EDGE counts, which run higher: one caller can call from many lines. */
  197. fanIn: incoming.length,
  198. fanOut: outgoingRest.length,
  199. members: members.total,
  200. hub: incomingGroups.length >= HUB_THRESHOLD,
  201. },
  202. tests: summarizeTestCallers(cg, directCallers),
  203. outsideIndex: summarizeOutsideIndex(cg, nodeId),
  204. blast: summarizeBlast(cg, node, incomingGroups.length),
  205. /** The symbol's file changed on disk since the index — line ranges may be shifted. */
  206. drift,
  207. };
  208. }
  209. // =============================================================================
  210. // Members
  211. // =============================================================================
  212. /**
  213. * The focal symbol's members, in source order, one level of nesting deep.
  214. *
  215. * A file's outline is file → class → method, so direct children alone would
  216. * show a class and nothing inside it. The grandchildren come from ONE batched
  217. * `getOutgoingEdgesFrom` over the container children, never a query per child.
  218. */
  219. function buildMembers(
  220. cg: CodeGraph,
  221. focal: Node,
  222. containsOut: readonly Edge[],
  223. endpoints: Map<string, Node>,
  224. overrides?: Map<string, WireOverride>
  225. ): { items: WireMember[]; total: number } {
  226. const direct: Array<{ node: Node; parentId: string; depth: number }> = [];
  227. for (const edge of containsOut) {
  228. const child = endpoints.get(edge.target);
  229. if (child) direct.push({ node: child, parentId: focal.id, depth: 1 });
  230. }
  231. const containerIds = direct
  232. .filter((entry) => CONTAINER_KINDS.has(entry.node.kind))
  233. .map((entry) => entry.node.id);
  234. const nested: Array<{ node: Node; parentId: string; depth: number }> = [];
  235. if (containerIds.length > 0) {
  236. const grandEdges = cg.getOutgoingEdgesFrom(containerIds, ['contains']);
  237. const grandNodes = cg.getNodesByIds(grandEdges.map((e) => e.target));
  238. for (const edge of grandEdges) {
  239. const child = grandNodes.get(edge.target);
  240. if (child) nested.push({ node: child, parentId: edge.source, depth: 2 });
  241. }
  242. }
  243. const all = [...direct, ...nested].sort(
  244. (a, b) => a.node.startLine - b.node.startLine || a.node.name.localeCompare(b.node.name)
  245. );
  246. const shown = all.slice(0, MAX_OUTLINE_NODES);
  247. // Two queries for the whole outline, not two per row: a file with 400
  248. // symbols would otherwise be 800 lookups behind one screen.
  249. const memberIds = shown.map((entry) => entry.node.id);
  250. const fanIn = cg.getFanIn(memberIds);
  251. const fanOut = cg.getFanOut(memberIds);
  252. return {
  253. items: shown.map((entry) => {
  254. const member: WireMember = {
  255. ...toNodeRef(entry.node),
  256. parentId: entry.parentId,
  257. depth: entry.depth,
  258. fanIn: fanIn.get(entry.node.id) ?? 0,
  259. fanOut: fanOut.get(entry.node.id) ?? 0,
  260. };
  261. const override = overrides?.get(entry.node.id);
  262. if (override) member.overrides = override;
  263. return member;
  264. }),
  265. total: all.length,
  266. };
  267. }
  268. // =============================================================================
  269. // Test coverage
  270. // =============================================================================
  271. export interface WireTestSummary {
  272. /** A test file reaches this symbol within {@link TEST_CALLER_HOPS} caller hops. */
  273. reached: boolean;
  274. /** How many hops away the nearest test was. 1 = a test calls it directly. */
  275. hops: number | null;
  276. fileCount: number;
  277. files: string[];
  278. /**
  279. * The search finished rather than running out of budget. `false` weakens the
  280. * claim from "no test reaches this within 3 hops" to "no test calls this
  281. * directly", which is all that was actually checked.
  282. */
  283. exhaustive: boolean;
  284. hopsSearched: number;
  285. }
  286. /**
  287. * Which tests reach this symbol — the same question, and the same method,
  288. * behind `codegraph_explore`'s "tests:" line.
  289. *
  290. * Direct test callers first; failing that, walk up to two more caller hops,
  291. * because a helper called only by production code is still tested through
  292. * whatever calls it. The budget bounds a god-symbol, and running out of it is
  293. * reported rather than papered over: claiming "no test reaches this" after an
  294. * incomplete search would be exactly the kind of confident wrong answer the
  295. * viewer exists to avoid.
  296. */
  297. function summarizeTestCallers(cg: CodeGraph, directCallers: readonly Node[]): WireTestSummary {
  298. const directFiles = [
  299. ...new Set(directCallers.map((n) => toPosixPath(n.filePath)).filter(isTestFile)),
  300. ];
  301. if (directFiles.length > 0) {
  302. return {
  303. reached: true,
  304. hops: 1,
  305. fileCount: directFiles.length,
  306. files: directFiles.slice(0, MAX_TEST_FILES),
  307. exhaustive: true,
  308. hopsSearched: 1,
  309. };
  310. }
  311. let budget = TEST_CALLER_BUDGET;
  312. const visited = new Set(directCallers.map((n) => n.id));
  313. let frontier: Node[] = [...directCallers];
  314. let hopsSearched = 1;
  315. for (let hop = 2; hop <= TEST_CALLER_HOPS && frontier.length > 0 && budget > 0; hop++) {
  316. hopsSearched = hop;
  317. const next: Node[] = [];
  318. const found = new Set<string>();
  319. for (const current of frontier) {
  320. if (budget-- <= 0) break;
  321. let callers: Array<{ node: Node }>;
  322. try {
  323. callers = cg.getCallers(current.id) as Array<{ node: Node }>;
  324. } catch {
  325. continue;
  326. }
  327. for (const caller of callers) {
  328. const source = caller?.node;
  329. if (!source || visited.has(source.id)) continue;
  330. visited.add(source.id);
  331. const file = toPosixPath(source.filePath);
  332. if (isTestFile(file)) found.add(file);
  333. else next.push(source);
  334. }
  335. }
  336. if (found.size > 0) {
  337. const files = [...found];
  338. return {
  339. reached: true,
  340. hops: hop,
  341. fileCount: files.length,
  342. files: files.slice(0, MAX_TEST_FILES),
  343. exhaustive: true,
  344. hopsSearched: hop,
  345. };
  346. }
  347. frontier = next;
  348. }
  349. return {
  350. reached: false,
  351. hops: null,
  352. fileCount: 0,
  353. files: [],
  354. exhaustive: budget > 0,
  355. hopsSearched,
  356. };
  357. }
  358. // =============================================================================
  359. // References that leave the index
  360. // =============================================================================
  361. /**
  362. * Calls and type mentions from this symbol that never resolved to a node — a
  363. * third-party package, a runtime builtin, a construct extraction doesn't model.
  364. *
  365. * Without this the callee rail would silently be shorter than the body's call
  366. * sites, which reads as "nothing else happens here". Saying "+N calls into
  367. * symbols outside the index" is the honest version of the same screen.
  368. */
  369. function summarizeOutsideIndex(
  370. cg: CodeGraph,
  371. nodeId: string
  372. ): {
  373. total: number;
  374. byKind: Record<string, number>;
  375. samples: Array<{ name: string; kind: string; line: number; col: number }>;
  376. } {
  377. let refs;
  378. try {
  379. refs = cg.getUnresolvedReferencesFrom(nodeId);
  380. } catch {
  381. return { total: 0, byKind: {}, samples: [] };
  382. }
  383. const byKind: Record<string, number> = {};
  384. for (const ref of refs) byKind[ref.referenceKind] = (byKind[ref.referenceKind] ?? 0) + 1;
  385. const samples = [...refs]
  386. .sort((a, b) => a.line - b.line || a.column - b.column)
  387. .slice(0, MAX_OUTSIDE_INDEX_SAMPLES)
  388. .map((ref) => ({
  389. name: ref.referenceName,
  390. kind: ref.referenceKind,
  391. line: ref.line,
  392. col: ref.column,
  393. }));
  394. return { total: refs.length, byKind, samples };
  395. }
  396. // =============================================================================
  397. // Blast radius
  398. // =============================================================================
  399. export interface WireBlastSummary {
  400. /** Distinct symbols that depend on this one directly. */
  401. direct: number;
  402. /** Distinct symbols reached within {@link BLAST_DEPTH} dependency hops. */
  403. withinHops: number;
  404. hops: number;
  405. files: number;
  406. testFiles: number;
  407. routes: number;
  408. /** Up to 40 of the dependent files, most-affected first, for the "what would need re-checking" fold. */
  409. topFiles: Array<{ file: string; symbols: number; test: boolean }>;
  410. }
  411. /**
  412. * What would need re-checking if this symbol changed.
  413. *
  414. * `getImpactRadius` at depth 3 is the engine's own answer to that question —
  415. * incoming dependencies only, `contains` excluded upward so a leaf symbol does
  416. * not explode into its whole class, container members expanded downward so
  417. * callers of a class's methods count against the class.
  418. */
  419. function summarizeBlast(cg: CodeGraph, node: Node, direct: number): WireBlastSummary | null {
  420. let subgraph;
  421. try {
  422. subgraph = cg.getImpactRadius(node.id, BLAST_DEPTH);
  423. } catch {
  424. return null;
  425. }
  426. const perFile = new Map<string, number>();
  427. let routes = 0;
  428. for (const [id, dependent] of subgraph.nodes) {
  429. if (id === node.id) continue;
  430. const file = toPosixPath(dependent.filePath);
  431. perFile.set(file, (perFile.get(file) ?? 0) + 1);
  432. if (dependent.kind === ('route' as NodeKind)) routes++;
  433. }
  434. const testFiles = [...perFile.keys()].filter(isTestFile).length;
  435. const topFiles = [...perFile.entries()]
  436. .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
  437. .slice(0, 40)
  438. .map(([file, symbols]) => ({ file, symbols, test: isTestFile(file) }));
  439. return {
  440. direct,
  441. withinHops: Math.max(0, subgraph.nodes.size - 1),
  442. hops: BLAST_DEPTH,
  443. files: perFile.size,
  444. testFiles,
  445. routes,
  446. topFiles,
  447. };
  448. }
  449. // =============================================================================
  450. // Drift
  451. // =============================================================================
  452. function driftFor(cg: CodeGraph, projectRoot: string, filePath: string): boolean {
  453. const found = findIndexedFile(cg, filePath);
  454. if (!found) return false;
  455. return hasDriftedOnDisk(projectRoot, found.storedPath, found.record);
  456. }