trails.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. /**
  2. * `GET/POST/DELETE /api/trails` — saved trails, the reader's own tours through
  3. * the graph (design spec §3.12).
  4. *
  5. * A trail is the path of symbols someone walked to explain something: "how a
  6. * request is served", "everything the token expiry touches". The viewer already
  7. * carries one in the URL; this is the same walk given a name and kept, so the
  8. * next person — or the same person next week — starts at the explanation rather
  9. * than at the search box.
  10. *
  11. * ## The one thing this feature has to get right
  12. *
  13. * **A trail must survive a re-index.** A node's id contains its start line, so
  14. * inserting an import at the top of a file renames every symbol below it. A
  15. * trail keyed on ids would break the first time anybody edited the code it
  16. * describes — which is exactly when it matters. So a hop is stored as what it
  17. * *is* — qualified name, kind, file — with the id kept only as a fast path, and
  18. * every hop is re-resolved against the current index on the way out:
  19. *
  20. * - the recorded id still names the same symbol → `ok`
  21. * - the qualified name resolves somewhere else → `moved`, and the row says
  22. * where from
  23. * - the name is now carried by several symbols and none is in the recorded
  24. * file → `ambiguous`, best guess offered and labelled as one
  25. * - nothing answers to it → `missing`, and the row says "moved or renamed"
  26. *
  27. * Nothing is silently dropped and nothing is silently guessed: a trail that has
  28. * decayed says so on its own row, which is the point at which its author can
  29. * fix it.
  30. *
  31. * ## What it opens
  32. *
  33. * A trail with a hole in it cannot be handed to the viewer whole — the `t`
  34. * param is a PATH, and stitching hop 2 to hop 4 would draw an adjacency that
  35. * does not exist. So the payload carries the longest run of consecutive
  36. * resolved hops, and the row says when that is less than the whole trail.
  37. *
  38. * Storage — the only write `codegraph ui` makes — is `./trail-store.ts`.
  39. */
  40. import { execFileSync } from 'child_process';
  41. import * as os from 'os';
  42. import type { CodeGraph } from '../../index';
  43. import type { Node } from '../../types';
  44. import { ApiError, badRequest, notFound } from './respond';
  45. import {
  46. MAX_TRAIL_HOPS,
  47. MAX_TRAIL_NAME,
  48. MAX_TRAIL_NOTE,
  49. MAX_TRAILS,
  50. TRAILS_RELATIVE_DIR,
  51. TRAIL_FORMAT_VERSION,
  52. deleteStoredTrail,
  53. listStoredTrails,
  54. slugify,
  55. uniqueTrailId,
  56. writeStoredTrail,
  57. type StoredHop,
  58. type StoredHopDirection,
  59. type StoredTrail,
  60. } from './trail-store';
  61. import { toNodeRef } from './wire';
  62. /* ------------------------------------------------------------------ wire -- */
  63. /** How a saved hop fared against the current index. */
  64. export type WireTrailHopStatus = 'ok' | 'moved' | 'ambiguous' | 'missing';
  65. export interface WireTrailHop {
  66. dir: StoredHopDirection;
  67. /** The name as it was when the trail was saved. */
  68. name: string;
  69. qualifiedName: string;
  70. kind: string;
  71. /** Where the symbol was when the trail was saved. */
  72. savedFile: string;
  73. savedLine: number;
  74. status: WireTrailHopStatus;
  75. /** The symbol's id NOW. Null when nothing answers to it any more. */
  76. id: string | null;
  77. file: string | null;
  78. line: number | null;
  79. /** Finished screen wording for a status that is not `ok`; null when it is. */
  80. note: string | null;
  81. }
  82. export interface WireTrail {
  83. id: string;
  84. name: string;
  85. note: string;
  86. author: string;
  87. createdAt: string;
  88. updatedAt: string;
  89. hops: WireTrailHop[];
  90. /** Hops that still resolve to a symbol in this index. */
  91. resolved: number;
  92. /** Every hop resolved, and none of them moved. */
  93. intact: boolean;
  94. /**
  95. * The longest run of CONSECUTIVE resolved hops, encoded as the `t` param.
  96. * Null when nothing in the trail resolves. Never stitched across a hole: the
  97. * trail is a path, and a fabricated adjacency is worse than a short one.
  98. */
  99. encoded: string | null;
  100. /** 1-based index of the first hop `encoded` carries. */
  101. openFrom: number;
  102. /** How many hops `encoded` carries. */
  103. openCount: number;
  104. /** The symbol the trail opens at — the last hop of that run. */
  105. openId: string | null;
  106. }
  107. export interface WireTrails {
  108. trails: WireTrail[];
  109. /** Writes are off. The viewer hides Save and Delete, and says why. */
  110. readOnly: boolean;
  111. readOnlyReason: string | null;
  112. /** Project-relative directory the files live in. The screen names it. */
  113. directory: string;
  114. /** Files in that directory that were not readable trails. */
  115. skipped: number;
  116. /** The list stopped at {@link MAX_TRAILS}. */
  117. bounded: boolean;
  118. /** The id just written, on the answer to a POST. */
  119. saved?: string;
  120. /** That POST replaced a trail of the same name. */
  121. replaced?: boolean;
  122. /** The id just removed, on the answer to a DELETE. */
  123. deleted?: string;
  124. }
  125. /* -------------------------------------------------------------- resolution -- */
  126. /**
  127. * Re-resolve one saved hop against the index as it is now.
  128. *
  129. * Order matters: the recorded id first, because in the common case (nothing
  130. * above the symbol changed) it is one lookup and exactly right. It is still
  131. * verified against the qualified name — an id is a hash of position as well as
  132. * identity, and a recycled one pointing at a different symbol would put a
  133. * stranger in the middle of somebody's explanation.
  134. */
  135. export function resolveHop(cg: CodeGraph, hop: StoredHop): WireTrailHop {
  136. const base = {
  137. dir: hop.dir,
  138. name: hop.name,
  139. qualifiedName: hop.qualifiedName,
  140. kind: hop.kind,
  141. savedFile: hop.file,
  142. savedLine: hop.line,
  143. };
  144. const byId = hop.id ? cg.getNode(hop.id) : null;
  145. if (byId && matches(byId, hop)) {
  146. return { ...base, status: 'ok', id: byId.id, file: byId.filePath, line: byId.startLine, note: null };
  147. }
  148. const candidates = cg
  149. .getNodesByQualifiedName(hop.qualifiedName)
  150. .filter((node) => hop.kind === '' || node.kind === hop.kind);
  151. if (candidates.length === 0) {
  152. return {
  153. ...base,
  154. status: 'missing',
  155. id: null,
  156. file: null,
  157. line: null,
  158. note: `no longer in the index — moved or renamed since this trail was saved`,
  159. };
  160. }
  161. const sameFile = candidates.filter((node) => node.filePath === hop.file);
  162. if (sameFile.length === 1) {
  163. const node = sameFile[0] as Node;
  164. return { ...base, status: 'ok', id: node.id, file: node.filePath, line: node.startLine, note: null };
  165. }
  166. if (candidates.length === 1) {
  167. const node = candidates[0] as Node;
  168. return {
  169. ...base,
  170. status: 'moved',
  171. id: node.id,
  172. file: node.filePath,
  173. line: node.startLine,
  174. note: `moved from ${hop.file || 'an unrecorded file'} to ${node.filePath}`,
  175. };
  176. }
  177. // Several symbols carry this name and none of them is where it used to be.
  178. // The best guess is offered — a row nobody can open is not more honest, it
  179. // is just less useful — but it is labelled as a guess.
  180. const pick = (sameFile[0] ?? candidates[0]) as Node;
  181. return {
  182. ...base,
  183. status: 'ambiguous',
  184. id: pick.id,
  185. file: pick.filePath,
  186. line: pick.startLine,
  187. note: `${candidates.length} symbols now carry this name — showing the one in ${pick.filePath}`,
  188. };
  189. }
  190. function matches(node: Node, hop: StoredHop): boolean {
  191. if (hop.kind !== '' && node.kind !== hop.kind) return false;
  192. return node.qualifiedName === hop.qualifiedName || node.name === hop.name;
  193. }
  194. /** The `t` param's own encoding — kept identical to `ui/src/lib/trail-codec.ts`. */
  195. const DIR_CHAR: Record<StoredHopDirection, string> = { start: 's', down: 'd', up: 'u' };
  196. /**
  197. * Turn resolved hops into something the viewer can open.
  198. *
  199. * The longest CONSECUTIVE run, not every resolved hop: skipping a missing hop
  200. * would encode a step from A to C that no edge supports, and the Flow strip
  201. * reads a trail as exactly that sequence of edges. The first hop of the run is
  202. * always written as `start`, because a run beginning mid-trail arrived from
  203. * nothing the viewer can draw.
  204. */
  205. export function encodeResolvedRun(hops: readonly WireTrailHop[]): {
  206. encoded: string | null;
  207. openFrom: number;
  208. openCount: number;
  209. openId: string | null;
  210. } {
  211. let bestStart = -1;
  212. let bestLength = 0;
  213. let start = -1;
  214. for (let i = 0; i <= hops.length; i += 1) {
  215. const resolved = i < hops.length && (hops[i] as WireTrailHop).id !== null;
  216. if (resolved) {
  217. if (start < 0) start = i;
  218. continue;
  219. }
  220. if (start >= 0 && i - start > bestLength) {
  221. bestStart = start;
  222. bestLength = i - start;
  223. }
  224. start = -1;
  225. }
  226. if (bestLength === 0) return { encoded: null, openFrom: 0, openCount: 0, openId: null };
  227. const run = hops.slice(bestStart, bestStart + bestLength);
  228. const encoded = run
  229. .map((hop, index) => `${index === 0 ? 's' : DIR_CHAR[hop.dir]}${encodeURIComponent(hop.id as string)}`)
  230. .join(',');
  231. return {
  232. encoded,
  233. openFrom: bestStart + 1,
  234. openCount: bestLength,
  235. openId: (run[run.length - 1] as WireTrailHop).id,
  236. };
  237. }
  238. export function resolveTrail(cg: CodeGraph, stored: StoredTrail): WireTrail {
  239. const hops = stored.hops.map((hop) => resolveHop(cg, hop));
  240. const run = encodeResolvedRun(hops);
  241. return {
  242. id: stored.id,
  243. name: stored.name,
  244. note: stored.note,
  245. author: stored.author,
  246. createdAt: stored.createdAt,
  247. updatedAt: stored.updatedAt,
  248. hops,
  249. resolved: hops.filter((hop) => hop.id !== null).length,
  250. intact: hops.every((hop) => hop.status === 'ok'),
  251. ...run,
  252. };
  253. }
  254. /* ------------------------------------------------------------------ read -- */
  255. export interface TrailsOptions {
  256. /** Writes refused, and the sentence saying why. */
  257. readOnly: boolean;
  258. readOnlyReason: string | null;
  259. }
  260. export function buildTrails(
  261. cg: CodeGraph,
  262. projectRoot: string,
  263. options: TrailsOptions
  264. ): WireTrails {
  265. const { trails, skipped } = listStoredTrails(projectRoot);
  266. return {
  267. trails: trails.map((stored) => resolveTrail(cg, stored)),
  268. readOnly: options.readOnly,
  269. readOnlyReason: options.readOnlyReason,
  270. directory: TRAILS_RELATIVE_DIR,
  271. skipped,
  272. bounded: trails.length >= MAX_TRAILS,
  273. };
  274. }
  275. /* ----------------------------------------------------------------- write -- */
  276. /** What a POST body has to be. Everything else about a hop comes from the graph. */
  277. export interface SaveTrailRequest {
  278. name: string;
  279. note?: string;
  280. hops: Array<{ dir?: string; id: string }>;
  281. }
  282. /**
  283. * Save a trail.
  284. *
  285. * The client sends ids and directions and nothing else: the name, kind, file
  286. * and line of every hop are read out of the index here. A client that supplied
  287. * its own metadata could save a trail describing symbols that are not in the
  288. * graph, and the whole value of the feature is that a trail is a claim the
  289. * index can re-check.
  290. *
  291. * A save under a name that already exists REPLACES that trail, keeping its
  292. * `createdAt`. That is what pressing Save with the same name means, and the
  293. * answer says `replaced` so the screen can too.
  294. */
  295. export function saveTrail(
  296. cg: CodeGraph,
  297. projectRoot: string,
  298. body: unknown,
  299. options: TrailsOptions
  300. ): WireTrails {
  301. if (options.readOnly) throw readOnlyRefusal(options.readOnlyReason);
  302. const request = parseSaveRequest(body);
  303. const hops: StoredHop[] = [];
  304. request.hops.forEach((hop, index) => {
  305. const node = cg.getNode(hop.id);
  306. if (!node) {
  307. throw badRequest(
  308. `Hop ${index + 1} is not in the index: ${hop.id}`,
  309. 'Trails are saved from symbols the index holds. Reload the page and walk the trail again.'
  310. );
  311. }
  312. const ref = toNodeRef(node);
  313. hops.push({
  314. dir: hop.dir === 'up' || hop.dir === 'down' ? hop.dir : 'start',
  315. name: ref.name,
  316. qualifiedName: ref.qualifiedName,
  317. kind: ref.kind,
  318. file: ref.file,
  319. line: ref.line,
  320. id: ref.id,
  321. });
  322. });
  323. // The first hop is where the walk began, whatever the client called it.
  324. if (hops[0]) hops[0].dir = 'start';
  325. const existing = listStoredTrails(projectRoot).trails;
  326. const sameName = existing.find((trail) => trail.name === request.name);
  327. const takenByOthers = new Set(
  328. existing.filter((trail) => trail.name !== request.name).map((trail) => trail.id)
  329. );
  330. const id = sameName ? sameName.id : uniqueTrailId(slugify(request.name), takenByOthers);
  331. const now = new Date().toISOString();
  332. writeStoredTrail(projectRoot, {
  333. version: TRAIL_FORMAT_VERSION,
  334. id,
  335. name: request.name,
  336. note: request.note,
  337. author: trailAuthor(projectRoot),
  338. createdAt: sameName?.createdAt || now,
  339. updatedAt: now,
  340. hops,
  341. });
  342. return { ...buildTrails(cg, projectRoot, options), saved: id, replaced: sameName !== undefined };
  343. }
  344. export function removeTrail(
  345. cg: CodeGraph,
  346. projectRoot: string,
  347. id: string,
  348. options: TrailsOptions
  349. ): WireTrails {
  350. if (options.readOnly) throw readOnlyRefusal(options.readOnlyReason);
  351. if (!deleteStoredTrail(projectRoot, id)) {
  352. throw notFound(`There is no saved trail called "${id}".`);
  353. }
  354. return { ...buildTrails(cg, projectRoot, options), deleted: id };
  355. }
  356. function readOnlyRefusal(reason: string | null): ApiError {
  357. return new ApiError(
  358. 'refused',
  359. reason ?? 'This viewer is running read-only, so trails cannot be saved.',
  360. `Restart without --read-only to let the viewer write trails into ${TRAILS_RELATIVE_DIR}.`
  361. );
  362. }
  363. function parseSaveRequest(body: unknown): { name: string; note: string; hops: SaveTrailRequest['hops'] } {
  364. if (typeof body !== 'object' || body === null || Array.isArray(body)) {
  365. throw badRequest('A trail is saved from a JSON object: { name, hops }.');
  366. }
  367. const value = body as Record<string, unknown>;
  368. const name = typeof value.name === 'string' ? value.name.trim().replace(/\s+/g, ' ') : '';
  369. if (name === '') throw badRequest('A saved trail needs a name.');
  370. if (name.length > MAX_TRAIL_NAME) {
  371. throw badRequest(`That name is too long (max ${MAX_TRAIL_NAME} characters).`);
  372. }
  373. const note = typeof value.note === 'string' ? value.note.trim() : '';
  374. if (note.length > MAX_TRAIL_NOTE) {
  375. throw badRequest(`That note is too long (max ${MAX_TRAIL_NOTE} characters).`);
  376. }
  377. if (!Array.isArray(value.hops) || value.hops.length === 0) {
  378. throw badRequest('A saved trail needs at least one hop.');
  379. }
  380. if (value.hops.length > MAX_TRAIL_HOPS) {
  381. throw badRequest(`A saved trail can hold at most ${MAX_TRAIL_HOPS} hops.`);
  382. }
  383. const hops: SaveTrailRequest['hops'] = [];
  384. for (const entry of value.hops) {
  385. if (typeof entry !== 'object' || entry === null) throw badRequest('Each hop is { dir, id }.');
  386. const hop = entry as Record<string, unknown>;
  387. if (typeof hop.id !== 'string' || hop.id === '') throw badRequest('Each hop needs an id.');
  388. hops.push({ id: hop.id, ...(typeof hop.dir === 'string' ? { dir: hop.dir } : {}) });
  389. }
  390. return { name, note, hops };
  391. }
  392. /* ---------------------------------------------------------------- author -- */
  393. /**
  394. * Who to record as the author.
  395. *
  396. * Git's `user.name` first, because a trail is a thing one person wrote for
  397. * others to read and that is the name they already sign work with in this
  398. * project; the OS user is the fallback. Read ONCE per process — `git config` is
  399. * a subprocess, and a save should not pay for it twice — and never sent
  400. * anywhere: it goes into a file inside the user's own `.codegraph/`.
  401. */
  402. let cachedAuthor: string | null = null;
  403. export function trailAuthor(projectRoot: string): string {
  404. if (cachedAuthor !== null) return cachedAuthor;
  405. cachedAuthor = gitUserName(projectRoot) ?? osUserName() ?? '';
  406. return cachedAuthor;
  407. }
  408. /** Test seam: forget the cached author. */
  409. export function resetTrailAuthor(): void {
  410. cachedAuthor = null;
  411. }
  412. function gitUserName(projectRoot: string): string | null {
  413. try {
  414. const out = execFileSync('git', ['config', 'user.name'], {
  415. cwd: projectRoot,
  416. encoding: 'utf-8',
  417. timeout: 2_000,
  418. stdio: ['ignore', 'pipe', 'ignore'],
  419. });
  420. const name = out.trim();
  421. return name === '' ? null : name.slice(0, 120);
  422. } catch {
  423. // No git, no config, not a repository — all ordinary. Fall through.
  424. return null;
  425. }
  426. }
  427. function osUserName(): string | null {
  428. try {
  429. const name = os.userInfo().username.trim();
  430. return name === '' ? null : name.slice(0, 120);
  431. } catch {
  432. return null;
  433. }
  434. }