feat(ui): saved trails — a walk you named, kept, and still true after a re-index (CG-60)
Save trail on the trail bar writes the walk to .codegraph/ui/trails/ as one JSON file, listed on the empty screen and on Entry points above the derived suggestions, reopened at the symbol you left with the whole path restored. A hop is stored by qualified name, kind and file — never by node id, which contains a start line and so changes the first time anybody edits above the symbol. Every hop is re-resolved against the current index on the way out and each row says what became of it: still here, moved to another file, now ambiguous, or gone. A hole is never stitched over: the row opens the longest run of CONSECUTIVE resolved hops and says which ones those are, because the trail is a path and a skipped hop would draw a call that does not exist. This is the first write the viewer makes, and the boundary moved with it: POST/DELETE answer under /api/ only, must carry X-CodeGraph-UI and application/json (neither of which a cross-origin form can produce without a preflight this server answers none of), and --read-only refuses both while still listing what is there. The blanket "read-only" claim is retired from the banner, the README, the CLI help and the docs site in favour of the narrower true one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
55a33055ee
commit
47576b392e
+34
-12
@@ -1856,8 +1856,10 @@ function printNoIndexGuidance(projectPath: string): void {
|
||||
* codegraph ui [path] (alias: web)
|
||||
*
|
||||
* The browser reader: serves the built viewer (`dist/viewer/`) over loopback
|
||||
* and opens it. Read-only in every sense — it answers GET, it opens the index
|
||||
* for reading, and it never writes to the project or the graph.
|
||||
* and opens it. It opens the index for reading and never writes to it, never
|
||||
* indexes, and never changes a line of the project's code. The single thing it
|
||||
* writes is a trail the reader saved, as JSON under `.codegraph/ui/trails/`;
|
||||
* `--read-only` turns even that off.
|
||||
*
|
||||
* Deliberately absent from TELEMETRY_FLUSH_COMMANDS above: the command's own
|
||||
* banner tells the user nothing leaves their machine, so it must not be the
|
||||
@@ -1870,6 +1872,7 @@ program
|
||||
.description('Open the CodeGraph viewer in your browser — read your indexed project as a graph')
|
||||
.option('--port <number>', `Port to listen on (default: ${DEFAULT_UI_PORT}, or the next free one)`)
|
||||
.option('--no-open', 'Print the URL instead of opening a browser')
|
||||
.option('--read-only', 'Refuse every write — saved trails can be opened but not saved or deleted')
|
||||
.addHelpText(
|
||||
'after',
|
||||
`
|
||||
@@ -1897,10 +1900,17 @@ The page keeps up with the project while it is open: save a file and it says so
|
||||
within about a third of a second, and whatever is on screen re-reads the graph
|
||||
when something re-indexes it. It watches for that; it never polls.
|
||||
|
||||
The viewer listens on 127.0.0.1 only, so nothing on your network can reach it,
|
||||
and it is read-only: it opens an index that already exists and never changes
|
||||
your project or your graph. Requests from any other host are refused, and
|
||||
nothing is sent anywhere: no code, no paths, no analytics.
|
||||
Save a walk you want to keep: name the trail and it is written to
|
||||
.codegraph/ui/trails/ (already gitignored) as plain JSON, listed on the empty
|
||||
screen, and reopened at the symbol you left. Hops are remembered by name rather
|
||||
than by position, so a saved trail survives re-indexing and says which hop moved
|
||||
when one does. Pass --read-only to refuse every write.
|
||||
|
||||
The viewer listens on 127.0.0.1 only, so nothing on your network can reach it.
|
||||
It opens an index that already exists, never indexes, and never changes a line
|
||||
of your code — the one thing it writes is a trail you asked it to save.
|
||||
Requests from any other host are refused, and nothing is sent anywhere: no code,
|
||||
no paths, no analytics.
|
||||
|
||||
Without --port it takes ${DEFAULT_UI_PORT}, or the next free port if that one is busy.
|
||||
|
||||
@@ -1908,7 +1918,7 @@ Set ${BROWSER_ENV}=<command> to choose which browser opens, or
|
||||
${BROWSER_ENV}=none to never open one.
|
||||
`
|
||||
)
|
||||
.action(async (pathArg: string | undefined, options: { port?: string; open?: boolean }) => {
|
||||
.action(async (pathArg: string | undefined, options: { port?: string; open?: boolean; readOnly?: boolean }) => {
|
||||
// An explicit --port stays explicit: a scripted `--port 8080` that quietly
|
||||
// lands on 8081 is worse than one that says the port is busy. The default
|
||||
// port is the only one we're free to walk away from.
|
||||
@@ -1942,10 +1952,17 @@ ${BROWSER_ENV}=none to never open one.
|
||||
'../ui-server'
|
||||
);
|
||||
|
||||
// The read-only JSON API the viewer reads its screens from. It opens the
|
||||
// index lazily on the first request, so a slow first paint is the only cost
|
||||
// of mounting it here rather than after the browser connects.
|
||||
const api = createGraphApi({ projectRoot: projectPath });
|
||||
// The JSON API the viewer reads its screens from. It opens the index lazily
|
||||
// on the first request, so a slow first paint is the only cost of mounting
|
||||
// it here rather than after the browser connects.
|
||||
const readOnly = options.readOnly === true;
|
||||
const api = createGraphApi({
|
||||
projectRoot: projectPath,
|
||||
readOnly,
|
||||
readOnlyReason: readOnly
|
||||
? 'This viewer was started with --read-only, so trails cannot be saved.'
|
||||
: undefined,
|
||||
});
|
||||
|
||||
let handle: UiServerHandle;
|
||||
try {
|
||||
@@ -1968,7 +1985,12 @@ ${BROWSER_ENV}=none to never open one.
|
||||
console.log('');
|
||||
console.log(` ${chalk.dim('Reading')} ${projectPath}`);
|
||||
console.log(` ${chalk.dim('URL')} ${chalk.cyan(handle.url)}`);
|
||||
console.log(` ${chalk.dim('Access')} this machine only ${getGlyphs().dash} read-only, nothing leaves your computer`);
|
||||
console.log(
|
||||
` ${chalk.dim('Access')} this machine only ${getGlyphs().dash} ` +
|
||||
(readOnly
|
||||
? 'read-only, nothing leaves your computer'
|
||||
: 'nothing leaves your computer; saved trails are the only thing written')
|
||||
);
|
||||
console.log('');
|
||||
|
||||
const opened = options.open === false ? false : openBrowser(handle.url);
|
||||
|
||||
@@ -1373,6 +1373,21 @@ export class CodeGraph {
|
||||
return this.queries.getNodesByIds(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every symbol carrying an exact qualified name.
|
||||
*
|
||||
* The identity that survives a re-index. A node's id contains its start line,
|
||||
* so any edit ABOVE a symbol gives it a different id — anything that has to
|
||||
* name the same symbol across two indexes (a saved trail, a bookmark, a
|
||||
* review comment) has to key on this instead, and then disambiguate the
|
||||
* result by kind and file. Index-backed; unlike
|
||||
* {@link GraphQueryManager.findByQualifiedName} it takes no pattern and scans
|
||||
* nothing.
|
||||
*/
|
||||
getNodesByQualifiedName(qualifiedName: string): Node[] {
|
||||
return this.queries.getNodesByQualifiedNameExact(qualifiedName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Outgoing edges for many source nodes at once — the batch form of
|
||||
* {@link getOutgoingEdges}. See {@link QueryBuilder.getOutgoingEdgesFrom}.
|
||||
|
||||
+124
-12
@@ -1,12 +1,17 @@
|
||||
/**
|
||||
* The read-only JSON API the viewer reads its screens from.
|
||||
*
|
||||
* Twelve endpoints, one per screen, each answering in a single round-trip — the
|
||||
* same principle as `codegraph_explore`: return enough that the caller does not
|
||||
* have to ask a follow-up question — plus one that does not answer at all and
|
||||
* stays open instead (`/api/events`), so a screen learns that its answer went
|
||||
* stale rather than waiting to be asked again. Everything here is a *reader* of
|
||||
* the existing schema; nothing indexes, resolves, or writes.
|
||||
* Thirteen endpoints, one per screen, each answering in a single round-trip —
|
||||
* the same principle as `codegraph_explore`: return enough that the caller does
|
||||
* not have to ask a follow-up question — plus one that does not answer at all
|
||||
* and stays open instead (`/api/events`), so a screen learns that its answer
|
||||
* went stale rather than waiting to be asked again.
|
||||
*
|
||||
* All but one are *readers* of the existing schema; nothing here indexes or
|
||||
* resolves. The exception is `/api/trails`, which saves the reader's own named
|
||||
* walks as JSON under `.codegraph/ui/trails/` — the only write the viewer makes,
|
||||
* to the only directory it may write to, and refused outright under
|
||||
* `--read-only`. See `./trail-store.ts`.
|
||||
*
|
||||
* ```
|
||||
* GET /api/stats what this index is and how much to trust it
|
||||
@@ -22,20 +27,25 @@
|
||||
* GET /api/deadcode symbols nothing reaches, and what was excluded
|
||||
* GET /api/flow?from=&to= the flow strip: one card per hop
|
||||
* GET /api/events the live channel (SSE): drift and refresh
|
||||
* GET /api/trails saved trails, re-resolved against the index
|
||||
* POST /api/trails save one (refused under --read-only)
|
||||
* DELETE /api/trails/<id> remove one (refused under --read-only)
|
||||
* ```
|
||||
*
|
||||
* It mounts on the `api` seam of `startUiServer`, which means it sits *behind*
|
||||
* the loopback boundary in `security.ts`: the `Host` allowlist, the absence of
|
||||
* CORS headers and the GET/HEAD restriction are already enforced by the time a
|
||||
* handler here runs. The one obligation that remains ours is the read
|
||||
* chokepoint — `resolveProjectFile` for anything that touches the repository —
|
||||
* and it lives in `source.ts`, the only module here that opens a file.
|
||||
* CORS headers and the method restriction are already enforced by the time a
|
||||
* handler here runs — including the extra shape a write has to have. The one
|
||||
* obligation that remains ours is the path chokepoint, `resolveProjectFile` for
|
||||
* anything that touches the repository. Two modules here reach the filesystem
|
||||
* and no others: `source.ts` reads the project's code, and `trail-store.ts`
|
||||
* reads and writes `.codegraph/ui/trails/`.
|
||||
*/
|
||||
|
||||
import type { UiApiHandler, UiRequestContext } from '../index';
|
||||
import { PathRefusalError } from '../security';
|
||||
import { GraphSession } from './session';
|
||||
import { ApiError, badRequest, fail, notFound, ok } from './respond';
|
||||
import { ApiError, badRequest, fail, notFound, ok, readJsonBody } from './respond';
|
||||
import { buildStats } from './stats';
|
||||
import { buildSearch } from './search';
|
||||
import { buildNode } from './node';
|
||||
@@ -48,6 +58,7 @@ import { buildNodeRefs } from './nodes';
|
||||
import { buildMap } from './map';
|
||||
import { buildDeadCode } from './deadcode';
|
||||
import { buildFlow } from './flow';
|
||||
import { buildTrails, removeTrail, saveTrail, type TrailsOptions } from './trails';
|
||||
import { EventHub } from './events';
|
||||
|
||||
export { GraphSession } from './session';
|
||||
@@ -99,6 +110,28 @@ export type {
|
||||
WireDeadCodeRow,
|
||||
} from './deadcode';
|
||||
export { MAX_DEAD_CODE_MEMBERS, MAX_DEAD_CODE_ROWS } from './deadcode';
|
||||
export type {
|
||||
WireTrail,
|
||||
WireTrailHop,
|
||||
WireTrailHopStatus,
|
||||
WireTrails,
|
||||
SaveTrailRequest,
|
||||
TrailsOptions,
|
||||
} from './trails';
|
||||
export { buildTrails, encodeResolvedRun, resolveHop, resolveTrail } from './trails';
|
||||
export type { StoredHop, StoredTrail } from './trail-store';
|
||||
export {
|
||||
MAX_TRAILS,
|
||||
MAX_TRAIL_HOPS,
|
||||
MAX_TRAIL_NAME,
|
||||
MAX_TRAIL_NOTE,
|
||||
TRAILS_RELATIVE_DIR,
|
||||
TRAIL_FORMAT_VERSION,
|
||||
isTrailId,
|
||||
listStoredTrails,
|
||||
parseTrail,
|
||||
slugify,
|
||||
} from './trail-store';
|
||||
|
||||
/**
|
||||
* A mounted API, plus the handle it holds open.
|
||||
@@ -114,12 +147,29 @@ export interface GraphApi {
|
||||
export interface GraphApiOptions {
|
||||
/** Absolute path of the indexed project to read. */
|
||||
projectRoot: string;
|
||||
/**
|
||||
* Refuse every write, so the viewer is a pure reader again.
|
||||
*
|
||||
* The one thing it would otherwise write is a saved trail into
|
||||
* `.codegraph/ui/trails/`. Turning this on is for a checkout that must not
|
||||
* change (a review sandbox, a read-only mount, a shared machine); the viewer
|
||||
* still lists trails that are already there, and says why Save is gone.
|
||||
*/
|
||||
readOnly?: boolean;
|
||||
/** The sentence shown in place of Save. Defaults to a generic one. */
|
||||
readOnlyReason?: string;
|
||||
}
|
||||
|
||||
/** What `GET /api` answers: the endpoint list, for anyone poking at it by hand. */
|
||||
const API_INDEX = {
|
||||
name: 'codegraph ui',
|
||||
readOnly: true,
|
||||
/**
|
||||
* Every endpoint but `/api/trails` is a pure read. Kept as a field rather
|
||||
* than dropped, because it was `true` and something may be reading it; it is
|
||||
* now the honest, narrower claim.
|
||||
*/
|
||||
readOnly: false,
|
||||
writes: ['POST /api/trails', 'DELETE /api/trails/<id>'],
|
||||
endpoints: [
|
||||
{ path: '/api/stats', description: 'Index state, graph counts, detected frameworks.' },
|
||||
{ path: '/api/search', description: 'Ranked symbol search.', params: ['q', 'limit'] },
|
||||
@@ -167,6 +217,12 @@ const API_INDEX = {
|
||||
description: 'Where to start reading: routes, files that run something, and hubs.',
|
||||
params: ['limit'],
|
||||
},
|
||||
{
|
||||
path: '/api/trails',
|
||||
description:
|
||||
'Saved trails, each hop re-resolved against the current index. POST saves one, ' +
|
||||
'DELETE /api/trails/<id> removes it. The only endpoint that writes.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -175,12 +231,25 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
|
||||
// Watches nothing until a browser subscribes, and stops again when the last
|
||||
// one goes away — mounting the API costs no watch descriptors.
|
||||
const events = new EventHub(options.projectRoot, session);
|
||||
const trails: TrailsOptions = {
|
||||
readOnly: options.readOnly === true,
|
||||
readOnlyReason:
|
||||
options.readOnly === true
|
||||
? options.readOnlyReason ?? 'This viewer is running read-only, so trails cannot be saved.'
|
||||
: null,
|
||||
};
|
||||
|
||||
// Async because `/api/source` highlights: everything else answers straight
|
||||
// out of SQLite and resolves on the same tick.
|
||||
const handler: UiApiHandler = async (req, res, ctx) => {
|
||||
const route = normalize(ctx.pathname);
|
||||
try {
|
||||
// Writes first: they are the only requests that carry a body, and
|
||||
// routing them beside the readers would put a `case` that mutates in a
|
||||
// switch every other arm of which is a query.
|
||||
if (ctx.method === 'POST' || ctx.method === 'DELETE') {
|
||||
return await dispatchWrite(route, req, res, ctx, session, trails);
|
||||
}
|
||||
switch (route) {
|
||||
case '/api':
|
||||
return ok(res, API_INDEX, ctx.method);
|
||||
@@ -196,6 +265,8 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
|
||||
return ok(res, buildDeadCode(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
|
||||
case '/api/entrypoints':
|
||||
return ok(res, buildEntryPoints(session.acquire(), ctx.query), ctx.method);
|
||||
case '/api/trails':
|
||||
return ok(res, buildTrails(session.acquire(), ctx.projectRoot, trails), ctx.method);
|
||||
case '/api/nodes':
|
||||
return ok(res, buildNodeRefs(session.acquire(), ctx.query), ctx.method);
|
||||
case '/api/source':
|
||||
@@ -231,6 +302,47 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The write half: `/api/trails` and nothing else.
|
||||
*
|
||||
* Kept to one function so the answer to "what can this server change?" is one
|
||||
* place a reviewer can read in full. Anything else that arrives with a write
|
||||
* method is a 405 naming the endpoint that does accept one — by the time this
|
||||
* runs, `isWriteRequest` has already established the request could not have
|
||||
* been forged from another origin, so an unhelpfully vague refusal here would
|
||||
* only confuse the person poking at their own API.
|
||||
*/
|
||||
async function dispatchWrite(
|
||||
route: string,
|
||||
req: Parameters<UiApiHandler>[0],
|
||||
res: Parameters<UiApiHandler>[1],
|
||||
ctx: UiRequestContext,
|
||||
session: GraphSession,
|
||||
trails: TrailsOptions
|
||||
): Promise<boolean> {
|
||||
if (ctx.method === 'POST' && route === '/api/trails') {
|
||||
const body = await readJsonBody(req);
|
||||
return ok(res, saveTrail(session.acquire(), ctx.projectRoot, body, trails), ctx.method);
|
||||
}
|
||||
|
||||
if (ctx.method === 'DELETE') {
|
||||
const id = suffixAfter(route, '/api/trails/');
|
||||
if (id !== null && id !== '') {
|
||||
return ok(res, removeTrail(session.acquire(), ctx.projectRoot, id, trails), ctx.method);
|
||||
}
|
||||
if (route === '/api/trails') {
|
||||
throw badRequest('Deleting a trail needs its id: DELETE /api/trails/<id>.');
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader('Allow', 'GET, HEAD');
|
||||
throw new ApiError(
|
||||
'bad-request',
|
||||
`${ctx.method} ${route} is not something this server changes.`,
|
||||
'The only endpoint that writes is /api/trails (POST to save, DELETE /api/trails/<id> to remove).'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The two endpoints that carry their argument in the path.
|
||||
*
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* the CLI and the MCP tools do. What it never does is leak a stack trace.
|
||||
*/
|
||||
|
||||
import type { ServerResponse } from 'http';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { sendJson } from '../static';
|
||||
|
||||
/**
|
||||
@@ -88,6 +88,54 @@ export function fail(res: ServerResponse, err: unknown, method: string): true {
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Request bodies
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Bytes a request body may carry.
|
||||
*
|
||||
* The only body this server reads is a saved trail: a name and up to 64 node
|
||||
* ids. 64 KB is generous for that and small enough that a runaway client cannot
|
||||
* make the process hold a megabyte per socket.
|
||||
*/
|
||||
export const MAX_BODY_BYTES = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Read a request body as JSON.
|
||||
*
|
||||
* Counts BYTES, not characters, and stops at the cap by destroying the socket
|
||||
* rather than draining a body nobody is going to parse — a `Content-Length`
|
||||
* header is a claim, and the only limit that holds is the one applied to what
|
||||
* actually arrives.
|
||||
*
|
||||
* @throws {ApiError} `bad-request` for a body that is too large or is not JSON.
|
||||
*/
|
||||
export async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for await (const chunk of req) {
|
||||
const buf = chunk as Buffer;
|
||||
size += buf.length;
|
||||
if (size > MAX_BODY_BYTES) {
|
||||
req.destroy();
|
||||
throw badRequest(`That request body is too large (max ${MAX_BODY_BYTES} bytes).`);
|
||||
}
|
||||
chunks.push(buf);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
throw badRequest('That request body could not be read.');
|
||||
}
|
||||
if (size === 0) throw badRequest('That request needs a JSON body.');
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf-8')) as unknown;
|
||||
} catch {
|
||||
throw badRequest('That request body is not valid JSON.');
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Query parameters
|
||||
// =============================================================================
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Where saved trails live on disk — the only thing `codegraph ui` ever writes.
|
||||
*
|
||||
* Every other module under `api/` is a reader. This one holds the single write
|
||||
* path in the whole viewer, and it is scoped as narrowly as a write can be: one
|
||||
* directory, `<CODEGRAPH_DIR>/ui/trails/`, inside the project the server was
|
||||
* started on, one JSON file per trail. It never touches source, never touches
|
||||
* the index, and never writes anywhere a `codegraph init` would not already
|
||||
* have created. `.codegraph/.gitignore` ignores everything but itself, so a
|
||||
* saved trail is local by default; exporting one to commit is a copy the reader
|
||||
* makes deliberately.
|
||||
*
|
||||
* Two rules hold it inside the boundary described in `../security.ts`:
|
||||
*
|
||||
* - **The directory is resolved through `resolveProjectFile`**, exactly like a
|
||||
* source read, so a trail id that tried to be a path is refused by the same
|
||||
* chokepoint that refuses `?file=../../.ssh/id_rsa`. It is belt and braces on
|
||||
* top of {@link isTrailId}, which already refuses anything but a slug.
|
||||
* - **A write is atomic.** Temp file in the same directory, then rename. A
|
||||
* half-written trail read back by the list would look like a corrupt one, and
|
||||
* the list would then have to decide whether to hide it — which is a decision
|
||||
* nobody should have to make about a file they saved a second ago.
|
||||
*
|
||||
* The format is deliberately plain: a reader can open one in an editor, and a
|
||||
* hop is described by what it IS (a qualified name in a file) rather than by the
|
||||
* node id it happened to have. Node ids contain a start line, so any edit above
|
||||
* a symbol renames it — a trail keyed on ids would not survive its own project.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { CODEGRAPH_DIR } from '../../directory';
|
||||
import { resolveProjectFile } from '../security';
|
||||
import { ApiError, badRequest } from './respond';
|
||||
|
||||
/** Where trails live, relative to the project root. Forward slashes always. */
|
||||
export const TRAILS_RELATIVE_DIR = `${CODEGRAPH_DIR}/ui/trails`;
|
||||
|
||||
/** The only `version` this build writes, and the only one it reads. */
|
||||
export const TRAIL_FORMAT_VERSION = 1;
|
||||
|
||||
/** Trail files read from the directory before the list stops looking. */
|
||||
export const MAX_TRAILS = 200;
|
||||
|
||||
/** Hops one trail may carry. Past this it is a history, not a tour. */
|
||||
export const MAX_TRAIL_HOPS = 64;
|
||||
|
||||
/** Characters in a trail's name. */
|
||||
export const MAX_TRAIL_NAME = 120;
|
||||
|
||||
/** Characters in a trail's note. */
|
||||
export const MAX_TRAIL_NOTE = 600;
|
||||
|
||||
/** Bytes a single trail file may be before it is skipped as not-ours. */
|
||||
export const MAX_TRAIL_FILE_BYTES = 64 * 1024;
|
||||
|
||||
/** Characters in a generated slug, before any de-duplicating suffix. */
|
||||
const MAX_SLUG = 60;
|
||||
|
||||
/** How a reader got from the previous hop to this one. Mirrors the viewer's `HopDirection`. */
|
||||
export type StoredHopDirection = 'start' | 'down' | 'up';
|
||||
|
||||
/**
|
||||
* One hop, described by what it is rather than by the id it had.
|
||||
*
|
||||
* `id` is kept as a HINT — when the file has not changed it resolves in one
|
||||
* lookup — but `qualifiedName` + `kind` + `file` is what the trail is actually
|
||||
* keyed on, and what lets it survive a re-index.
|
||||
*/
|
||||
export interface StoredHop {
|
||||
dir: StoredHopDirection;
|
||||
name: string;
|
||||
qualifiedName: string;
|
||||
kind: string;
|
||||
/** Project-relative, forward slashes. */
|
||||
file: string;
|
||||
line: number;
|
||||
/** The node id at save time. A fast path, never the identity. */
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface StoredTrail {
|
||||
version: number;
|
||||
/** Slug, and the file's basename. */
|
||||
id: string;
|
||||
name: string;
|
||||
note: string;
|
||||
/** Whoever saved it — git's `user.name`, or the OS user. */
|
||||
author: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
hops: StoredHop[];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ paths -- */
|
||||
|
||||
/**
|
||||
* Whether a string is a trail id we would have written.
|
||||
*
|
||||
* Lowercase slug characters only: no dot, no separator, no leading dash. This
|
||||
* is what makes `<id>.json` a filename rather than a path expression, and it
|
||||
* runs before the id is ever joined to anything.
|
||||
*/
|
||||
export function isTrailId(value: string): boolean {
|
||||
return /^[a-z0-9][a-z0-9-]{0,79}$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* `Read a file with these lines` -> `read-a-file-with-these-lines`.
|
||||
*
|
||||
* Names that carry no ASCII letters or digits at all (a trail named entirely in
|
||||
* Chinese, or in emoji) slug to nothing; they get `trail`, and the collision
|
||||
* handling in {@link saveTrail} keeps them distinct from each other.
|
||||
*/
|
||||
export function slugify(name: string): string {
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, MAX_SLUG)
|
||||
.replace(/-+$/g, '');
|
||||
return slug === '' ? 'trail' : slug;
|
||||
}
|
||||
|
||||
/** The absolute trails directory, having been through the read chokepoint. */
|
||||
export function trailsDirectory(projectRoot: string): string {
|
||||
return resolveProjectFile(projectRoot, TRAILS_RELATIVE_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* The absolute path of one trail file.
|
||||
*
|
||||
* @throws {ApiError} `bad-request` when the id is not a slug we would have
|
||||
* written — checked before the join, so nothing path-shaped is ever built.
|
||||
*/
|
||||
export function trailPath(projectRoot: string, id: string): string {
|
||||
if (!isTrailId(id)) {
|
||||
throw badRequest(
|
||||
`"${id}" is not a saved trail id.`,
|
||||
'Trail ids are the lowercase slug in the file name, e.g. "how-a-request-is-served".'
|
||||
);
|
||||
}
|
||||
return resolveProjectFile(projectRoot, `${TRAILS_RELATIVE_DIR}/${id}.json`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- read -- */
|
||||
|
||||
/**
|
||||
* Parse a file into a trail, or `null` if it is not one.
|
||||
*
|
||||
* Everything is re-validated rather than trusted: the directory is a place a
|
||||
* user may hand-edit a file, or drop one somebody else exported, and a trail
|
||||
* that half-parsed would draw a row with holes in it. A file that fails is
|
||||
* skipped and counted, never repaired in place.
|
||||
*/
|
||||
export function parseTrail(id: string, text: string): StoredTrail | null {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof raw !== 'object' || raw === null) return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
if (typeof value.name !== 'string' || value.name.trim() === '') return null;
|
||||
if (!Array.isArray(value.hops) || value.hops.length === 0) return null;
|
||||
|
||||
const hops: StoredHop[] = [];
|
||||
for (const entry of value.hops.slice(0, MAX_TRAIL_HOPS)) {
|
||||
if (typeof entry !== 'object' || entry === null) return null;
|
||||
const hop = entry as Record<string, unknown>;
|
||||
const qualifiedName = typeof hop.qualifiedName === 'string' ? hop.qualifiedName : '';
|
||||
const name = typeof hop.name === 'string' ? hop.name : '';
|
||||
if (qualifiedName === '' && name === '') return null;
|
||||
hops.push({
|
||||
dir: hop.dir === 'up' || hop.dir === 'down' ? hop.dir : 'start',
|
||||
name: name || qualifiedName,
|
||||
qualifiedName: qualifiedName || name,
|
||||
kind: typeof hop.kind === 'string' ? hop.kind : '',
|
||||
file: typeof hop.file === 'string' ? hop.file : '',
|
||||
line: typeof hop.line === 'number' && hop.line > 0 ? Math.floor(hop.line) : 0,
|
||||
id: typeof hop.id === 'string' ? hop.id : '',
|
||||
});
|
||||
}
|
||||
|
||||
const created = typeof value.createdAt === 'string' ? value.createdAt : '';
|
||||
return {
|
||||
version: typeof value.version === 'number' ? value.version : TRAIL_FORMAT_VERSION,
|
||||
// The FILE's name wins over any `id` inside it: the basename is what the
|
||||
// delete route addresses, so a hand-copied file is addressable under the
|
||||
// name it actually has rather than the one it remembers having.
|
||||
id,
|
||||
name: value.name.slice(0, MAX_TRAIL_NAME),
|
||||
note: typeof value.note === 'string' ? value.note.slice(0, MAX_TRAIL_NOTE) : '',
|
||||
author: typeof value.author === 'string' ? value.author.slice(0, 120) : '',
|
||||
createdAt: created,
|
||||
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : created,
|
||||
hops,
|
||||
};
|
||||
}
|
||||
|
||||
export interface StoredTrailList {
|
||||
trails: StoredTrail[];
|
||||
/** Files in the directory that were not readable trails. */
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every trail in the project, newest save first.
|
||||
*
|
||||
* A missing directory is the ordinary state of a project nobody has saved a
|
||||
* trail in — an empty list, never an error.
|
||||
*/
|
||||
export function listStoredTrails(projectRoot: string): StoredTrailList {
|
||||
const dir = trailsDirectory(projectRoot);
|
||||
let names: string[];
|
||||
try {
|
||||
names = fs.readdirSync(dir);
|
||||
} catch {
|
||||
return { trails: [], skipped: 0 };
|
||||
}
|
||||
|
||||
const trails: StoredTrail[] = [];
|
||||
let skipped = 0;
|
||||
for (const name of names.sort()) {
|
||||
if (!name.endsWith('.json')) continue;
|
||||
if (trails.length >= MAX_TRAILS) break;
|
||||
const id = name.slice(0, -'.json'.length);
|
||||
if (!isTrailId(id)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const trail = readTrailFile(path.join(dir, name), id);
|
||||
if (trail) trails.push(trail);
|
||||
else skipped += 1;
|
||||
}
|
||||
|
||||
// Newest save first: a tour written a minute ago is the one being iterated on.
|
||||
trails.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : a.name.localeCompare(b.name)));
|
||||
return { trails, skipped };
|
||||
}
|
||||
|
||||
function readTrailFile(absolute: string, id: string): StoredTrail | null {
|
||||
try {
|
||||
const stat = fs.statSync(absolute);
|
||||
// A file too big to be a trail is skipped rather than read: this directory
|
||||
// is inside the project, and something else may one day put a log in it.
|
||||
if (!stat.isFile() || stat.size > MAX_TRAIL_FILE_BYTES) return null;
|
||||
return parseTrail(id, fs.readFileSync(absolute, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** One trail by id, or `null` when there is no such file. */
|
||||
export function readStoredTrail(projectRoot: string, id: string): StoredTrail | null {
|
||||
return readTrailFile(trailPath(projectRoot, id), id);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ write -- */
|
||||
|
||||
/**
|
||||
* Write a trail, atomically.
|
||||
*
|
||||
* Temp file beside the target then `rename`, so a reader either sees the
|
||||
* previous trail or the new one and never a partial file. The temp name carries
|
||||
* the pid: two `codegraph ui` processes on one project is unusual but not
|
||||
* forbidden, and two writers sharing a temp name would corrupt each other's.
|
||||
*/
|
||||
export function writeStoredTrail(projectRoot: string, trail: StoredTrail): void {
|
||||
const dir = trailsDirectory(projectRoot);
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
} catch (err) {
|
||||
throw writeFailure(err);
|
||||
}
|
||||
const target = trailPath(projectRoot, trail.id);
|
||||
const temp = `${target}.${process.pid}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(temp, `${JSON.stringify(trail, null, 2)}\n`, 'utf-8');
|
||||
fs.renameSync(temp, target);
|
||||
} catch (err) {
|
||||
try {
|
||||
fs.unlinkSync(temp);
|
||||
} catch {
|
||||
// Nothing to clean up, or nothing we can do about it. The write already
|
||||
// failed; the caller is about to be told so.
|
||||
}
|
||||
throw writeFailure(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a trail. Returns false when there was nothing there. */
|
||||
export function deleteStoredTrail(projectRoot: string, id: string): boolean {
|
||||
try {
|
||||
fs.unlinkSync(trailPath(projectRoot, id));
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||||
throw writeFailure(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An id nothing in `taken` is using, preferring the plain slug.
|
||||
*
|
||||
* A save under a name that is already there REPLACES it — that is what a reader
|
||||
* pressing Save with the same name means — so the caller passes the ids of
|
||||
* trails carrying a *different* name, and this only steps aside for those.
|
||||
*/
|
||||
export function uniqueTrailId(base: string, taken: ReadonlySet<string>): string {
|
||||
if (!taken.has(base)) return base;
|
||||
for (let n = 2; n < 1000; n += 1) {
|
||||
const candidate = `${base}-${n}`;
|
||||
if (!taken.has(candidate)) return candidate;
|
||||
}
|
||||
// 999 trails sharing one slug is not a state worth a clever answer.
|
||||
throw new ApiError('bad-request', `Too many saved trails are already named like "${base}".`);
|
||||
}
|
||||
|
||||
function writeFailure(err: unknown): ApiError {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
|
||||
return new ApiError(
|
||||
'refused',
|
||||
`Saved trails could not be written: ${detail}`,
|
||||
`The viewer writes only to ${TRAILS_RELATIVE_DIR} inside this project. Check that it is writable.`
|
||||
);
|
||||
}
|
||||
return new ApiError('internal', `Saved trails could not be written: ${detail}`);
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* `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;
|
||||
}
|
||||
}
|
||||
+46
-10
@@ -1,11 +1,16 @@
|
||||
/**
|
||||
* The `codegraph ui` server.
|
||||
*
|
||||
* A loopback-only, read-only `node:http` server that hands the browser the
|
||||
* built viewer (`dist/viewer/`) and, through the JSON API mounted on the `api`
|
||||
* seam below (`./api`), a read-only view of one indexed project. No framework,
|
||||
* no new dependency: it answers GET, serves files, and refuses everything
|
||||
* else.
|
||||
* A loopback-only `node:http` server that hands the browser the built viewer
|
||||
* (`dist/viewer/`) and, through the JSON API mounted on the `api` seam below
|
||||
* (`./api`), a view of one indexed project. No framework, no new dependency: it
|
||||
* answers GET, serves files, and refuses everything else.
|
||||
*
|
||||
* It is a reader with one exception, added deliberately and scoped as narrowly
|
||||
* as it could be: `POST`/`DELETE /api/trails` saves and removes the reader's own
|
||||
* named trails, as JSON files under `.codegraph/ui/trails/`. Nothing else it
|
||||
* serves has a side effect, no other path accepts a write, and `--read-only`
|
||||
* turns even that one off. See `security.ts` for what a write has to carry.
|
||||
*
|
||||
* The interesting part is not the routing, it is the boundary in `security.ts`.
|
||||
* Read that first.
|
||||
@@ -17,9 +22,13 @@ import * as path from 'path';
|
||||
import { resolveViewerDir } from './assets';
|
||||
import {
|
||||
ALLOWED_METHODS,
|
||||
READ_METHODS,
|
||||
WRITE_HEADER,
|
||||
isAllowedHost,
|
||||
isAllowedOrigin,
|
||||
isSafeRequestPath,
|
||||
isWriteMethod,
|
||||
isWriteRequest,
|
||||
resolveStaticAsset,
|
||||
} from './security';
|
||||
import { sendFile, sendJson, sendText, shouldFallBackToIndex } from './static';
|
||||
@@ -35,10 +44,15 @@ export {
|
||||
} from './constants';
|
||||
export {
|
||||
ALLOWED_METHODS,
|
||||
READ_METHODS,
|
||||
WRITE_HEADER,
|
||||
WRITE_METHODS,
|
||||
PathRefusalError,
|
||||
isAllowedHost,
|
||||
isAllowedOrigin,
|
||||
isSafeRequestPath,
|
||||
isWriteMethod,
|
||||
isWriteRequest,
|
||||
resolveProjectFile,
|
||||
resolveStaticAsset,
|
||||
} from './security';
|
||||
@@ -58,7 +72,11 @@ export interface UiRequestContext {
|
||||
query: URLSearchParams;
|
||||
/** Absolute path of the indexed project this server is reading. */
|
||||
projectRoot: string;
|
||||
/** The request method — `GET` or `HEAD`; nothing else reaches a handler. */
|
||||
/**
|
||||
* The request method. `GET` or `HEAD` for every read; `POST` or `DELETE`
|
||||
* only for a request that already passed {@link isWriteRequest}, which is
|
||||
* `/api/trails` and nothing else.
|
||||
*/
|
||||
method: string;
|
||||
}
|
||||
|
||||
@@ -66,9 +84,9 @@ export interface UiRequestContext {
|
||||
* A handler mounted under `/api/`. Returns `true` when it answered the request
|
||||
* (i.e. wrote a response), `false` to fall through to a 404.
|
||||
*
|
||||
* This is the seam the read-only JSON API plugs into. Everything it serves out
|
||||
* of the user's repository must go through `resolveProjectFile` — see
|
||||
* `security.ts`.
|
||||
* This is the seam the JSON API plugs into. Everything it reads out of — or
|
||||
* writes into — the user's repository must go through `resolveProjectFile`;
|
||||
* see `security.ts`.
|
||||
*/
|
||||
export type UiApiHandler = (
|
||||
req: http.IncomingMessage,
|
||||
@@ -228,7 +246,7 @@ async function handleRequest(
|
||||
|
||||
if (!ALLOWED_METHODS.includes(method)) {
|
||||
res.setHeader('Allow', ALLOWED_METHODS.join(', '));
|
||||
sendText(res, 405, `codegraph ui is read-only — ${method} is not allowed.`, method);
|
||||
sendText(res, 405, `codegraph ui does not answer ${method}.`, method);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -258,6 +276,24 @@ async function handleRequest(
|
||||
// the viewer parses these responses, and a text/plain body here would surface
|
||||
// as a parse error instead of the refusal it actually is.
|
||||
const jsonNamespace = rawPath === '/api' || rawPath.startsWith('/api/');
|
||||
|
||||
// The one place this server stops being a pure reader. A write has to be
|
||||
// under /api/ and carry the marker header — see `isWriteRequest` for what
|
||||
// that closes that Host and Origin do not.
|
||||
if (isWriteMethod(method)) {
|
||||
const verdict = isWriteRequest(rawPath, {
|
||||
marker: readHeader(req, WRITE_HEADER),
|
||||
contentType: readHeader(req, 'content-type'),
|
||||
});
|
||||
if (!verdict.ok) {
|
||||
if (!jsonNamespace) res.setHeader('Allow', READ_METHODS.join(', '));
|
||||
const body = `Refused: ${verdict.reason}`;
|
||||
if (jsonNamespace) sendJson(res, 403, { error: body, code: 'refused' }, method);
|
||||
else sendText(res, 405, body, method);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSafeRequestPath(rawPath)) {
|
||||
if (jsonNamespace) {
|
||||
sendJson(res, 404, { error: 'Not found', code: 'not-found' }, method);
|
||||
|
||||
@@ -20,8 +20,11 @@
|
||||
* - **No CORS headers, ever.** Not adding `Access-Control-Allow-Origin` is what
|
||||
* keeps a cross-origin reader from seeing a response body even if it does
|
||||
* reach us. There is deliberately no way to turn this on.
|
||||
* - **GET/HEAD only.** The viewer is a reader; nothing it serves has a side
|
||||
* effect, so there is no state for a forged request to change.
|
||||
* - **GET/HEAD everywhere; POST/DELETE only under `/api/`, and only for a
|
||||
* request that could not have been forged by a form.** See
|
||||
* {@link isWriteRequest} below — the viewer went from a pure reader to one
|
||||
* that saves trails into `.codegraph/ui/`, and that is the entire change to
|
||||
* this boundary.
|
||||
* - **Every path resolves through {@link validatePathWithinRoot}** — the same
|
||||
* chokepoint the MCP read sinks use, which catches `../` traversal AND
|
||||
* in-tree symlinks pointing out of the root (#527).
|
||||
@@ -40,8 +43,65 @@ export { PathRefusalError };
|
||||
*/
|
||||
const LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set(['localhost', '127.0.0.1', '::1']);
|
||||
|
||||
/** HTTP methods the viewer server answers. Everything else is 405. */
|
||||
export const ALLOWED_METHODS: readonly string[] = ['GET', 'HEAD'];
|
||||
/** Methods that answer anywhere: the viewer's assets and every read endpoint. */
|
||||
export const READ_METHODS: readonly string[] = ['GET', 'HEAD'];
|
||||
|
||||
/**
|
||||
* Methods that answer under `/api/` only, and only for a request carrying
|
||||
* {@link WRITE_HEADER}. The viewer's one write is a saved trail.
|
||||
*/
|
||||
export const WRITE_METHODS: readonly string[] = ['POST', 'DELETE'];
|
||||
|
||||
/** HTTP methods the viewer server answers at all. Everything else is 405. */
|
||||
export const ALLOWED_METHODS: readonly string[] = [...READ_METHODS, ...WRITE_METHODS];
|
||||
|
||||
/**
|
||||
* The header a write has to carry.
|
||||
*
|
||||
* Belt and braces behind the `Host` and `Origin` checks, and worth the two
|
||||
* lines because it fails *differently*: a custom request header cannot be sent
|
||||
* cross-origin without a CORS preflight, and this server answers no preflight
|
||||
* and sends no `Access-Control-*` header, so the browser never issues the real
|
||||
* request. That closes the one shape those checks lean on a header for — a
|
||||
* `<form method="post">` submitted from another page, which sends no `Origin`
|
||||
* in some older browsers and cannot set a custom header in any of them.
|
||||
*/
|
||||
export const WRITE_HEADER = 'x-codegraph-ui';
|
||||
|
||||
/** The content type a write body must declare. A form can send none of these. */
|
||||
const WRITE_CONTENT_TYPE = 'application/json';
|
||||
|
||||
export function isWriteMethod(method: string): boolean {
|
||||
return WRITE_METHODS.includes(method);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a mutating request is one the viewer could have made.
|
||||
*
|
||||
* @param method the request method, already known to be a write method
|
||||
* @param pathname the raw request path
|
||||
* @param headers `x-codegraph-ui` and, for a body-carrying method, `content-type`
|
||||
*/
|
||||
export function isWriteRequest(
|
||||
pathname: string,
|
||||
headers: { marker: string | undefined; contentType: string | undefined }
|
||||
): { ok: true } | { ok: false; reason: string } {
|
||||
// Writes live under /api/ and nowhere else. The static side of this server
|
||||
// serves a built bundle; there is nothing there to POST to.
|
||||
if (pathname !== '/api' && !pathname.startsWith('/api/')) {
|
||||
return { ok: false, reason: 'Only the /api/ endpoints accept writes.' };
|
||||
}
|
||||
if (headers.marker === undefined || headers.marker.trim() === '') {
|
||||
return { ok: false, reason: `A write must carry the ${WRITE_HEADER} header.` };
|
||||
}
|
||||
if (headers.contentType !== undefined) {
|
||||
const type = headers.contentType.split(';')[0]?.trim().toLowerCase();
|
||||
if (type !== '' && type !== WRITE_CONTENT_TYPE) {
|
||||
return { ok: false, reason: `A write body must be ${WRITE_CONTENT_TYPE}.` };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
interface HostParts {
|
||||
hostname: string;
|
||||
|
||||
Reference in New Issue
Block a user