wire.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. /**
  2. * The wire shapes the viewer reads, and the rules for producing them.
  3. *
  4. * Two ideas run through this file:
  5. *
  6. * 1. **One round-trip per screen.** Every endpoint returns everything a screen
  7. * draws, in the spirit of `codegraph_explore`: the Symbol view never has to
  8. * ask a follow-up question to render a rail, a badge or a count.
  9. * 2. **Capped lists, honest totals.** A symbol with 545 callers cannot ship 545
  10. * rows, but it must never claim it has fewer. Every capped list carries the
  11. * true `total` beside the `shown` slice, so the UI can say "+N more" rather
  12. * than quietly truncating.
  13. *
  14. * Nothing here reads the filesystem — that lives in `source.ts`, behind
  15. * `resolveProjectFile`.
  16. */
  17. import type { Edge, EdgeKind, Language, Node, NodeKind } from '../../types';
  18. import { isTestFile } from '../../search/query-utils';
  19. // =============================================================================
  20. // Caps and thresholds
  21. // =============================================================================
  22. /**
  23. * Fan-in at or above which a symbol is a "hub" — changing it is a
  24. * repo-wide event. Matches the threshold the Symbol view's `hub · N` badge
  25. * uses (design spec §3.2).
  26. */
  27. export const HUB_THRESHOLD = 40;
  28. /**
  29. * Below this resolution confidence an edge is a name-only guess. The viewer
  30. * folds these away behind "Uncertain · N name-only matches, confidence < 0.6"
  31. * rather than mixing them into the rails as if they were resolved.
  32. */
  33. export const UNCERTAIN_BELOW = 0.6;
  34. /** Caller groups (one per calling symbol) returned for a node. */
  35. export const MAX_INCOMING_GROUPS = 300;
  36. /** Callee groups (one per called symbol) returned for a node. */
  37. export const MAX_OUTGOING_GROUPS = 200;
  38. /** Edges kept inside a single group — one symbol calling another 400 times. */
  39. export const MAX_EDGES_PER_GROUP = 40;
  40. /** Test files named in a node's test-caller summary (explore uses the same shape). */
  41. export const MAX_TEST_FILES = 6;
  42. /**
  43. * Dependency hops the blast-radius summary walks. Matches the depth
  44. * `codegraph_explore` claims when it says "within 3 hops".
  45. */
  46. export const BLAST_DEPTH = 3;
  47. /** Caller hops walked looking for a test. Mirrors `codegraph_explore`'s "tests:" line. */
  48. export const TEST_CALLER_HOPS = 3;
  49. /** `getCallers` lookups the test walk may spend, so a god-symbol can't stall a request. */
  50. export const TEST_CALLER_BUDGET = 64;
  51. /** Unresolved references listed by name before the payload just counts them. */
  52. export const MAX_OUTSIDE_INDEX_SAMPLES = 40;
  53. /** Symbols in a file outline. Beyond this the outline is truncated, not dropped. */
  54. export const MAX_OUTLINE_NODES = 3000;
  55. /** Files listed in each direction of the File view's import rails. */
  56. export const MAX_IMPORT_FILES = 300;
  57. // =============================================================================
  58. // Node shapes
  59. // =============================================================================
  60. /**
  61. * A symbol as it appears in a rail, an outline or a search result: enough to
  62. * draw a row and navigate to it, and nothing else. Deliberately excludes the
  63. * docstring — a 300-caller rail would otherwise ship 300 docstrings.
  64. */
  65. export interface WireNodeRef {
  66. id: string;
  67. kind: NodeKind;
  68. name: string;
  69. qualifiedName: string;
  70. /** Project-relative, forward slashes on every platform. */
  71. file: string;
  72. line: number;
  73. endLine: number;
  74. language: Language;
  75. signature?: string;
  76. exported?: boolean;
  77. /** The file this symbol lives in looks like test/fixture code. */
  78. test: boolean;
  79. /**
  80. * The file this symbol lives in is tool-generated, so the row draws in ink-4.
  81. *
  82. * OPTIONAL and absent by default: the verdict is a bounded lookup
  83. * (`generatedFilePredicate`), affordable over a screen's worth of rows and
  84. * not over a 545-caller rail. An endpoint fills it where it shows.
  85. */
  86. generated?: boolean;
  87. }
  88. /** The focal symbol of a Symbol view — the ref, plus everything the header shows. */
  89. export interface WireNodeDetail extends WireNodeRef {
  90. startColumn: number;
  91. endColumn: number;
  92. docstring?: string;
  93. visibility?: string;
  94. async?: boolean;
  95. static?: boolean;
  96. abstract?: boolean;
  97. decorators?: string[];
  98. typeParameters?: string[];
  99. returnType?: string;
  100. /** `endLine - line + 1`, so the header can print "N lines" without the source. */
  101. lines: number;
  102. }
  103. const rel = (p: string): string => p.replace(/\\/g, '/');
  104. export function toNodeRef(node: Node): WireNodeRef {
  105. const file = rel(node.filePath);
  106. const ref: WireNodeRef = {
  107. id: node.id,
  108. kind: node.kind,
  109. name: node.name,
  110. qualifiedName: node.qualifiedName,
  111. file,
  112. line: node.startLine,
  113. endLine: node.endLine,
  114. language: node.language,
  115. test: isTestFile(file),
  116. };
  117. if (node.signature) ref.signature = node.signature;
  118. if (node.isExported) ref.exported = true;
  119. return ref;
  120. }
  121. export function toNodeDetail(node: Node): WireNodeDetail {
  122. const detail: WireNodeDetail = {
  123. ...toNodeRef(node),
  124. startColumn: node.startColumn,
  125. endColumn: node.endColumn,
  126. lines: Math.max(1, node.endLine - node.startLine + 1),
  127. };
  128. if (node.docstring) detail.docstring = node.docstring;
  129. if (node.visibility) detail.visibility = node.visibility;
  130. if (node.isAsync) detail.async = true;
  131. if (node.isStatic) detail.static = true;
  132. if (node.isAbstract) detail.abstract = true;
  133. if (node.decorators?.length) detail.decorators = node.decorators;
  134. if (node.typeParameters?.length) detail.typeParameters = node.typeParameters;
  135. if (node.returnType) detail.returnType = node.returnType;
  136. return detail;
  137. }
  138. // =============================================================================
  139. // Edge shapes
  140. // =============================================================================
  141. /**
  142. * One edge, flattened.
  143. *
  144. * `metadata` is a free-form JSON blob in the schema; the fields lifted out here
  145. * are the ones the viewer draws with — confidence decides the uncertain fold,
  146. * `provenance`/`synthesizedBy`/`via`/`registeredAt` decide how a connector is
  147. * dashed and what the "via <mechanism>" pill says, `valueRef` distinguishes
  148. * "passes as value" from "calls". Anything else in the blob stays out: it is
  149. * resolver bookkeeping, not something a reader can act on.
  150. */
  151. export interface WireEdge {
  152. kind: EdgeKind;
  153. line?: number;
  154. col?: number;
  155. confidence?: number;
  156. resolvedBy?: string;
  157. provenance?: string;
  158. synthesizedBy?: string;
  159. via?: string;
  160. registeredAt?: string;
  161. valueRef?: boolean;
  162. }
  163. export function toWireEdge(edge: Edge): WireEdge {
  164. const meta = (edge.metadata ?? {}) as Record<string, unknown>;
  165. const wire: WireEdge = { kind: edge.kind };
  166. if (typeof edge.line === 'number') wire.line = edge.line;
  167. if (typeof edge.column === 'number') wire.col = edge.column;
  168. if (typeof meta.confidence === 'number') wire.confidence = meta.confidence;
  169. if (typeof meta.resolvedBy === 'string') wire.resolvedBy = meta.resolvedBy;
  170. if (edge.provenance) wire.provenance = edge.provenance;
  171. if (typeof meta.synthesizedBy === 'string') wire.synthesizedBy = meta.synthesizedBy;
  172. if (typeof meta.via === 'string') wire.via = meta.via;
  173. if (typeof meta.registeredAt === 'string') wire.registeredAt = meta.registeredAt;
  174. if (meta.valueRef === true) wire.valueRef = true;
  175. return wire;
  176. }
  177. // =============================================================================
  178. // Relations — edges grouped by the symbol at the other end
  179. // =============================================================================
  180. /**
  181. * Every edge between the focal symbol and ONE other symbol, as a single row.
  182. *
  183. * Grouping is what makes the rails readable: a helper called from eleven lines
  184. * of the same function is one row with eleven call-site chips, not eleven rows.
  185. */
  186. export interface WireRelation {
  187. node: WireNodeRef;
  188. /** Distinct edge kinds between the two, in first-seen order. */
  189. edgeKinds: EdgeKind[];
  190. /** Up to {@link MAX_EDGES_PER_GROUP} edges, ordered by line. */
  191. edges: WireEdge[];
  192. /** True number of edges, even when `edges` was capped. */
  193. edgeCount: number;
  194. /** Distinct call-site lines, ascending — what the gutter ports anchor to. */
  195. lines: number[];
  196. /** Highest confidence any edge in the group carries; null when none does. */
  197. confidence: number | null;
  198. /** The whole group is a name-only guess (see {@link UNCERTAIN_BELOW}). */
  199. uncertain: boolean;
  200. /** At least one edge was synthesized rather than parsed (dynamic dispatch). */
  201. synthesized: boolean;
  202. /** Fan-in of the other symbol — the `hub · N` pill. Only filled where the UI shows it. */
  203. fanIn?: number;
  204. hub?: boolean;
  205. }
  206. /** A capped list that still knows how long it really is. */
  207. export interface WireList<T> {
  208. total: number;
  209. shown: number;
  210. truncated: boolean;
  211. items: T[];
  212. }
  213. export function wireList<T>(items: T[], total: number): WireList<T> {
  214. return { total, shown: items.length, truncated: items.length < total, items };
  215. }
  216. /**
  217. * Fold edges into one relation per counterpart symbol.
  218. *
  219. * @param edges edges all sharing the focal node at one end
  220. * @param endpoint which end of each edge names the OTHER symbol
  221. * @param nodes batch-resolved endpoint nodes (never a lookup per edge)
  222. */
  223. export function groupRelations(
  224. edges: readonly Edge[],
  225. endpoint: (edge: Edge) => string,
  226. nodes: Map<string, Node>
  227. ): WireRelation[] {
  228. const byNode = new Map<string, Edge[]>();
  229. for (const edge of edges) {
  230. const id = endpoint(edge);
  231. const bucket = byNode.get(id);
  232. if (bucket) bucket.push(edge);
  233. else byNode.set(id, [edge]);
  234. }
  235. const relations: WireRelation[] = [];
  236. for (const [id, group] of byNode) {
  237. const node = nodes.get(id);
  238. // An edge whose endpoint is missing from `nodes` means the graph and the
  239. // node table disagree — skip it rather than invent a row. Callers still see
  240. // it in the totals they computed from the raw edge list.
  241. if (!node) continue;
  242. const ordered = [...group].sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
  243. const wireEdges = ordered.slice(0, MAX_EDGES_PER_GROUP).map(toWireEdge);
  244. const edgeKinds: EdgeKind[] = [];
  245. for (const edge of ordered) if (!edgeKinds.includes(edge.kind)) edgeKinds.push(edge.kind);
  246. const lines = [
  247. ...new Set(ordered.map((e) => e.line).filter((l): l is number => typeof l === 'number' && l > 0)),
  248. ].sort((a, b) => a - b);
  249. let confidence: number | null = null;
  250. let synthesized = false;
  251. for (const edge of ordered) {
  252. const value = (edge.metadata as Record<string, unknown> | undefined)?.confidence;
  253. if (typeof value === 'number' && (confidence === null || value > confidence)) confidence = value;
  254. if (edge.provenance === 'heuristic') synthesized = true;
  255. }
  256. relations.push({
  257. node: toNodeRef(node),
  258. edgeKinds,
  259. edges: wireEdges,
  260. edgeCount: ordered.length,
  261. lines,
  262. confidence,
  263. // No confidence recorded is NOT uncertain: tree-sitter edges extracted
  264. // straight from the AST carry none precisely because they are certain.
  265. uncertain: confidence !== null && confidence < UNCERTAIN_BELOW,
  266. synthesized,
  267. });
  268. }
  269. return relations;
  270. }
  271. /** First call-site line of a relation, for line-anchored ordering. Unlined rows sort last. */
  272. export function firstLine(relation: WireRelation): number {
  273. return relation.lines[0] ?? Number.MAX_SAFE_INTEGER;
  274. }
  275. /** Node kinds that count as "a type" for the Symbol view's "types used" chips. */
  276. export const TYPE_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
  277. 'interface',
  278. 'type_alias',
  279. 'class',
  280. 'struct',
  281. 'enum',
  282. 'union',
  283. 'trait',
  284. 'protocol',
  285. ]);
  286. /** Container kinds whose members the outline nests one level deeper. */
  287. export const CONTAINER_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
  288. 'file',
  289. 'module',
  290. 'namespace',
  291. 'class',
  292. 'struct',
  293. 'interface',
  294. 'trait',
  295. 'protocol',
  296. 'enum',
  297. 'union',
  298. ]);
  299. /** The four edge kinds `getCallers` treats as "reaches this symbol". */
  300. export const CALLER_EDGE_KINDS: ReadonlySet<EdgeKind> = new Set<EdgeKind>([
  301. 'calls',
  302. 'references',
  303. 'imports',
  304. 'instantiates',
  305. ]);
  306. export { rel as toPosixPath };