| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477 |
- /**
- * `GET/POST/DELETE /api/trails` — saved trails, the reader's own tours through
- * the graph (design spec §3.12).
- *
- * A trail is the path of symbols someone walked to explain something: "how a
- * request is served", "everything the token expiry touches". The viewer already
- * carries one in the URL; this is the same walk given a name and kept, so the
- * next person — or the same person next week — starts at the explanation rather
- * than at the search box.
- *
- * ## The one thing this feature has to get right
- *
- * **A trail must survive a re-index.** A node's id contains its start line, so
- * inserting an import at the top of a file renames every symbol below it. A
- * trail keyed on ids would break the first time anybody edited the code it
- * describes — which is exactly when it matters. So a hop is stored as what it
- * *is* — qualified name, kind, file — with the id kept only as a fast path, and
- * every hop is re-resolved against the current index on the way out:
- *
- * - the recorded id still names the same symbol → `ok`
- * - the qualified name resolves somewhere else → `moved`, and the row says
- * where from
- * - the name is now carried by several symbols and none is in the recorded
- * file → `ambiguous`, best guess offered and labelled as one
- * - nothing answers to it → `missing`, and the row says "moved or renamed"
- *
- * Nothing is silently dropped and nothing is silently guessed: a trail that has
- * decayed says so on its own row, which is the point at which its author can
- * fix it.
- *
- * ## What it opens
- *
- * A trail with a hole in it cannot be handed to the viewer whole — the `t`
- * param is a PATH, and stitching hop 2 to hop 4 would draw an adjacency that
- * does not exist. So the payload carries the longest run of consecutive
- * resolved hops, and the row says when that is less than the whole trail.
- *
- * Storage — the only write `codegraph ui` makes — is `./trail-store.ts`.
- */
- import { execFileSync } from 'child_process';
- import * as os from 'os';
- import type { CodeGraph } from '../../index';
- import type { Node } from '../../types';
- import { ApiError, badRequest, notFound } from './respond';
- import {
- MAX_TRAIL_HOPS,
- MAX_TRAIL_NAME,
- MAX_TRAIL_NOTE,
- MAX_TRAILS,
- TRAILS_RELATIVE_DIR,
- TRAIL_FORMAT_VERSION,
- deleteStoredTrail,
- listStoredTrails,
- slugify,
- uniqueTrailId,
- writeStoredTrail,
- type StoredHop,
- type StoredHopDirection,
- type StoredTrail,
- } from './trail-store';
- import { toNodeRef } from './wire';
- /* ------------------------------------------------------------------ wire -- */
- /** How a saved hop fared against the current index. */
- export type WireTrailHopStatus = 'ok' | 'moved' | 'ambiguous' | 'missing';
- export interface WireTrailHop {
- dir: StoredHopDirection;
- /** The name as it was when the trail was saved. */
- name: string;
- qualifiedName: string;
- kind: string;
- /** Where the symbol was when the trail was saved. */
- savedFile: string;
- savedLine: number;
- status: WireTrailHopStatus;
- /** The symbol's id NOW. Null when nothing answers to it any more. */
- id: string | null;
- file: string | null;
- line: number | null;
- /** Finished screen wording for a status that is not `ok`; null when it is. */
- note: string | null;
- }
- export interface WireTrail {
- id: string;
- name: string;
- note: string;
- author: string;
- createdAt: string;
- updatedAt: string;
- hops: WireTrailHop[];
- /** Hops that still resolve to a symbol in this index. */
- resolved: number;
- /** Every hop resolved, and none of them moved. */
- intact: boolean;
- /**
- * The longest run of CONSECUTIVE resolved hops, encoded as the `t` param.
- * Null when nothing in the trail resolves. Never stitched across a hole: the
- * trail is a path, and a fabricated adjacency is worse than a short one.
- */
- encoded: string | null;
- /** 1-based index of the first hop `encoded` carries. */
- openFrom: number;
- /** How many hops `encoded` carries. */
- openCount: number;
- /** The symbol the trail opens at — the last hop of that run. */
- openId: string | null;
- }
- export interface WireTrails {
- trails: WireTrail[];
- /** Writes are off. The viewer hides Save and Delete, and says why. */
- readOnly: boolean;
- readOnlyReason: string | null;
- /** Project-relative directory the files live in. The screen names it. */
- directory: string;
- /** Files in that directory that were not readable trails. */
- skipped: number;
- /** The list stopped at {@link MAX_TRAILS}. */
- bounded: boolean;
- /** The id just written, on the answer to a POST. */
- saved?: string;
- /** That POST replaced a trail of the same name. */
- replaced?: boolean;
- /** The id just removed, on the answer to a DELETE. */
- deleted?: string;
- }
- /* -------------------------------------------------------------- resolution -- */
- /**
- * Re-resolve one saved hop against the index as it is now.
- *
- * Order matters: the recorded id first, because in the common case (nothing
- * above the symbol changed) it is one lookup and exactly right. It is still
- * verified against the qualified name — an id is a hash of position as well as
- * identity, and a recycled one pointing at a different symbol would put a
- * stranger in the middle of somebody's explanation.
- */
- export function resolveHop(cg: CodeGraph, hop: StoredHop): WireTrailHop {
- const base = {
- dir: hop.dir,
- name: hop.name,
- qualifiedName: hop.qualifiedName,
- kind: hop.kind,
- savedFile: hop.file,
- savedLine: hop.line,
- };
- const byId = hop.id ? cg.getNode(hop.id) : null;
- if (byId && matches(byId, hop)) {
- return { ...base, status: 'ok', id: byId.id, file: byId.filePath, line: byId.startLine, note: null };
- }
- const candidates = cg
- .getNodesByQualifiedName(hop.qualifiedName)
- .filter((node) => hop.kind === '' || node.kind === hop.kind);
- if (candidates.length === 0) {
- return {
- ...base,
- status: 'missing',
- id: null,
- file: null,
- line: null,
- note: `no longer in the index — moved or renamed since this trail was saved`,
- };
- }
- const sameFile = candidates.filter((node) => node.filePath === hop.file);
- if (sameFile.length === 1) {
- const node = sameFile[0] as Node;
- return { ...base, status: 'ok', id: node.id, file: node.filePath, line: node.startLine, note: null };
- }
- if (candidates.length === 1) {
- const node = candidates[0] as Node;
- return {
- ...base,
- status: 'moved',
- id: node.id,
- file: node.filePath,
- line: node.startLine,
- note: `moved from ${hop.file || 'an unrecorded file'} to ${node.filePath}`,
- };
- }
- // Several symbols carry this name and none of them is where it used to be.
- // The best guess is offered — a row nobody can open is not more honest, it
- // is just less useful — but it is labelled as a guess.
- const pick = (sameFile[0] ?? candidates[0]) as Node;
- return {
- ...base,
- status: 'ambiguous',
- id: pick.id,
- file: pick.filePath,
- line: pick.startLine,
- note: `${candidates.length} symbols now carry this name — showing the one in ${pick.filePath}`,
- };
- }
- function matches(node: Node, hop: StoredHop): boolean {
- if (hop.kind !== '' && node.kind !== hop.kind) return false;
- return node.qualifiedName === hop.qualifiedName || node.name === hop.name;
- }
- /** The `t` param's own encoding — kept identical to `ui/src/lib/trail-codec.ts`. */
- const DIR_CHAR: Record<StoredHopDirection, string> = { start: 's', down: 'd', up: 'u' };
- /**
- * Turn resolved hops into something the viewer can open.
- *
- * The longest CONSECUTIVE run, not every resolved hop: skipping a missing hop
- * would encode a step from A to C that no edge supports, and the Flow strip
- * reads a trail as exactly that sequence of edges. The first hop of the run is
- * always written as `start`, because a run beginning mid-trail arrived from
- * nothing the viewer can draw.
- */
- export function encodeResolvedRun(hops: readonly WireTrailHop[]): {
- encoded: string | null;
- openFrom: number;
- openCount: number;
- openId: string | null;
- } {
- let bestStart = -1;
- let bestLength = 0;
- let start = -1;
- for (let i = 0; i <= hops.length; i += 1) {
- const resolved = i < hops.length && (hops[i] as WireTrailHop).id !== null;
- if (resolved) {
- if (start < 0) start = i;
- continue;
- }
- if (start >= 0 && i - start > bestLength) {
- bestStart = start;
- bestLength = i - start;
- }
- start = -1;
- }
- if (bestLength === 0) return { encoded: null, openFrom: 0, openCount: 0, openId: null };
- const run = hops.slice(bestStart, bestStart + bestLength);
- const encoded = run
- .map((hop, index) => `${index === 0 ? 's' : DIR_CHAR[hop.dir]}${encodeURIComponent(hop.id as string)}`)
- .join(',');
- return {
- encoded,
- openFrom: bestStart + 1,
- openCount: bestLength,
- openId: (run[run.length - 1] as WireTrailHop).id,
- };
- }
- export function resolveTrail(cg: CodeGraph, stored: StoredTrail): WireTrail {
- const hops = stored.hops.map((hop) => resolveHop(cg, hop));
- const run = encodeResolvedRun(hops);
- return {
- id: stored.id,
- name: stored.name,
- note: stored.note,
- author: stored.author,
- createdAt: stored.createdAt,
- updatedAt: stored.updatedAt,
- hops,
- resolved: hops.filter((hop) => hop.id !== null).length,
- intact: hops.every((hop) => hop.status === 'ok'),
- ...run,
- };
- }
- /* ------------------------------------------------------------------ read -- */
- export interface TrailsOptions {
- /** Writes refused, and the sentence saying why. */
- readOnly: boolean;
- readOnlyReason: string | null;
- }
- export function buildTrails(
- cg: CodeGraph,
- projectRoot: string,
- options: TrailsOptions
- ): WireTrails {
- const { trails, skipped } = listStoredTrails(projectRoot);
- return {
- trails: trails.map((stored) => resolveTrail(cg, stored)),
- readOnly: options.readOnly,
- readOnlyReason: options.readOnlyReason,
- directory: TRAILS_RELATIVE_DIR,
- skipped,
- bounded: trails.length >= MAX_TRAILS,
- };
- }
- /* ----------------------------------------------------------------- write -- */
- /** What a POST body has to be. Everything else about a hop comes from the graph. */
- export interface SaveTrailRequest {
- name: string;
- note?: string;
- hops: Array<{ dir?: string; id: string }>;
- }
- /**
- * Save a trail.
- *
- * The client sends ids and directions and nothing else: the name, kind, file
- * and line of every hop are read out of the index here. A client that supplied
- * its own metadata could save a trail describing symbols that are not in the
- * graph, and the whole value of the feature is that a trail is a claim the
- * index can re-check.
- *
- * A save under a name that already exists REPLACES that trail, keeping its
- * `createdAt`. That is what pressing Save with the same name means, and the
- * answer says `replaced` so the screen can too.
- */
- export function saveTrail(
- cg: CodeGraph,
- projectRoot: string,
- body: unknown,
- options: TrailsOptions
- ): WireTrails {
- if (options.readOnly) throw readOnlyRefusal(options.readOnlyReason);
- const request = parseSaveRequest(body);
- const hops: StoredHop[] = [];
- request.hops.forEach((hop, index) => {
- const node = cg.getNode(hop.id);
- if (!node) {
- throw badRequest(
- `Hop ${index + 1} is not in the index: ${hop.id}`,
- 'Trails are saved from symbols the index holds. Reload the page and walk the trail again.'
- );
- }
- const ref = toNodeRef(node);
- hops.push({
- dir: hop.dir === 'up' || hop.dir === 'down' ? hop.dir : 'start',
- name: ref.name,
- qualifiedName: ref.qualifiedName,
- kind: ref.kind,
- file: ref.file,
- line: ref.line,
- id: ref.id,
- });
- });
- // The first hop is where the walk began, whatever the client called it.
- if (hops[0]) hops[0].dir = 'start';
- const existing = listStoredTrails(projectRoot).trails;
- const sameName = existing.find((trail) => trail.name === request.name);
- const takenByOthers = new Set(
- existing.filter((trail) => trail.name !== request.name).map((trail) => trail.id)
- );
- const id = sameName ? sameName.id : uniqueTrailId(slugify(request.name), takenByOthers);
- const now = new Date().toISOString();
- writeStoredTrail(projectRoot, {
- version: TRAIL_FORMAT_VERSION,
- id,
- name: request.name,
- note: request.note,
- author: trailAuthor(projectRoot),
- createdAt: sameName?.createdAt || now,
- updatedAt: now,
- hops,
- });
- return { ...buildTrails(cg, projectRoot, options), saved: id, replaced: sameName !== undefined };
- }
- export function removeTrail(
- cg: CodeGraph,
- projectRoot: string,
- id: string,
- options: TrailsOptions
- ): WireTrails {
- if (options.readOnly) throw readOnlyRefusal(options.readOnlyReason);
- if (!deleteStoredTrail(projectRoot, id)) {
- throw notFound(`There is no saved trail called "${id}".`);
- }
- return { ...buildTrails(cg, projectRoot, options), deleted: id };
- }
- function readOnlyRefusal(reason: string | null): ApiError {
- return new ApiError(
- 'refused',
- reason ?? 'This viewer is running read-only, so trails cannot be saved.',
- `Restart without --read-only to let the viewer write trails into ${TRAILS_RELATIVE_DIR}.`
- );
- }
- function parseSaveRequest(body: unknown): { name: string; note: string; hops: SaveTrailRequest['hops'] } {
- if (typeof body !== 'object' || body === null || Array.isArray(body)) {
- throw badRequest('A trail is saved from a JSON object: { name, hops }.');
- }
- const value = body as Record<string, unknown>;
- const name = typeof value.name === 'string' ? value.name.trim().replace(/\s+/g, ' ') : '';
- if (name === '') throw badRequest('A saved trail needs a name.');
- if (name.length > MAX_TRAIL_NAME) {
- throw badRequest(`That name is too long (max ${MAX_TRAIL_NAME} characters).`);
- }
- const note = typeof value.note === 'string' ? value.note.trim() : '';
- if (note.length > MAX_TRAIL_NOTE) {
- throw badRequest(`That note is too long (max ${MAX_TRAIL_NOTE} characters).`);
- }
- if (!Array.isArray(value.hops) || value.hops.length === 0) {
- throw badRequest('A saved trail needs at least one hop.');
- }
- if (value.hops.length > MAX_TRAIL_HOPS) {
- throw badRequest(`A saved trail can hold at most ${MAX_TRAIL_HOPS} hops.`);
- }
- const hops: SaveTrailRequest['hops'] = [];
- for (const entry of value.hops) {
- if (typeof entry !== 'object' || entry === null) throw badRequest('Each hop is { dir, id }.');
- const hop = entry as Record<string, unknown>;
- if (typeof hop.id !== 'string' || hop.id === '') throw badRequest('Each hop needs an id.');
- hops.push({ id: hop.id, ...(typeof hop.dir === 'string' ? { dir: hop.dir } : {}) });
- }
- return { name, note, hops };
- }
- /* ---------------------------------------------------------------- author -- */
- /**
- * Who to record as the author.
- *
- * Git's `user.name` first, because a trail is a thing one person wrote for
- * others to read and that is the name they already sign work with in this
- * project; the OS user is the fallback. Read ONCE per process — `git config` is
- * a subprocess, and a save should not pay for it twice — and never sent
- * anywhere: it goes into a file inside the user's own `.codegraph/`.
- */
- let cachedAuthor: string | null = null;
- export function trailAuthor(projectRoot: string): string {
- if (cachedAuthor !== null) return cachedAuthor;
- cachedAuthor = gitUserName(projectRoot) ?? osUserName() ?? '';
- return cachedAuthor;
- }
- /** Test seam: forget the cached author. */
- export function resetTrailAuthor(): void {
- cachedAuthor = null;
- }
- function gitUserName(projectRoot: string): string | null {
- try {
- const out = execFileSync('git', ['config', 'user.name'], {
- cwd: projectRoot,
- encoding: 'utf-8',
- timeout: 2_000,
- stdio: ['ignore', 'pipe', 'ignore'],
- });
- const name = out.trim();
- return name === '' ? null : name.slice(0, 120);
- } catch {
- // No git, no config, not a repository — all ordinary. Fall through.
- return null;
- }
- }
- function osUserName(): string | null {
- try {
- const name = os.userInfo().username.trim();
- return name === '' ? null : name.slice(0, 120);
- } catch {
- return null;
- }
- }
|