1
0

steps.ts 74 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470
  1. /**
  2. * `GET /api/steps` — what happens from here: a screen, a handler or any
  3. * symbol as the ANCHOR, and everything it sets in motion drawn as typed steps.
  4. *
  5. * The Screens view (`screens.ts`) is already a picture of steps with one step
  6. * type: it folds `HomeScreen → ItemsGrid → ItemCard → openObjectDetail` into
  7. * one arrow labelled with its condition, because the reader wants the
  8. * transition, not the plumbing. This endpoint keeps that fold and widens the
  9. * set of things worth a box. Walking FORWARD from the anchor over calls,
  10. * renders, handler bindings and navigations, a node is a step when it is:
  11. *
  12. * - a **screen** (a route reached over a `navigates` edge),
  13. * - a **trigger** — a function wired as a value (`onPress={handleX}`,
  14. * `addListener('x', handleX)`), the user's or the platform's way in,
  15. * - a **bridge** call — the language changes under the call, JS → native
  16. * (the React Native bridge resolver's edges, or any family crossing),
  17. * - a native **event** landing back in JS (`sendEvent(withName:)` → the
  18. * listener, via the RN event channel),
  19. * - a **store** action — a function in a store file, the state it writes,
  20. * - an **effect** — a call that leaves the index into the network, storage,
  21. * the device or telemetry, drawn as its own box beside the function that
  22. * makes it.
  23. *
  24. * Everything else — hooks, helpers, services, the components between a
  25. * screen and its handlers — is `via`: listed on the link, never a box. The
  26. * branch conditions along the folded chain join into the link's `when`, read
  27. * from the source at request time exactly as the Screens view reads them.
  28. *
  29. * The picture is finite because it is ANCHORED and CAPPED, not because the
  30. * graph is small: a bounded depth in steps, a bounded fan-out per node, a
  31. * bounded number of nodes folded per step, and hubs and shared chrome (a top
  32. * bar rendered on ten screens) are dead ends rather than paths. Every cap
  33. * that fired is reported on the step it fired at, so a short picture never
  34. * reads as "nothing else happens here".
  35. *
  36. * Read from the graph at request time, never cached: the `when` labels and
  37. * the effect sites are read from the source as it stands.
  38. */
  39. import type CodeGraph from '../../index';
  40. import type { Edge, Language, Node, UnresolvedReference } from '../../types';
  41. import { badRequest, intParam, notFound } from './respond';
  42. import { createSiteReader } from './when';
  43. import type { BranchGuard, SiteLoop, SiteTrigger } from '../../graph/branch-guards';
  44. import { buildProgram, type ProgramSite, type WireProgram } from './program';
  45. import { classifyEffect, implicitResponseStatus, responseStatus, type Effect } from './effects';
  46. import { guardLabel } from '../../graph/branch-guards';
  47. import { looksLikeComponent, routeRoots } from './route-roots';
  48. import { nextRouteForFile } from '../../resolution/frameworks/nextjs';
  49. import { splitRouteName } from './routes';
  50. import { HUB_THRESHOLD, UNCERTAIN_BELOW, toNodeRef, type WireNodeRef } from './wire';
  51. import { isTestPath } from '../../search/query-utils';
  52. // =============================================================================
  53. // Wire shapes
  54. // =============================================================================
  55. export type WireStepKind = 'anchor' | 'screen' | 'trigger' | 'bridge' | 'event' | 'store' | 'effect';
  56. export type WireStepLinkKind = 'calls' | 'navigates' | 'handler' | 'bridge' | 'event' | 'store' | 'effect';
  57. export interface WireStepSite {
  58. file: string;
  59. line: number;
  60. /** `push /capture`, `calls`, `client.post` — what the site does, in a word or two. */
  61. text: string;
  62. /**
  63. * What the site passes, as written and abbreviated: `'userEmail',
  64. * values.email`, `'/auth/login', { email, password }`. '' for an empty
  65. * argument list; absent when the source could not be read.
  66. */
  67. args?: string;
  68. /**
  69. * The conditions THIS site runs under — the whole chain's, joined; '' when
  70. * unconditional. A link with several sites is several scenarios (four
  71. * early returns that each go home), and the viewer lists them as rows with
  72. * the clauses they share factored out; the link's own `when` is only the
  73. * summary of all of them.
  74. */
  75. when: string;
  76. /** What fires THIS site, when it differs from the link's first. */
  77. trigger?: WireStepTrigger;
  78. /** For a response site: the status code it sends, when literal (`res.status(404)`, `throw new NotFoundException`). */
  79. status?: number;
  80. }
  81. /** What fires a step or a link: the event it is written under, and the function that writes it there. */
  82. export interface WireStepTrigger extends SiteTrigger {
  83. /** The function the binding is written in — `LoginButton` for its `onPress`. */
  84. in: string;
  85. }
  86. export interface WireStep {
  87. /** The node's id, or `effect:<function id>:<api>` for a call leaving the index. */
  88. id: string;
  89. kind: WireStepKind;
  90. /** The step the picture starts from. A screen anchor keeps `kind: 'screen'`. */
  91. anchor: boolean;
  92. /** Null only for an effect, which is a call site rather than a symbol. */
  93. node: WireNodeRef | null;
  94. /** `/capture/review`, `handleApproveAllImages`, `client.post`. */
  95. label: string;
  96. /** The component for a screen, the file for a symbol, the category and caller for an effect. */
  97. sub: string;
  98. /** Steps from the anchor: the row. */
  99. depth: number;
  100. /**
  101. * Why the walk did not go on from this step, when it did not: a cap it hit
  102. * (`depth`, `fan-out`, `folded`, `steps`), or `screen` — another screen, or
  103. * an endpoint reached across a tier, is a chapter of its own, drawn but not
  104. * entered unless `through` asks.
  105. */
  106. cut: 'depth' | 'fan-out' | 'folded' | 'steps' | 'screen' | 'component' | null;
  107. /** The event name a native event step arrived on (`onZipComplete`) — the first, when several land here. */
  108. event?: string;
  109. /** Every event that lands on this step, in the order the walk met them. */
  110. events?: string[];
  111. /** For a handler: what fires it — the first binding the walk met. */
  112. trigger?: WireStepTrigger;
  113. /**
  114. * The step's place in its row, in the code's order: by the position of the
  115. * hop that first reached it, a hop written inside another site's arguments
  116. * counting before that site. The viewer lays the row out in it.
  117. */
  118. order?: number;
  119. /**
  120. * For a screen or an endpoint: its path and the symbol that serves it — the
  121. * component a screen renders, the handler an endpoint runs. `endpoint` when
  122. * the route leads with an HTTP verb (`POST /users`); `inline` when the
  123. * handler is an anonymous function at the registration site, so the route
  124. * itself stands in for it and `component` is null.
  125. */
  126. screen?: { path: string; component: WireNodeRef | null; endpoint: boolean; inline: boolean };
  127. /**
  128. * For an effect: the calls one function makes into one category — `api` is
  129. * the first, `apis` all of them — and the function that makes them. A
  130. * database call also says the model / table it touches when the call
  131. * names one, and whether it reads or writes; a response box lists the
  132. * status codes its sites send.
  133. */
  134. effect?: {
  135. api: string;
  136. apis: string[];
  137. category: string;
  138. by: WireNodeRef;
  139. line: number;
  140. model?: string;
  141. access?: 'read' | 'write';
  142. statuses?: number[];
  143. };
  144. }
  145. export interface WireStepLink {
  146. id: string;
  147. from: string;
  148. to: string;
  149. kind: WireStepLinkKind;
  150. /** The symbols folded between the two steps, in order. */
  151. via: WireNodeRef[];
  152. /** Conditions along the whole chain, joined; '' when unconditional. */
  153. when: string;
  154. /** How the last hop was established when it was not a plain call — `via rn-event-channel · registered at file:line`. */
  155. label: string;
  156. /** The call the first hop is written inside the arguments of — `res.json` for a token signed while building the reply. */
  157. within?: string;
  158. synthesized: boolean;
  159. uncertain: boolean;
  160. sites: WireStepSite[];
  161. /** What fires the first site, when something binds it to an event. */
  162. trigger?: WireStepTrigger;
  163. }
  164. export interface WireStepsPayload {
  165. anchor: WireNodeRef;
  166. /** Other symbols that share the anchor's name, when it was given by name. */
  167. ambiguous: WireNodeRef[];
  168. /**
  169. * What the index is a picture of, decided from its routes: an `app` of
  170. * screens, an `api` of endpoints, or a `web` app with both. The viewer's
  171. * words (screen / endpoint, store action / data) follow it.
  172. */
  173. project: 'app' | 'api' | 'web';
  174. steps: WireStep[];
  175. links: WireStepLink[];
  176. /**
  177. * The same walk read in the code's ORDER: the anchor's body as a rail that
  178. * forks where the code forks. Built from the same records the links are, so
  179. * the two readings hold the same steps; null when the anchor has no body to
  180. * read (nothing was recorded).
  181. */
  182. program: WireProgram | null;
  183. /**
  184. * Which reading to open with: the code's order for a handler, an endpoint or
  185. * any function; the tree for a screen, where handlers fire on events and
  186. * have no order between them. The URL's `view` overrides it.
  187. */
  188. defaultView: 'order' | 'tree';
  189. depth: number;
  190. limit: number;
  191. /** Screens reached from the anchor were entered rather than drawn as boundaries. */
  192. through: boolean;
  193. truncated: {
  194. /** Steps not added because the picture reached `limit`. */
  195. steps: number;
  196. /** Folded walks that stopped at a hub (fan-in ≥ the hub threshold). */
  197. hubs: number;
  198. /** Folded walks that stopped at shared chrome (a component rendered by several screens). */
  199. chrome: number;
  200. };
  201. index: { lastIndexedAt: number | null; edges: number; files: number };
  202. timing: { elapsedMs: number };
  203. }
  204. // =============================================================================
  205. // Caps
  206. // =============================================================================
  207. export const DEFAULT_DEPTH = 8;
  208. export const MAX_DEPTH = 14;
  209. export const DEFAULT_LIMIT = 120;
  210. export const MAX_LIMIT = 400;
  211. /** Nodes folded while exploring from ONE step before the walk stops. */
  212. const MAX_FOLDED_PER_STEP = 300;
  213. /** Hops of folded plumbing between two steps. */
  214. const MAX_FOLD_DEPTH = 7;
  215. /** Outgoing edges followed from one node; past this the node is a god function and the rest is announced. */
  216. const MAX_FANOUT = 80;
  217. /** Unresolved-reference scans (for effects) per request. */
  218. const MAX_EFFECT_SCANS = 800;
  219. /** Call sites read for conditions and arguments per request. */
  220. const MAX_WHEN_SITES = 1600;
  221. /** Call sites read for the callee as written (effect classification) per request — lookups on trees the guards parsed anyway. */
  222. const MAX_CALL_SITES = 4000;
  223. /** Longest effect-box label before its argument list is cut. */
  224. const MAX_EFFECT_LABEL = 56;
  225. /**
  226. * A component rendered by this many distinct parents is chrome (a top bar, a
  227. * button), not a screen's own behaviour. Higher than the Screens view's 3: that
  228. * one attributes navigations, where three screens sharing a link is already
  229. * chrome; this one decides what to WALK INTO, and a capture component shared
  230. * by three capture flows is the screen's whole body.
  231. */
  232. const SHARED_CHROME_MIN = 5;
  233. /** Edges walked forward. `contains` only function → function (a hook's handlers); `references` only function-as-value. */
  234. const WALK_KINDS: Edge['kind'][] = ['calls', 'instantiates', 'navigates', 'references', 'contains'];
  235. // =============================================================================
  236. // Classification
  237. // =============================================================================
  238. const JS_FAMILY: ReadonlySet<Language> = new Set<Language>(['javascript', 'typescript', 'tsx', 'jsx']);
  239. const NATIVE_FAMILY: ReadonlySet<Language> = new Set<Language>(['swift', 'objc', 'java', 'kotlin']);
  240. /**
  241. * JS → native is a bridge call; native → JS is an event. Anything else is one
  242. * family — unless the edge itself says which way it crosses: a synthesized
  243. * channel (`resolution/tier-synthesizer.ts`) marks a client's request onto its
  244. * own route `client→server`, a socket message back `server→client`, and a
  245. * queue job or a bus event as a `channel` whose landing is an arrival; a
  246. * server action called from a client file is marked `client→server` at
  247. * request time, by its directive.
  248. */
  249. export function crossing(from: Language, to: Language, meta: Record<string, unknown> = {}): 'bridge' | 'event' | null {
  250. if (meta.tier === 'client→server') return 'bridge';
  251. if (meta.tier === 'server→client') return 'event';
  252. if (meta.channel === 'queue' || meta.channel === 'event' || meta.channel === 'socket') return 'event';
  253. if (JS_FAMILY.has(from) && NATIVE_FAMILY.has(to)) return 'bridge';
  254. if (NATIVE_FAMILY.has(from) && JS_FAMILY.has(to)) return 'event';
  255. return null;
  256. }
  257. /**
  258. * A file that holds state: a store, a slice, a reducer. The graph has no
  259. * "store" kind — a Zustand action is an ordinary function node — so the file
  260. * is the evidence, and the legend says so.
  261. */
  262. export const STORE_FILE = /(?:^|\/)(?:stores?|storage|state|slices?|reducers?)\/|\.(?:store|storage|slice|reducer)\.[cm]?[jt]sx?$/i;
  263. export function isStoreFile(file: string): boolean {
  264. return STORE_FILE.test(file.replace(/\\/g, '/'));
  265. }
  266. /**
  267. * What a call is when it leaves the index, by the reference text alone — the
  268. * mobile app's table, kept for callers that have no language in hand. The
  269. * Steps walk itself classifies on the call AS WRITTEN with the language and
  270. * the project kind (`effects.ts`).
  271. */
  272. export function effectCategory(referenceName: string): string | null {
  273. return classifyEffect({ text: referenceName, kind: 'calls' })?.category ?? null;
  274. }
  275. /** A method of a repository / DAO / mapper, by the container's name — the ORM boundary in a project that types it. */
  276. const REPOSITORY_CONTAINER = /(?:Repository|Repositories|Repo|Dao|DAO|Mapper|Store|Datastore)$/;
  277. /** Decorators that gate a handler: guards, interceptors, pipes, roles, auth, validation, transactions, throttles. */
  278. const GUARD_DECORATOR =
  279. /^(?:UseGuards|UseInterceptors|UsePipes|UseFilters|Roles|Auth|Public|Permissions|Throttle|SkipThrottle|Authorize|AllowAnonymous|PreAuthorize|PostAuthorize|Secured|RolesAllowed|PermitAll|DenyAll|Transactional|Validated|login_required|permission_required|user_passes_test|staff_member_required|require_http_methods|require_POST|require_GET|csrf_exempt|csrf_protect|ratelimit|throttle_classes|permission_classes|authentication_classes|cache_page|ValidateAntiForgeryToken|RequireAuthorization|RequireRole|RequireHttps|EnableCors|CrossOrigin|Cacheable|CacheEvict|CachePut|RateLimiter|CircuitBreaker|Retry|Timeout|Bulkhead|jwt_required|Security|ApiBearerAuth|ApiKeyAuth|BearerAuth|OAuth|Scopes|Roles|HasRole|HasPermission|Idempotent|Lock|Locked|Retryable|Recover)$|Guard|Interceptor|Pipe$|Filter$|Auth|Role|Permission|Throttle|Valid|Transaction|Csrf|Limit/i;
  280. /** Decorators that ARE the route, the DI wiring, or documentation — never a guard. */
  281. const NOT_A_GUARD =
  282. /^(?:Get|Post|Put|Patch|Delete|Head|Options|All|Controller|RestController|Resolver|Query|Mutation|Subscription|Injectable|Module|Api\w*|Http(?:Get|Post|Put|Patch|Delete|Head|Options)|Route|RequestMapping|\w+Mapping|Component|Service|Repository|Bean|Autowired|Override|Inject|Param|Body|Res|Req|Headers|Ip|HostParam|Session|UploadedFiles?|HttpCode|Header|Redirect|Render|Version|SerializeOptions|ResponseBody|ResponseStatus|Produces|Consumes|FromBody|FromRoute|FromQuery|FromForm|FromHeader|FromServices|Path|PathVariable|RequestParam|RequestBody|RequestHeader|ModelAttribute|Valid|Args|Context|Parent|Info|Field|ObjectType|InputType|ArgsType|Entity|Column|PrimaryGeneratedColumn|OneToMany|ManyToOne|Prop|Schema|Type|Expose|Exclude|Transform|IsString|IsNumber|IsOptional|Length|Min|Max|Deprecated|SuppressWarnings|FunctionalInterface|Slf4j|Data|Builder|Getter|Setter|NoArgsConstructor|AllArgsConstructor|RequiredArgsConstructor|Value|ConfigurationProperties|Configuration|EnableScheduling|SpringBootApplication|Profile|Order|Primary|Qualifier|Lazy|Scope|JsonProperty|JsonIgnore|Nullable|NonNull|NotNull|Size|Pattern|Email|Positive|router\.\w+|app\.\w+|api\.\w+|bp\.\w+|blueprint\.\w+|\w+\.(?:route|get|post|put|patch|delete))$/;
  283. /** Decorators that fire a function from outside a request: a job, an event, a message, a schedule. */
  284. const CONSUMER_DECORATOR =
  285. /^(?:Process|Processor|OnEvent|OnQueueEvent|OnWorkerEvent|OnGlobalQueueEvent|Cron|Interval|Timeout|MessagePattern|EventPattern|SubscribeMessage|Scheduled|Schedules?|KafkaListener|RabbitListener|RabbitSubscribe|RabbitRPC|JmsListener|SqsListener|SqsMessageHandler|EventListener|TransactionalEventListener|StreamListener|ServiceActivator|receiver|shared_task|task|periodic_task|app\.task|celery\.task|on|hears|command|event|listen|listener|Consume|Consumer|Subscribe|Subscriber|CapSubscribe|Function|FunctionName|TimerTrigger|QueueTrigger|ServiceBusTrigger|EventGridTrigger|BlobTrigger|CosmosDBTrigger|Job|job|Worker|worker|EventHandler|CommandHandler|QueryHandler|OnMessage|MessageHandler|GrpcMethod|GrpcStreamMethod|WebSocketGateway|dramatiq\.actor|actor|huey\.task|db_task|Signal|signal|hook|Hook|OnModuleInit|OnApplicationBootstrap|PostConstruct|PreDestroy|Bean|Startup|Shutdown)$/;
  286. /** The name of a decorator, before its arguments. */
  287. function decoratorName(text: string): string {
  288. return text.replace(/\(.*$/s, '').trim();
  289. }
  290. /** The first string literal in a decorator's arguments — `'email'` of `@Process('email')`. */
  291. function decoratorLiteral(text: string): string | null {
  292. const m = /\(\s*(['"`])((?:(?!\1).)*)\1/.exec(text);
  293. return m ? `'${m[2]}'` : null;
  294. }
  295. function isGuardDecorator(text: string): boolean {
  296. const name = decoratorName(text);
  297. if (NOT_A_GUARD.test(name)) return false;
  298. return GUARD_DECORATOR.test(name);
  299. }
  300. /** FastAPI: `dependencies=[Depends(auth), Depends(rate_limit)]` inside the route decorator. */
  301. function dependenciesIn(text: string): string[] {
  302. const m = /dependencies\s*=\s*\[([^\]]*)\]/.exec(text);
  303. if (!m) return [];
  304. return m[1]!.split(/,(?![^()]*\))/).map((x) => x.trim()).filter(Boolean);
  305. }
  306. // =============================================================================
  307. // The endpoint
  308. // =============================================================================
  309. /**
  310. * Where a step is first reached from its parent's root: the hop's position,
  311. * its call's span, and the call it is written inside — what orders a row the
  312. * way the code reads, and says `inside res.json(…)` on the link.
  313. */
  314. interface HopSite {
  315. file: string;
  316. line: number;
  317. column: number;
  318. end: { line: number; column: number };
  319. within: string | null;
  320. }
  321. interface Fold {
  322. node: Node;
  323. /** [first folded node, …, this node]; empty for the step's own root. */
  324. chain: Node[];
  325. whens: string[];
  326. /** The hop out of the step's root this fold descends from; null for the root itself. */
  327. first: HopSite | null;
  328. }
  329. interface StepRecord extends WireStep {
  330. /** The hop that first reached this step, for the row's order; the anchor has none. */
  331. first?: HopSite;
  332. /** Where exploration from this step begins: a screen's component, otherwise the node itself. */
  333. root: Node | null;
  334. }
  335. export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLSearchParams): Promise<WireStepsPayload> {
  336. const started = Date.now();
  337. const depthCap = intParam(query, 'depth', { min: 1, max: MAX_DEPTH, default: DEFAULT_DEPTH });
  338. const limit = intParam(query, 'limit', { min: 20, max: MAX_LIMIT, default: DEFAULT_LIMIT });
  339. const through = query.get('through') === '1';
  340. const stats = cg.getStats();
  341. const index = { lastIndexedAt: cg.getLastIndexedAt() ?? null, edges: stats.edgeCount, files: stats.fileCount };
  342. const { anchor, ambiguous } = resolveAnchor(cg, query);
  343. // Route → where its code starts: the handler a resolver named, the page a
  344. // screen file exports, or the route itself standing in for an inline
  345. // handler (`route-roots.ts`) — and what kind of project this is, for the
  346. // words the viewer uses.
  347. const routes = cg.getNodesByKind('route');
  348. const roots = routeRoots(cg, routes);
  349. const project = projectKind(routes, stats.edgesByKind?.navigates ?? 0);
  350. const reader = createSiteReader(cg, projectRoot, MAX_WHEN_SITES);
  351. const calls = createSiteReader(cg, projectRoot, MAX_CALL_SITES);
  352. /** The conditions a site runs under, structured — one read, joined where a string is wanted. */
  353. const guardsAt = (caller: Node, site: { line?: number; column?: number }) => reader.guards(caller, site);
  354. /** The loops a site is written inside — a run of calls that happens once per item. */
  355. const loopsAt = (caller: Node, site: { line?: number; column?: number }) => reader.loops(caller, site);
  356. const argsAt = (caller: Node, site: { line?: number; column?: number }) => reader.args(caller, site);
  357. const withArgs = async (site: WireStepSite, caller: Node, at: { line?: number; column?: number }): Promise<WireStepSite> => {
  358. const args = await argsAt(caller, at);
  359. return args === null ? site : { ...site, args };
  360. };
  361. /** The call as written at a site, and what it passes — one read for both. */
  362. const callAt = (caller: Node, site: { line?: number; column?: number; callee?: string }) => calls.callSite(caller, site);
  363. /** A hop's position with its call's span and enclosing call, read from the tree; the bare position when unreadable. */
  364. const hopAt = async (caller: Node, at: { line?: number; column?: number }, callee?: string): Promise<HopSite> => {
  365. const line = at.line ?? caller.startLine;
  366. const column = at.column ?? 0;
  367. const read = at.line ? await callAt(caller, { ...at, ...(callee ? { callee } : {}) }) : null;
  368. return {
  369. file: caller.filePath,
  370. line: read?.span?.start.line ?? line,
  371. column: read?.span?.start.column ?? column,
  372. end: read?.span?.end ?? { line, column },
  373. within: read?.within ?? null,
  374. };
  375. };
  376. const pointHop = (caller: Node, at: { line?: number; column?: number }): HopSite => {
  377. const line = at.line ?? caller.startLine;
  378. const column = at.column ?? 0;
  379. return { file: caller.filePath, line, column, end: { line, column }, within: null };
  380. };
  381. // The declared type of a receiver: `OwnerRepository owners` in a Spring
  382. // controller makes `owners.save` the database; `private readonly
  383. // usersService: UsersService` in a Nest controller says where
  384. // `this.usersService.findByEmail` goes. The index keeps the first in a
  385. // field's signature and nothing of the second, so the class body is read
  386. // from the tree at request time, once per class.
  387. const fileTypes = new Map<string, Map<string, string>>();
  388. const classTypes = new Map<string, Map<string, string>>();
  389. const receiverTypeFor = async (caller: Node, callee: string): Promise<string | null> => {
  390. const first = callee.replace(/^(?:this|self)\./, '').split(/[.:(]/)[0] ?? '';
  391. if (!first || /^[A-Z]/.test(first)) return null;
  392. const declared = await memberTypesOf(caller);
  393. const own = declared.get(first) ?? declared.get(first.replace(/^_/, '')) ?? null;
  394. if (own) return own;
  395. let types = fileTypes.get(caller.filePath);
  396. if (!types) {
  397. types = new Map();
  398. for (const n of cg.getNodesInFile(caller.filePath)) {
  399. if ((n.kind !== 'field' && n.kind !== 'property' && n.kind !== 'variable' && n.kind !== 'parameter') || !n.signature) continue;
  400. const sig = n.signature.replace(/\s+/g, ' ').trim();
  401. // `OwnerRepository owners`, `private final OwnerRepository owners`, `owners: OwnerRepository`, `val owners: OwnerRepository`.
  402. const typed = new RegExp(`(?:^|\\s)([A-Z][\\w<>,?. ]*?)\\s+${n.name}\\b`).exec(sig) ?? new RegExp(`\\b${n.name}\\s*:\\s*([A-Z][\\w<>,?. ]*)`).exec(sig);
  403. if (typed && !types.has(n.name)) types.set(n.name, typed[1]!.trim());
  404. }
  405. fileTypes.set(caller.filePath, types);
  406. }
  407. return types.get(first) ?? null;
  408. };
  409. const memberTypesOf = async (node: Node): Promise<Map<string, string>> => {
  410. const key = `${node.filePath}:${node.startLine}`;
  411. let types = classTypes.get(key);
  412. if (!types) {
  413. types = await calls.memberTypes(node);
  414. classTypes.set(key, types);
  415. }
  416. return types;
  417. };
  418. // Where a member call really goes, by the receiver's declared type: the
  419. // class named by the type, and its method of the call's name. Null when the
  420. // type names nothing in the index (an ORM's `Repository<Cat>`) — then the
  421. // call leaves the index, and the effect table says as what.
  422. const classByName = new Map<string, Node | null>();
  423. /** The class / interface / struct a declared type names in the index, or null for a library's. */
  424. const classOfType = async (type: string): Promise<Node | null> => {
  425. const typeName = type.replace(/<.*$/, '').replace(/^[*&]+/, '').replace(/[?!]$/, '').split(/[.:]/).pop()?.trim() ?? '';
  426. if (!typeName || /^(?:string|number|boolean|any|unknown|object|void|String|Integer|Long|Boolean|int|long|bool|var|dynamic|Object|List|Map|Set|Array|Promise|Optional|Task|IEnumerable|Iterable)$/.test(typeName)) return null;
  427. let cls = classByName.get(typeName);
  428. if (cls === undefined) {
  429. const found = cg.getNodesByName(typeName).filter((n) => n.kind === 'class' || n.kind === 'interface' || n.kind === 'struct');
  430. cls = found.find((n) => !isTestPath(n.filePath)) ?? found[0] ?? null;
  431. classByName.set(typeName, cls);
  432. }
  433. return cls;
  434. };
  435. const resolveByReceiver = async (caller: Node, callee: string): Promise<Node | null> => {
  436. const segments = callee.replace(/\([^()]*\)/g, '').split(/[.:]+/).filter(Boolean);
  437. if (segments.length < 2) return null;
  438. const type = await receiverTypeFor(caller, callee);
  439. if (!type) return null;
  440. const cls = await classOfType(type);
  441. if (!cls) return null;
  442. const method = segments[segments.length - 1]!;
  443. const members = cg.getNodesInFile(cls.filePath).filter((n) => (n.kind === 'method' || n.kind === 'function') && n.name === method && n.startLine >= cls!.startLine && n.endLine <= cls!.endLine);
  444. return members[0] ?? null;
  445. };
  446. /** A method the walk cannot enter (an interface's, an ORM's) on a repository-shaped container. */
  447. const repositoryMethod = (target: Node): boolean => {
  448. if (target.kind !== 'method' && target.kind !== 'function') return false;
  449. if (isTestPath(target.filePath)) return false;
  450. const container = target.qualifiedName.replace(/[.:]+[^.:]*$/, '').split(/[.:]+/).pop() ?? '';
  451. if (!REPOSITORY_CONTAINER.test(container) && !/(?:^|\/)(?:repositories|repository|dao|daos|mappers)\//i.test(posix(target.filePath))) return false;
  452. return cg.getOutgoingEdgesFrom([target.id], WALK_KINDS).length === 0;
  453. };
  454. /** What runs before a route's handler: the middleware arguments at the registration, or the guard decorators on it. */
  455. const chainFor = async (route: Node, root: Node | null): Promise<string[]> => {
  456. const after: string[] = [];
  457. if (JS_FAMILY.has(route.language)) {
  458. const site = await calls.callSite(route, { line: route.startLine, column: 0 });
  459. if (site && /\.(?:get|post|put|patch|delete|all|use|head|options|route)$/i.test(site.callee)) {
  460. const args = site.argList.slice(1);
  461. if (args.length > 0 && !/^\{/.test(args[args.length - 1]!)) args.pop();
  462. for (const a of args) if (a && !/^\{ ?…? ?\}$/.test(a)) after.push(a);
  463. }
  464. }
  465. if (root && root.id !== route.id) {
  466. const decs = await calls.decorators(root);
  467. if (decs) {
  468. for (const d of [...decs.class, ...decs.own]) {
  469. if (!JS_FAMILY.has(root.language) && !/^(?:python)$/.test(root.language)) {
  470. if (isGuardDecorator(d)) after.push(d);
  471. continue;
  472. }
  473. for (const dep of dependenciesIn(d)) after.push(dep);
  474. if (isGuardDecorator(d)) after.push(d);
  475. }
  476. }
  477. }
  478. return [...new Set(after)];
  479. };
  480. /** The request a route's handler serves, as its trigger; a Next page's own work fires from its load. */
  481. const requestTrigger = async (route: Node, root: Node | null): Promise<WireStepTrigger | null> => {
  482. const { method, path } = splitRouteName(route.name);
  483. if (method === null) {
  484. if (nextRouteForFile(route.filePath)?.kind === 'page') return { kind: 'load', name: 'GET', of: path, in: basename(route.filePath) };
  485. return null;
  486. }
  487. const after = await chainFor(route, root);
  488. return { kind: 'request', name: method, of: path, in: basename(route.filePath), ...(after.length > 0 ? { after } : {}) };
  489. };
  490. /** A job, an event, a message or a schedule that fires a function, from its decorators. */
  491. const consumerTrigger = async (node: Node): Promise<WireStepTrigger | null> => {
  492. if (node.kind !== 'function' && node.kind !== 'method') return null;
  493. const decs = await calls.decorators(node);
  494. if (!decs) return null;
  495. for (const d of decs.own) {
  496. const name = decoratorName(d);
  497. const last = name.split('.').pop() ?? name;
  498. if (!CONSUMER_DECORATOR.test(name) && !CONSUMER_DECORATOR.test(last)) continue;
  499. const guards = [...decs.class, ...decs.own].filter((x) => x !== d && isGuardDecorator(x));
  500. return { kind: 'decorator', name, of: decoratorLiteral(d), in: basename(node.filePath), ...(guards.length > 0 ? { after: guards } : {}) };
  501. }
  502. return null;
  503. };
  504. const steps = new Map<string, StepRecord>();
  505. const links = new Map<string, WireStepLink>();
  506. /**
  507. * What happens in each function, in the code's own order — the rail's
  508. * material, recorded by the SAME pass that makes the links so the two
  509. * readings can never hold different steps. Keyed by the function's node id,
  510. * then by the site's position and what it reaches: a helper folded from two
  511. * different steps is walked twice and must not be written twice.
  512. */
  513. const programs = new Map<string, Map<string, ProgramSite>>();
  514. const record = (
  515. fn: Node,
  516. hop: HopSite,
  517. guards: readonly BranchGuard[],
  518. what: { step?: string; link?: string; into?: string },
  519. trigger: WireStepTrigger | null = null,
  520. loops: readonly SiteLoop[] = []
  521. ): void => {
  522. let sites = programs.get(fn.id);
  523. if (!sites) {
  524. sites = new Map();
  525. programs.set(fn.id, sites);
  526. }
  527. const key = `${hop.line}:${hop.column}:${what.step ?? what.into ?? ''}`;
  528. if (sites.has(key)) return;
  529. sites.set(key, {
  530. ...what,
  531. at: { line: hop.line, column: hop.column, end: hop.end },
  532. ...(hop.within ? { within: hop.within } : {}),
  533. guards: [...guards],
  534. ...(loops.length > 0 ? { loops: [...loops] } : {}),
  535. ...(trigger ? { trigger } : {}),
  536. });
  537. };
  538. const truncated = { steps: 0, hubs: 0, chrome: 0 };
  539. let effectScans = 0;
  540. const fanIn = new Map<string, number>();
  541. const chromeParents = new Map<string, number>();
  542. const fileScopeRefs = new Map<string, Edge[]>();
  543. const fileScopeUnresolved = new Map<string, UnresolvedReference[]>();
  544. const stepFor = (node: Node, kind: WireStepKind, depth: number, extra: Partial<WireStep> = {}): StepRecord | null => {
  545. const existing = steps.get(node.id);
  546. if (existing) {
  547. // A listener the screen registers is a handler when first met, and the
  548. // native event's landing when the walk arrives from the other side —
  549. // the second is the fuller fact, and it names the event.
  550. if (existing.kind === 'trigger' && kind === 'event') {
  551. existing.kind = 'event';
  552. if (extra.event) existing.event = extra.event;
  553. }
  554. if (kind === 'event' && extra.event) {
  555. existing.events = existing.events ?? (existing.event ? [existing.event] : []);
  556. if (!existing.events.includes(extra.event)) existing.events.push(extra.event);
  557. }
  558. return existing;
  559. }
  560. if (steps.size >= limit) {
  561. truncated.steps++;
  562. return null;
  563. }
  564. const isRoute = node.kind === 'route';
  565. const routeRoot = isRoute ? (roots.get(node.id) ?? null) : null;
  566. const record: StepRecord = {
  567. id: node.id,
  568. // A route is a screen or an endpoint — except one reached across a
  569. // tier (`fetch('/api/users')` onto its own route), which is the crossing.
  570. kind: isRoute && kind !== 'bridge' ? 'screen' : kind,
  571. anchor: false,
  572. node: toNodeRef(node),
  573. label: node.name,
  574. // A screen says its component, an endpoint its handler; a route the
  575. // graph bound to nothing says only where it is registered.
  576. sub: isRoute
  577. ? routeRoot === null
  578. ? basename(node.filePath)
  579. : routeRoot.inline
  580. ? `inline handler · ${basename(node.filePath)}`
  581. : routeRoot.node.name
  582. : posix(node.filePath),
  583. depth,
  584. cut: null,
  585. ...extra,
  586. root: isRoute ? (routeRoot?.node ?? null) : node,
  587. };
  588. if (kind === 'event' && extra.event) record.events = [extra.event];
  589. if (isRoute) {
  590. record.screen = {
  591. path: node.name,
  592. component: routeRoot !== null && !routeRoot.inline ? toNodeRef(routeRoot.node) : null,
  593. endpoint: splitRouteName(node.name).method !== null,
  594. inline: routeRoot?.inline ?? false,
  595. };
  596. }
  597. steps.set(node.id, record);
  598. return record;
  599. };
  600. // One box per (function, category): `uploadARCapture` makes one network
  601. // call, three storage calls and three telemetry calls — three boxes, each
  602. // listing its calls, not seven. A reply is the exception: its identity is
  603. // the outcome, so `authUser` answering 200 or 401 is two boxes — each
  604. // line into them then carries its own condition on the picture, the
  605. // Screens view's idiom — and the sites whose status cannot be read share
  606. // one `response` box labelled by the call.
  607. const effectSub = (e: NonNullable<WireStep['effect']>, by: Node): string =>
  608. [e.category, e.model, e.access, by.name].filter((x): x is string => !!x).join(' · ');
  609. const effectStep = (by: Node, ref: { referenceName: string; line: number }, effect: Effect, depth: number, status: number | null = null): StepRecord | null => {
  610. const category = effect.category;
  611. const id = status !== null ? `effect:${by.id}:${category}:${status}` : `effect:${by.id}:${category}`;
  612. const existing = steps.get(id);
  613. if (existing) {
  614. const e = existing.effect!;
  615. if (!e.apis.includes(ref.referenceName)) {
  616. e.apis.push(ref.referenceName);
  617. existing.label = `${e.apis[0]} +${e.apis.length - 1}`;
  618. }
  619. // Several models behind one box: list them; several accesses: say both.
  620. if (effect.model && e.model !== effect.model) {
  621. const models = new Set((e.model ?? '').split(', ').filter(Boolean));
  622. models.add(effect.model);
  623. e.model = [...models].slice(0, 3).join(', ') + (models.size > 3 ? ', …' : '');
  624. }
  625. if (effect.access && e.access && e.access !== effect.access) e.access = undefined;
  626. existing.sub = effectSub(e, by);
  627. return existing;
  628. }
  629. if (steps.size >= limit) {
  630. truncated.steps++;
  631. return null;
  632. }
  633. const e: NonNullable<WireStep['effect']> = {
  634. api: ref.referenceName,
  635. apis: [ref.referenceName],
  636. category,
  637. by: toNodeRef(by),
  638. line: ref.line,
  639. ...(effect.model ? { model: effect.model } : {}),
  640. ...(effect.access ? { access: effect.access } : {}),
  641. };
  642. const record: StepRecord = {
  643. id,
  644. kind: 'effect',
  645. anchor: false,
  646. node: null,
  647. label: ref.referenceName,
  648. sub: effectSub(e, by),
  649. depth,
  650. cut: null,
  651. effect: e,
  652. root: null,
  653. };
  654. steps.set(id, record);
  655. return record;
  656. };
  657. /** One effect site: the call as written, what it passes, when, what fires it, and — for a response — the status. */
  658. const effectLink = async (
  659. step: StepRecord,
  660. fold: Fold,
  661. ref: { referenceName: string; referenceKind: 'calls' | 'instantiates'; line: number; column?: number },
  662. trigger: WireStepTrigger | null,
  663. fallbackArgs: string | null = null,
  664. requireReceiver = false
  665. ): Promise<boolean> => {
  666. const at = { line: ref.line, column: ref.column };
  667. const site = await callAt(fold.node, { ...at, callee: ref.referenceName });
  668. // The site read must be THIS call: its last segment is the reference's.
  669. const last = (n: string) => n.replace(/\([^()]*\)/g, '').split(/[.:]/).pop() ?? n;
  670. const usable = !!site && site.callee !== '' && last(site.callee) === last(ref.referenceName);
  671. const text = usable ? site.callee : ref.referenceName;
  672. if (requireReceiver && !/[.:>]/.test(text)) return false;
  673. const args = usable ? site.args : fallbackArgs;
  674. // The receiver's declared type counts only when the call leaves the
  675. // index through it: a library's `Repository<Cat>`, or the project's own
  676. // `OwnerRepository` interface whose `save` comes from Spring Data — never
  677. // a project class that declares the method, which is a place to walk into.
  678. const declared = await receiverTypeFor(fold.node, text);
  679. const receiverType = declared && (await resolveByReceiver(fold.node, text)) === null ? declared : null;
  680. const effect = classifyEffect({
  681. text,
  682. kind: ref.referenceKind,
  683. language: fold.node.language,
  684. project,
  685. receiverType,
  686. args,
  687. });
  688. if (effect === null) return false;
  689. // A reply's status, read before its box exists — the box is per outcome.
  690. // `NextResponse.json(user, { status: 201 })`: the code sits in an object
  691. // the abbreviation reduced to its keys; the site reader kept it. And a
  692. // body-sending reply that sets none is a 200, so a success has a box of
  693. // its own beside the 401's.
  694. const status =
  695. effect.category === 'response'
  696. ? (responseStatus(text, args, ref.referenceKind) ?? (usable && typeof site.status === 'number' ? site.status : null) ?? implicitResponseStatus(text))
  697. : null;
  698. const target = effectStep(fold.node, { referenceName: text, line: ref.line }, effect, step.depth + 1, status);
  699. if (target === null) return true;
  700. const guards = await guardsAt(fold.node, at);
  701. const when = guardLabel(guards);
  702. const wireSite: WireStepSite = { file: posix(fold.node.filePath), line: ref.line, text, when: '' };
  703. if (args !== null) wireSite.args = args;
  704. if (status !== null) wireSite.status = status;
  705. // Where the call is written HERE — in this function, at this line. The
  706. // rail places the step by it; the tree's row order uses the hop out of the
  707. // step's root, which is the same position when nothing was folded.
  708. const local: HopSite = {
  709. file: fold.node.filePath,
  710. line: site?.span?.start.line ?? ref.line,
  711. column: site?.span?.start.column ?? ref.column ?? 0,
  712. end: site?.span?.end ?? { line: ref.line, column: ref.column ?? 0 },
  713. within: site?.within ?? null,
  714. };
  715. const hop: HopSite = fold.first ?? local;
  716. if (!target.first) target.first = hop;
  717. const fired = trigger ?? (await triggerAt(fold.node, at));
  718. const id = link(step, target, 'effect', fold.chain, [...fold.whens, when], wireSite, null, fired, hop.within);
  719. record(fold.node, local, guards, { step: target.id, link: id }, fired, await loopsAt(fold.node, at));
  720. return true;
  721. };
  722. const link = (
  723. from: StepRecord,
  724. to: StepRecord,
  725. kind: WireStepLinkKind,
  726. chain: Node[],
  727. whens: string[],
  728. site: WireStepSite,
  729. edge: Edge | null,
  730. trigger: WireStepTrigger | null = null,
  731. within: string | null = null
  732. ): string => {
  733. const meta = (edge?.metadata ?? {}) as Record<string, unknown>;
  734. const synthesized = edge?.provenance === 'heuristic';
  735. const confidence = typeof meta.confidence === 'number' ? meta.confidence : null;
  736. const via = chain.map(toNodeRef);
  737. const viaKey = via.map((v) => v.id).join('>');
  738. const id = `${from.id} ${to.id} ${viaKey}`;
  739. const when = whens.filter((w, i) => w && whens.indexOf(w) === i).join(' && ');
  740. const stamped: WireStepSite = { ...site, when, ...(trigger ? { trigger } : {}) };
  741. // A `contains` edge is how a nested handler is FOUND, not a place it is
  742. // called from: its row stays only while no call site has been seen.
  743. const structural = (s: WireStepSite) => s.text.startsWith('defines ');
  744. const existing = links.get(id);
  745. if (existing) {
  746. if (structural(stamped) && existing.sites.some((s) => !structural(s))) return id;
  747. if (!structural(stamped) && existing.sites.every(structural)) existing.sites.length = 0;
  748. // One statement, two references (`res.status(201)` and its `.json(…)`):
  749. // the outer call is the site, the inner one folds into it.
  750. const sameLine = existing.sites.findIndex((s) => s.file === site.file && s.line === site.line);
  751. if (sameLine < 0) existing.sites.push(stamped);
  752. else if (stamped.text.startsWith(existing.sites[sameLine]!.text) && stamped.text.length > existing.sites[sameLine]!.text.length) {
  753. existing.sites[sameLine] = stamped;
  754. }
  755. if (!existing.trigger && trigger) existing.trigger = trigger;
  756. if (!existing.within && within) existing.within = within;
  757. if (when !== existing.when) {
  758. if (!when || !existing.when) existing.when = '';
  759. else if (!existing.when.split(' || ').includes(when)) existing.when = `${existing.when} || ${when}`;
  760. }
  761. return id;
  762. }
  763. links.set(id, {
  764. id,
  765. from: from.id,
  766. to: to.id,
  767. kind,
  768. via,
  769. when,
  770. label: hopLabel(meta, synthesized),
  771. synthesized,
  772. uncertain: confidence !== null && confidence < UNCERTAIN_BELOW,
  773. sites: [stamped],
  774. ...(trigger ? { trigger } : {}),
  775. ...(within ? { within } : {}),
  776. });
  777. if (trigger && to.kind === 'trigger' && !to.trigger) to.trigger = trigger;
  778. return id;
  779. };
  780. /** What fires a site, with the function it is written in. */
  781. const triggerAt = async (caller: Node, at: { line?: number; column?: number }): Promise<WireStepTrigger | null> => {
  782. const t = await reader.trigger(caller, at);
  783. return t ? { ...t, in: caller.name } : null;
  784. };
  785. // The anchor: a screen keeps its kind and explores from its component; an
  786. // endpoint says the request that fires it and what runs before its handler;
  787. // a function says the job, event or schedule written on it.
  788. const first = stepFor(anchor, 'anchor', 0)!;
  789. first.anchor = true;
  790. if (anchor.kind === 'route') {
  791. const t = await requestTrigger(anchor, first.root);
  792. if (t) first.trigger = t;
  793. } else {
  794. const t = await consumerTrigger(anchor);
  795. if (t) first.trigger = t;
  796. }
  797. const queue: StepRecord[] = [first];
  798. /** Steps whose exploration has been queued — each is explored once, from the first row it appears on. */
  799. const explored = new Set<string>([first.id]);
  800. while (queue.length > 0) {
  801. const step = queue.shift()!;
  802. if (step.root === null) continue;
  803. // Another screen is a chapter of its own: the Screens view draws the way
  804. // between screens, and a picture that walked on through Home would be the
  805. // whole app. Drawn as a boundary, entered on request.
  806. // An endpoint reached across a tier is the same kind of boundary: the
  807. // request's own picture starts at its handler, entered on request.
  808. if ((step.kind === 'screen' || (step.kind === 'bridge' && step.node?.kind === 'route')) && !step.anchor && !through) {
  809. step.cut = 'screen';
  810. continue;
  811. }
  812. // A native event that lands in a COMPONENT — the capture overlay taking
  813. // `onCaptureProgress` — lands on another screen's body: its picture is
  814. // that screen's, not this one's. A boundary too, entered on request.
  815. if (step.kind === 'event' && !step.anchor && !through && looksLikeComponent(step.root)) {
  816. step.cut = 'component';
  817. continue;
  818. }
  819. if (step.depth >= depthCap) {
  820. // Something to explore, and no room in the picture for it.
  821. if (cg.getOutgoingEdgesFrom([step.root.id], WALK_KINDS).length > 0) step.cut = 'depth';
  822. continue;
  823. }
  824. // Breadth-first through the plumbing until the next steps.
  825. const visited = new Set<string>([step.root.id]);
  826. let frontier: Fold[] = [{ node: step.root, chain: [], whens: [], first: null }];
  827. for (let hop = 0; hop <= MAX_FOLD_DEPTH && frontier.length > 0; hop++) {
  828. const next: Fold[] = [];
  829. const ids = frontier.map((f) => f.node.id);
  830. const outgoing = cg.getOutgoingEdgesFrom(ids, WALK_KINDS);
  831. const bySource = new Map<string, Edge[]>();
  832. for (const e of outgoing) {
  833. const list = bySource.get(e.source) ?? [];
  834. list.push(e);
  835. bySource.set(e.source, list);
  836. }
  837. // `const Memoized = memo(CaptureComponent)`: the wrapper is a component
  838. // node with no edges of its own — the inner component is referenced
  839. // from the FILE scope, at the wrapper's line. Lend the wrapper those
  840. // references, so the screen that renders `<Memoized/>` walks on into
  841. // what the component does.
  842. // The same for a value a registration is written inside — `const
  843. // worker = new Worker('q', async (job) => { … })`: the arrow's calls
  844. // belong to the file scope and the constant spans them; a queue job
  845. // lands on the constant, and the walk goes on into what the handler does.
  846. for (const fold of frontier) {
  847. const value = fold.node.kind === 'constant' || fold.node.kind === 'variable';
  848. if ((fold.node.kind !== 'component' && !value) || (bySource.get(fold.node.id)?.length ?? 0) > 0) continue;
  849. for (const e of fileScopeEdgesWithin(cg, fold.node, fileScopeRefs, value)) {
  850. const list = bySource.get(fold.node.id) ?? [];
  851. list.push({ ...e, source: fold.node.id });
  852. bySource.set(fold.node.id, list);
  853. }
  854. }
  855. const targetIds = new Set<string>();
  856. for (const list of bySource.values()) for (const e of list) targetIds.add(e.target);
  857. const targets = targetIds.size === 0 ? new Map<string, Node>() : cg.getNodesByIds([...targetIds]);
  858. // Hubs and chrome are judged on the nodes about to be entered.
  859. const unknownFanIn = [...targetIds].filter((id) => !fanIn.has(id));
  860. if (unknownFanIn.length > 0) for (const [id, n] of cg.getFanIn(unknownFanIn)) fanIn.set(id, n);
  861. for (const fold of frontier) {
  862. // A call a synthesized channel already follows — the `fetch` that
  863. // reaches its own route, the `queue.add` its consumer picks up — is
  864. // the crossing, not also a call outside the index.
  865. const channelLines = new Set<number>();
  866. /** Per line, the last segment of each call a channel follows there (`add` of `emailQueue.add`). */
  867. const channelCalls = new Map<number, Set<string>>();
  868. for (const e of bySource.get(fold.node.id) ?? []) {
  869. const m = e.metadata as Record<string, unknown> | undefined;
  870. if (typeof m?.channel !== 'string' || typeof e.line !== 'number') continue;
  871. channelLines.add(e.line);
  872. if (typeof m.callee === 'string') {
  873. const set = channelCalls.get(e.line) ?? new Set<string>();
  874. set.add(m.callee.split(/[.:]/).pop() ?? m.callee);
  875. channelCalls.set(e.line, set);
  876. }
  877. }
  878. // Effects made by this node, folded or not. A value a handler is
  879. // written inside (`const authUser = asyncHandler(async (req, res) =>
  880. // …)`) made none itself — the arrow's calls belong to the file scope —
  881. // so it is lent the file's, within its lines, as its call edges are.
  882. if (effectScans < MAX_EFFECT_SCANS) {
  883. effectScans++;
  884. let refs: UnresolvedReference[] = [];
  885. try {
  886. refs = cg.getUnresolvedReferencesFrom(fold.node.id);
  887. if (refs.length === 0 && (fold.node.kind === 'constant' || fold.node.kind === 'variable')) refs = fileScopeRefsWithin(cg, fold.node, fileScopeUnresolved);
  888. } catch {
  889. refs = [];
  890. }
  891. for (const ref of [...refs].sort((a, b) => a.line - b.line || a.column - b.column)) {
  892. if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'instantiates') continue;
  893. if (channelLines.has(ref.line)) continue;
  894. await effectLink(step, fold, { referenceName: ref.referenceName, referenceKind: ref.referenceKind, line: ref.line, column: ref.column }, null);
  895. }
  896. }
  897. let edges = (bySource.get(fold.node.id) ?? []).slice();
  898. edges = edges.filter((e) => {
  899. const meta = (e.metadata ?? {}) as Record<string, unknown>;
  900. if (e.kind === 'references') return meta.fnRef === true;
  901. if (e.kind === 'contains') {
  902. // A function's nested handlers; and, when the walk STARTS at a
  903. // class (a ViewSet, a class-based view bound to a route), its
  904. // methods — never a class met on the way, whose methods are not
  905. // what the caller reached.
  906. const t = targets.get(e.target);
  907. const fromFunction = fold.node.kind === 'function' || fold.node.kind === 'method';
  908. const fromRootClass = fold.node.kind === 'class' && fold.chain.length === 0 && fold.node.id === step.root?.id;
  909. return (fromFunction || fromRootClass) && !!t && (t.kind === 'function' || t.kind === 'method');
  910. }
  911. return true;
  912. });
  913. edges.sort((a, b) => (a.line ?? 0) - (b.line ?? 0) || a.target.localeCompare(b.target));
  914. if (edges.length > MAX_FANOUT) {
  915. step.cut = 'fan-out';
  916. edges = edges.slice(0, MAX_FANOUT);
  917. }
  918. // Two passes: first every edge that arrives at a step, then the rest —
  919. // so a node that IS a step (a handler wired to a tap) is never also
  920. // folded as plumbing by the `contains` edge from the same component.
  921. interface Arrival {
  922. e: Edge;
  923. target: Node;
  924. meta: Record<string, unknown>;
  925. site: WireStepSite;
  926. kind: WireStepKind | null;
  927. linkKind: WireStepLinkKind;
  928. extra: Partial<WireStep>;
  929. trigger: WireStepTrigger | null;
  930. }
  931. const arrivals: Arrival[] = [];
  932. const fromTest = isTestPath(fold.node.filePath);
  933. for (const e of edges) {
  934. const found = targets.get(e.target);
  935. if (!found || found.kind === 'file') continue;
  936. const meta = (e.metadata ?? {}) as Record<string, unknown>;
  937. // A member call the index kept only the last segment of (`create`
  938. // for `prisma.user.create`) resolves by name alone — a guess, and
  939. // often the wrong one. The call AS WRITTEN decides first: an effect
  940. // is drawn as one and the guessed edge is not walked. A call through
  941. // a project-made value (`client.post` on the axios instance) is the
  942. // same case with the constant as the target.
  943. let target = targets.get(e.target)!;
  944. let retargeted = false;
  945. // `api.get('/users')` resolves to the `api` constant — and
  946. // `this.audioQueue.add('transcode')` to some `add` by name — AND,
  947. // on the same line, a channel follows the call: the channel is the story.
  948. if (typeof meta.channel !== 'string' && e.kind === 'calls' && channelLines.has(e.line ?? -1)) {
  949. const written = typeof meta.refName === 'string' ? meta.refName : target.name;
  950. const last = written.split(/[.:]/).pop() ?? written;
  951. if (target.kind === 'constant' || target.kind === 'variable' || channelCalls.get(e.line!)?.has(last)) continue;
  952. }
  953. if (e.kind === 'calls' && typeof meta.synthesizedBy !== 'string' && e.provenance !== 'heuristic') {
  954. const refName = typeof meta.refName === 'string' ? meta.refName : target.name;
  955. const bare = !refName.includes('.');
  956. // A member call whose receiver is declared as a type the index
  957. // holds no class for (`DataStore<UserPreferences>`) leaves the
  958. // index too, whatever name-matched — an `updateData` on a test
  959. // double, say.
  960. let external = false;
  961. if (!bare && target.kind !== 'constant' && target.kind !== 'variable') {
  962. const declared = await receiverTypeFor(fold.node, refName);
  963. external = !!declared && (await classOfType(declared)) === null;
  964. }
  965. if (bare || external || target.kind === 'constant' || target.kind === 'variable') {
  966. const drawn = await effectLink(step, fold, { referenceName: refName, referenceKind: 'calls', line: e.line ?? fold.node.startLine, column: e.column }, null, null, true);
  967. if (drawn) continue;
  968. // Not an effect: does the receiver's declared type say where the
  969. // call goes? A class in the index wins over the name-only guess.
  970. if (bare) {
  971. const written = await callAt(fold.node, { line: e.line, column: e.column, callee: refName });
  972. if (written && /[.:]/.test(written.callee) && (written.callee.split(/[.:]/).pop() ?? '') === refName) {
  973. const real = await resolveByReceiver(fold.node, written.callee);
  974. if (real && real.id !== target.id) {
  975. target = real;
  976. retargeted = true;
  977. }
  978. }
  979. }
  980. }
  981. }
  982. if (target.id === fold.node.id) continue;
  983. // A production walk never enters a test double: an interface's
  984. // dispatch into `TestUserDataRepository`, or a `DataStore` name-matched
  985. // to the in-memory one, is the test suite's story. Judged after the
  986. // call as written had its chance to be an effect.
  987. if (!fromTest && isTestPath(target.filePath)) continue;
  988. const site: WireStepSite = {
  989. file: posix(fold.node.filePath),
  990. line: e.line ?? fold.node.startLine,
  991. text: siteText(e, meta, target),
  992. when: '',
  993. };
  994. // What fires this hop, when the site is written under an event:
  995. // the JSX prop, the `on*` option, the runs-later call. Read for
  996. // every call-shaped hop, so a store action or an effect fired by
  997. // a tap says so on its link too.
  998. const isCall = e.kind === 'calls' || e.kind === 'instantiates' || (e.kind === 'references' && meta.fnRef === true);
  999. const trigger = isCall ? await triggerAt(fold.node, { line: e.line, column: e.column }) : null;
  1000. // A server action, by its directive: a function in a `'use server'`
  1001. // file (or opening with the directive) called from a file that is
  1002. // not — the call crosses to the server, whatever the import says.
  1003. if (
  1004. e.provenance !== 'heuristic' &&
  1005. (e.kind === 'calls' || (e.kind === 'references' && meta.fnRef === true)) &&
  1006. (target.kind === 'function' || target.kind === 'method') &&
  1007. JS_FAMILY.has(target.language) &&
  1008. JS_FAMILY.has(fold.node.language)
  1009. ) {
  1010. const callee = await calls.directive(target);
  1011. if ((callee.file === 'server' || callee.own) && (await calls.directive(fold.node)).file !== 'server') {
  1012. meta.tier = 'client→server';
  1013. meta.channel = 'server-action';
  1014. }
  1015. }
  1016. // What kind of step, if any, this edge arrives at.
  1017. let kind: WireStepKind | null = null;
  1018. let linkKind: WireStepLinkKind = 'calls';
  1019. const extra: Partial<WireStep> = {};
  1020. if (target.kind === 'route' && meta.tier !== 'client→server') {
  1021. kind = 'screen';
  1022. linkKind = 'navigates';
  1023. } else {
  1024. // A language change under the code is a step only on evidence: a
  1025. // bridge resolver's edge (`bridge`, or a framework resolution), or
  1026. // a synthesized channel's. A plain name-matched call across the
  1027. // families (`arr.flat()` landing on a Swift `flat`) is noise, and
  1028. // is neither drawn nor walked.
  1029. const cross = crossing(fold.node.language, target.language, meta);
  1030. const evidenced =
  1031. e.provenance === 'heuristic' || meta.bridge === 'react-native' || meta.resolvedBy === 'framework' || meta.channel === 'server-action';
  1032. if (cross !== null && !evidenced) continue;
  1033. if (cross === 'event') {
  1034. kind = 'event';
  1035. linkKind = 'event';
  1036. if (typeof meta.event === 'string') extra.event = meta.event;
  1037. } else if (cross === 'bridge') {
  1038. kind = 'bridge';
  1039. linkKind = 'bridge';
  1040. } else if (
  1041. (target.kind === 'function' || target.kind === 'method') &&
  1042. isStoreFile(target.filePath) &&
  1043. !isStoreFile(fold.node.filePath)
  1044. ) {
  1045. // A store action fired straight from a tap stays a store
  1046. // action; the tap is on its link.
  1047. kind = 'store';
  1048. linkKind = 'store';
  1049. } else if (
  1050. (target.kind === 'function' || target.kind === 'method') &&
  1051. !looksLikeComponent(target) &&
  1052. ((e.kind === 'references' && meta.fnRef === true) || trigger !== null)
  1053. ) {
  1054. // A handler: a function passed as a value (`onPress={handleX}`,
  1055. // `addListener('x', handleX)`), or one called from under an
  1056. // event binding (`onPress={() => handleLogin(values)}`,
  1057. // `useFormik({ onSubmit: (v) => handleLogin(v) })`). A
  1058. // component passed as a value (`memo(CaptureComponent)`) is a
  1059. // render hop and folds like one.
  1060. kind = 'trigger';
  1061. linkKind = 'handler';
  1062. if (trigger) extra.trigger = trigger;
  1063. }
  1064. }
  1065. if (retargeted) meta.resolvedBy = 'receiver-type';
  1066. arrivals.push({ e, target, meta, site, kind, linkKind, extra, trigger });
  1067. }
  1068. for (const a of arrivals) {
  1069. if (a.kind === null) continue;
  1070. const fresh = !steps.has(a.target.id);
  1071. const to = stepFor(a.target, a.kind, step.depth + 1, a.extra);
  1072. if (to === null) continue;
  1073. if (fresh && !to.trigger) {
  1074. const t = a.target.kind === 'route' ? await requestTrigger(a.target, to.root) : await consumerTrigger(a.target);
  1075. if (t) to.trigger = t;
  1076. }
  1077. const at = { line: a.e.line, column: a.e.column };
  1078. const guards = await guardsAt(fold.node, at);
  1079. const when = guardLabel(guards);
  1080. // A call-shaped hop says what it passes; a navigation already says
  1081. // its href, a handler binding and a native event channel pass
  1082. // nothing. A hop over a synthesized channel is a call in the source
  1083. // — `fetch('/api/users', {…})`, `emailQueue.add('welcome', {…})` —
  1084. // and its site reads as written.
  1085. let site = a.site;
  1086. if (typeof a.meta.channel === 'string' && a.meta.channel !== 'server-action') {
  1087. const written = await callAt(fold.node, at);
  1088. site = written && written.callee ? { ...a.site, text: written.callee, args: written.args } : await withArgs(a.site, fold.node, at);
  1089. } else if (a.linkKind === 'bridge' || a.linkKind === 'store' || a.linkKind === 'calls') site = await withArgs(a.site, fold.node, at);
  1090. // Where this step is first reached from: the hop out of the root
  1091. // this fold descends from, else this site — its position orders the row.
  1092. const isCallHop = a.e.kind === 'calls' || a.e.kind === 'instantiates' || a.e.kind === 'navigates';
  1093. const local = isCallHop ? await hopAt(fold.node, at, a.target.name) : pointHop(fold.node, at);
  1094. const hop = fold.first ?? local;
  1095. if (!to.first) to.first = hop;
  1096. const id = link(step, to, a.linkKind, fold.chain, [...fold.whens, when], site, a.e, a.trigger, hop.within);
  1097. record(fold.node, local, guards, { step: to.id, link: id }, a.trigger, await loopsAt(fold.node, at));
  1098. if (to.root !== null && !explored.has(to.id)) {
  1099. explored.add(to.id);
  1100. queue.push(to);
  1101. }
  1102. }
  1103. for (const a of arrivals) {
  1104. if (a.kind !== null) continue;
  1105. const { e, target, meta } = a;
  1106. // A call through a VALUE the effect table knows — `client.post` on
  1107. // the axios instance the project made itself resolves to the
  1108. // `client` constant, not to anything outside the index. The call
  1109. // text is the evidence: the call is the effect, the constant is not
  1110. // a place to walk into.
  1111. // A thrown exception the framework answers with (`throw new
  1112. // NotFoundException(…)` on a class the project defines) is a
  1113. // response, not a place to walk into; a repository's method the
  1114. // walk cannot enter (an interface's, the ORM's) is the database.
  1115. if (e.kind === 'instantiates' && target.kind === 'class') {
  1116. if (await effectLink(step, fold, { referenceName: target.name, referenceKind: 'instantiates', line: e.line ?? fold.node.startLine, column: e.column }, a.trigger)) continue;
  1117. }
  1118. if (e.kind === 'calls' && repositoryMethod(target)) {
  1119. const container = target.qualifiedName.replace(/[.:]+[^.:]*$/, '').split(/[.:]+/).pop() ?? '';
  1120. const api = typeof meta.refName === 'string' && meta.refName.includes('.') ? meta.refName : `${container}.${target.name}`;
  1121. if (await effectLink(step, fold, { referenceName: api, referenceKind: 'calls', line: e.line ?? fold.node.startLine, column: e.column }, a.trigger)) continue;
  1122. }
  1123. // Already a step, reached here by a plain call: a link, not a fold.
  1124. const known = steps.get(target.id);
  1125. if (known) {
  1126. if (known.id !== step.id) {
  1127. const at = { line: e.line, column: e.column };
  1128. const guards = await guardsAt(fold.node, at);
  1129. const local = await hopAt(fold.node, at, target.name);
  1130. const hop = fold.first ?? local;
  1131. const id = link(step, known, 'calls', fold.chain, [...fold.whens, guardLabel(guards)], await withArgs(a.site, fold.node, at), e, a.trigger, hop.within);
  1132. record(fold.node, local, guards, { step: known.id, link: id }, a.trigger, await loopsAt(fold.node, at));
  1133. }
  1134. continue;
  1135. }
  1136. // Plumbing: fold it and keep walking, unless it is a dead end.
  1137. if (visited.has(target.id)) continue;
  1138. if ((fanIn.get(target.id) ?? 0) >= HUB_THRESHOLD) {
  1139. truncated.hubs++;
  1140. continue;
  1141. }
  1142. if (meta.synthesizedBy === 'jsx-render' && isSharedChrome(cg, target, chromeParents)) {
  1143. truncated.chrome++;
  1144. continue;
  1145. }
  1146. if (visited.size >= MAX_FOLDED_PER_STEP) {
  1147. step.cut = step.cut ?? 'folded';
  1148. continue;
  1149. }
  1150. visited.add(target.id);
  1151. const at = { line: e.line, column: e.column };
  1152. const guards = await guardsAt(fold.node, at);
  1153. const local =
  1154. e.kind === 'calls' || e.kind === 'instantiates' ? await hopAt(fold.node, at, target.name) : pointHop(fold.node, at);
  1155. const first = fold.first ?? local;
  1156. // The helper is drawn where it is CALLED: its own records are its
  1157. // body, and this is the site the rail nests them under.
  1158. record(fold.node, local, guards, { into: target.id }, a.trigger, await loopsAt(fold.node, at));
  1159. next.push({ node: target, chain: [...fold.chain, target], whens: [...fold.whens, guardLabel(guards)], first });
  1160. }
  1161. }
  1162. frontier = next;
  1163. }
  1164. }
  1165. // An effect box with ONE call behind it says what that call passes —
  1166. // `axios.post('/auth/login', { email, password })` is the fact a reader
  1167. // scans for; several calls list themselves in the panel instead.
  1168. const sitesByStep = new Map<string, WireStepSite[]>();
  1169. for (const l of links.values()) {
  1170. const list = sitesByStep.get(l.to) ?? [];
  1171. list.push(...l.sites);
  1172. sitesByStep.set(l.to, list);
  1173. }
  1174. for (const step of steps.values()) {
  1175. if (step.kind !== 'effect' || !step.effect) continue;
  1176. const sites = sitesByStep.get(step.id) ?? [];
  1177. // A response box is one outcome of the endpoint's contract: its status,
  1178. // when literal, is its label (one per box by construction); the rows say
  1179. // when. The box of unreadable statuses holds none and is labelled by its
  1180. // call below.
  1181. if (step.effect.category === 'response') {
  1182. const statuses = [...new Set(sites.map((s) => s.status).filter((x): x is number => typeof x === 'number'))].sort((a, b) => a - b);
  1183. if (statuses.length > 0) {
  1184. step.effect.statuses = statuses;
  1185. step.label = statuses.join(' · ');
  1186. continue;
  1187. }
  1188. }
  1189. if (step.effect.apis.length !== 1) continue;
  1190. if (sites.length !== 1 || sites[0]!.args === undefined) continue;
  1191. const label = `${step.effect.api}(${sites[0]!.args})`;
  1192. step.label = label.length > MAX_EFFECT_LABEL ? `${label.slice(0, MAX_EFFECT_LABEL - 2)}…)` : label;
  1193. }
  1194. // A row reads in the code's order: by the position of the hop that first
  1195. // reached each step, a hop written inside another site's arguments before
  1196. // that site — `generateToken(…)` in `res.json({ token: generateToken(…) })`
  1197. // signs the token before the 200 is sent, so it comes first.
  1198. const byDepth = new Map<number, StepRecord[]>();
  1199. for (const s of steps.values()) byDepth.set(s.depth, [...(byDepth.get(s.depth) ?? []), s]);
  1200. for (const row of byDepth.values()) {
  1201. row.sort((a, b) => hopCompare(a.first, b.first) || a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
  1202. row.forEach((s, i) => {
  1203. s.order = i;
  1204. });
  1205. }
  1206. const ordered = [...steps.values()].sort((a, b) => a.depth - b.depth || (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id));
  1207. // The second reading: the same steps in the code's order. A step the walk
  1208. // ENTERED reads on into its own body; a boundary (another screen, an
  1209. // endpoint across a tier) does not — it is a chapter of its own, exactly as
  1210. // on the picture.
  1211. const nodesById = new Map<string, Node>();
  1212. for (const s of steps.values()) if (s.root) nodesById.set(s.root.id, s.root);
  1213. const program = buildProgram({
  1214. sites: new Map([...programs].map(([fn, sites]) => [fn, [...sites.values()]])),
  1215. root: first.root?.id ?? null,
  1216. node: (id) => {
  1217. const found = nodesById.get(id) ?? cg.getNode(id);
  1218. return found ? toNodeRef(found) : null;
  1219. },
  1220. step: (id) => {
  1221. const s = steps.get(id);
  1222. if (!s) return null;
  1223. return { reply: s.effect?.category === 'response', into: s.cut === null && s.root ? s.root.id : null };
  1224. },
  1225. });
  1226. return {
  1227. anchor: toNodeRef(anchor),
  1228. ambiguous,
  1229. project,
  1230. steps: ordered.map(({ root: _root, first: _first, ...step }) => step),
  1231. links: [...links.values()].sort((a, b) => a.id.localeCompare(b.id)),
  1232. program,
  1233. // A screen is a set of handlers with no order between them; anything with a
  1234. // body — a handler, an endpoint, any function — reads in the code's order.
  1235. defaultView: program !== null && !(first.kind === 'screen' && !first.screen?.endpoint) ? 'order' : 'tree',
  1236. depth: depthCap,
  1237. limit,
  1238. through,
  1239. truncated,
  1240. index,
  1241. timing: { elapsedMs: Date.now() - started },
  1242. };
  1243. }
  1244. // =============================================================================
  1245. // Helpers
  1246. // =============================================================================
  1247. /**
  1248. * The anchor: `anchor=<id>`, or `symbol=<name>` resolved to the most
  1249. * screen-like symbol of that name — a route first, then a component or
  1250. * function, then a method — with the rest reported as `ambiguous`.
  1251. */
  1252. function resolveAnchor(cg: CodeGraph, query: URLSearchParams): { anchor: Node; ambiguous: WireNodeRef[] } {
  1253. const id = query.get('anchor');
  1254. if (id !== null && id.trim() !== '') {
  1255. const node = cg.getNode(id);
  1256. if (!node) throw notFound(`No symbol with id "${id}" in this index.`, 'It may have moved in a re-index; open it from search or the Screens view.');
  1257. return { anchor: node, ambiguous: [] };
  1258. }
  1259. const name = query.get('symbol');
  1260. if (name === null || name.trim() === '') throw badRequest('Give the picture an anchor: ?anchor=<node id> or ?symbol=<name>.');
  1261. const rank: Record<string, number> = { route: 0, component: 1, function: 2, method: 3, class: 4, constant: 5, variable: 6 };
  1262. const matches = cg
  1263. .getNodesByName(name.trim())
  1264. .filter((n) => n.kind !== 'file' && n.kind !== 'import' && n.kind !== 'export')
  1265. .sort((a, b) => (rank[a.kind] ?? 9) - (rank[b.kind] ?? 9) || a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine);
  1266. const anchor = matches[0];
  1267. if (!anchor) throw notFound(`Nothing in this index is named "${name}".`, 'Try the search box; names are matched exactly.');
  1268. return { anchor, ambiguous: matches.slice(1, 9).map(toNodeRef) };
  1269. }
  1270. /** How many distinct parents render this node as a JSX child. Memoised per request. */
  1271. function renderParents(cg: CodeGraph, node: Node, memo: Map<string, number>): number {
  1272. let parents = memo.get(node.id);
  1273. if (parents === undefined) {
  1274. const incoming = cg.getIncomingEdgesTo([node.id], ['calls']);
  1275. const sources = new Set<string>();
  1276. for (const e of incoming) {
  1277. if ((e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'jsx-render') sources.add(e.source);
  1278. }
  1279. parents = sources.size;
  1280. memo.set(node.id, parents);
  1281. }
  1282. return parents;
  1283. }
  1284. /** A component rendered by several distinct parents is chrome. */
  1285. function isSharedChrome(cg: CodeGraph, component: Node, memo: Map<string, number>): boolean {
  1286. return renderParents(cg, component, memo) >= SHARED_CHROME_MIN;
  1287. }
  1288. /**
  1289. * What kind of project the picture is of, by what its routes are: endpoints
  1290. * (`POST /users`) make an API; screens with navigation between them make an
  1291. * app; both — pages and the endpoints behind them — make a web app.
  1292. */
  1293. export function projectKind(routes: readonly Node[], navigates: number): 'app' | 'api' | 'web' {
  1294. let endpoints = 0;
  1295. let pages = 0;
  1296. for (const r of routes) {
  1297. if (splitRouteName(r.name).method !== null) endpoints++;
  1298. else if (r.name.startsWith('/')) {
  1299. pages++;
  1300. // A Next page is a web page whatever else the index holds.
  1301. if (nextRouteForFile(r.filePath)?.kind === 'page') return 'web';
  1302. }
  1303. }
  1304. if (endpoints === 0) return 'app';
  1305. return navigates > 0 || pages > 0 ? 'web' : 'api';
  1306. }
  1307. function basename(p: string): string {
  1308. const s = posix(p);
  1309. return s.slice(s.lastIndexOf('/') + 1);
  1310. }
  1311. /**
  1312. * Function-as-value references — and, for a value, calls — made at a file's
  1313. * top level within a node's lines: what `const Memoized = memo(CaptureComponent)`
  1314. * leaves behind (the reference belongs to the file scope, the wrapper node
  1315. * spans the line), and what `const worker = new Worker('q', async (job) =>
  1316. * { … })` leaves behind (the handler's calls belong to the file scope, the
  1317. * constant spans them).
  1318. */
  1319. function fileScopeEdgesWithin(cg: CodeGraph, node: Node, memo: Map<string, Edge[]>, calls: boolean): Edge[] {
  1320. let refs = memo.get(node.filePath);
  1321. if (refs === undefined) {
  1322. const file = cg.getNodesInFile(node.filePath).find((n) => n.kind === 'file');
  1323. refs = file
  1324. ? cg
  1325. .getOutgoingEdgesFrom([file.id], ['references', 'calls', 'navigates'])
  1326. .filter((e) => e.kind !== 'references' || (e.metadata as Record<string, unknown> | undefined)?.fnRef === true)
  1327. : [];
  1328. memo.set(node.filePath, refs);
  1329. }
  1330. return refs.filter((e) => (calls || e.kind === 'references') && typeof e.line === 'number' && e.line >= node.startLine && e.line <= node.endLine);
  1331. }
  1332. /** The file scope's unresolved calls within a value's lines — what a wrapped handler's arrow body leaves on the file node. */
  1333. function fileScopeRefsWithin(cg: CodeGraph, node: Node, memo: Map<string, UnresolvedReference[]>): UnresolvedReference[] {
  1334. let refs = memo.get(node.filePath);
  1335. if (refs === undefined) {
  1336. const file = cg.getNodesInFile(node.filePath).find((n) => n.kind === 'file');
  1337. try {
  1338. refs = file ? cg.getUnresolvedReferencesFrom(file.id) : [];
  1339. } catch {
  1340. refs = [];
  1341. }
  1342. memo.set(node.filePath, refs);
  1343. }
  1344. return refs.filter((r) => r.line >= node.startLine && r.line <= node.endLine);
  1345. }
  1346. /** `push /capture`, `renders <Button>`, `via rn-event-channel`, `calls`. */
  1347. function siteText(edge: Edge, meta: Record<string, unknown>, target: Node): string {
  1348. if (edge.kind === 'navigates') {
  1349. const method = edge.provenance === 'heuristic' ? 'returns' : typeof meta.navMethod === 'string' ? meta.navMethod : 'push';
  1350. return `${method} ${typeof meta.href === 'string' ? meta.href : target.name}`;
  1351. }
  1352. if (meta.synthesizedBy === 'jsx-render') return `renders <${target.name}>`;
  1353. if (edge.kind === 'references') return `passes ${target.name}`;
  1354. if (edge.kind === 'contains') return `defines ${target.name}`;
  1355. if (edge.kind === 'instantiates') return `new ${target.name}`;
  1356. if (meta.bridge === 'react-native') return `bridge ${typeof meta.module === 'string' ? meta.module + '.' : ''}${target.name}`;
  1357. if (meta.channel === 'http') return `${typeof meta.method === 'string' ? meta.method : 'GET'} ${typeof meta.href === 'string' ? meta.href : target.name}`;
  1358. if (typeof meta.synthesizedBy === 'string') return `via ${meta.synthesizedBy}`;
  1359. return `calls ${target.name}`;
  1360. }
  1361. /** The words on a hop that was not a plain call — the Flow strip's connector label, in short. */
  1362. function hopLabel(meta: Record<string, unknown>, synthesized: boolean): string {
  1363. const parts: string[] = [];
  1364. if (typeof meta.synthesizedBy === 'string') parts.push(`via ${meta.synthesizedBy}`);
  1365. else if (synthesized) parts.push('inferred');
  1366. if (meta.channel === 'server-action') parts.push('server action');
  1367. if (meta.channel === 'http' && typeof meta.method === 'string') parts.push(`${meta.method} ${typeof meta.href === 'string' ? meta.href : ''}`.trim());
  1368. if (meta.tier === 'client→server') parts.push('to the server');
  1369. else if (meta.tier === 'server→client') parts.push('from the server');
  1370. if (meta.resolvedBy === 'receiver-type') parts.push('by the receiver’s declared type');
  1371. if (typeof meta.event === 'string') parts.push(`${meta.channel === 'queue' ? 'job' : meta.channel === 'socket' ? 'message' : 'event'} ${meta.event}`);
  1372. if (typeof meta.queue === 'string') parts.push(`queue ${meta.queue}`);
  1373. if (meta.bridge === 'react-native') parts.push(`React Native bridge${typeof meta.module === 'string' ? ` · ${meta.module}` : ''}`);
  1374. if (typeof meta.registeredAt === 'string') parts.push(`registered at ${meta.registeredAt}`);
  1375. return parts.join(' · ');
  1376. }
  1377. /** Source order of two hops: a hop written inside the other's call runs first; else by position; another file sorts after. */
  1378. function hopCompare(a: HopSite | undefined, b: HopSite | undefined): number {
  1379. if (!a || !b) return a ? -1 : b ? 1 : 0;
  1380. if (a.file !== b.file) return a.file.localeCompare(b.file);
  1381. if (hopInside(a, b)) return -1;
  1382. if (hopInside(b, a)) return 1;
  1383. return a.line - b.line || a.column - b.column;
  1384. }
  1385. /** `x` starts strictly after `y` starts and before `y` ends. */
  1386. function hopInside(x: HopSite, y: HopSite): boolean {
  1387. const afterStart = x.line > y.line || (x.line === y.line && x.column > y.column);
  1388. const beforeEnd = x.line < y.end.line || (x.line === y.end.line && x.column < y.end.column);
  1389. return afterStart && beforeEnd;
  1390. }
  1391. function posix(p: string): string {
  1392. return p.replace(/\\/g, '/');
  1393. }