node.ts 19 KB

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