Files
Michael Ramos 91ad7c95fc feat(review): token hover cards, Tier 0 (#1461)
* feat(review): add POST /api/code-nav/hover in both runtimes

Tier 0 of the token hover card: the hover pipeline is the ripgrep search
/resolve already runs, plus three cheap enrichments read off the same
machinery.

- packages/shared/code-nav.ts: definition patterns carry the kind they
  prove (alternations split one-per-kind, so definition-vs-reference
  classification is byte-identical and classifyMatch becomes a thin
  wrapper over classifyMatchDetailed); scanDocComment, buildSignature and
  resolveCodeNavHover; an additive timeoutMs option on resolveCodeNav so
  hover can ask for 3s while /resolve keeps its 5s; an optional readFile
  member on CodeNavRuntime so /resolve callers stay unchanged.
- Both review servers gain /api/code-nav/hover behind the same guard
  stack as /resolve. /resolve itself is untouched.

The doc scan is conservative by construction: per-language, blank-line
separated, capped, and null for an unknown language. Returning nothing
always beats returning garbage.

* feat(review): token hover cards in the code-review diff

Resting the pointer on a symbol opens a card with where it is defined, an
approximate signature, its doc comment if the scan found a real one, and a
sample of its references. Every location on the card routes into the same
References panel Cmd+click opens.

- utils/stitchTokenIdentifier: rebuilds one identifier from the token spans
  Shiki fragmented it into, using each span's data-char column to prove
  adjacency. It stops at dots (rg searches with --word-regexp, where a
  dotted path matches nothing) and refuses keywords and one-character
  names, which is what keeps most hovers off the wire entirely.
- hooks/useTokenHover: 350ms dwell before any request exists, one in-flight
  request aborted by its successor, a 30-entry LRU flushed whenever the diff
  snapshot changes, a 250ms leave grace so the card's own links are
  reachable, and a scroll/wheel cancel because the anchor rect is stale the
  moment the pane moves. An unavailable backend, a failure, a timeout and a
  thin answer all render nothing, silently: a hover is an idle gesture and
  must never nag.
- components/TokenHoverCard: portaled to body so it escapes the Dockview
  overflow and stacking context; anchored below the token, flipped above
  when the viewport would clip it. It shows what the search found and
  nothing it did not, so uncertainty is a second location line rather than
  a description of the ranking.
- Wiring: two optional props beside onCodeNavRequest in both diff views,
  passed only when the existing live-workspace gate AND the new cookie-only
  "Token hover cards" setting are on. Off means no listeners, no requests
  and no card in the tree. Alt+click joins Cmd+click as an unadvertised
  alias into the References panel; the meta/ctrl branch is unchanged.

The guides.show viewer manifest moves with this: AllFilesCodeView is in the
portable viewer's graph, so its new optional props shift the bundle hash.

* fix(review): correct token hover supersession, scroll and doc-scan defects

Review findings, each with the regression test that fails without the fix.

- An open card could be rewritten by a NEIGHBOUR's answer: drifting onto an
  adjacent token launched its request, and returning to the open card took
  the same-key early return without reclaiming the active key, so the
  neighbour's answer still passed the landing check. The early return now
  reclaims the key, kills the pending dwell, and aborts a foreign request.
- Re-entering a token inside the leave grace re-armed the dwell while that
  token's own request was still in flight, spawning a second ripgrep for an
  answer already on its way. The dwell now joins the in-flight request, and
  the answer anchors to the span the pointer is on now.
- Scrolling INSIDE the card closed it, which made the signature block's
  horizontal scroller unreadable by the gesture meant to read it. The
  cancel now ignores events originating in the card's own subtree; a pane
  scroll still closes it.
- A below-threshold answer for a different token left the previous token's
  card standing over a symbol the reviewer had already left.
- The doc scan rendered tooling directives as documentation. Directives are
  dropped from BOTH ENDS of the comment run — eslint-disable, @ts-*,
  prettier-ignore, biome-ignore, istanbul ignore, noqa, type: ignore and
  triple-slash references — because the commonest real position is the line
  immediately above the definition, which is the trailing end of the run as
  collected. Never from the middle: a directive surrounded by prose sits
  inside documentation we would have to interpret to cut safely. A run that
  is nothing but directives returns null, and prose that merely mentions a
  directive is untouched.

Also: an answer whose token has been recycled out of the DOM opens no card
(a detached rect is 0,0 and would pin it to the viewport corner); a flipped
card is clamped to the top edge; the card is a tooltip, not a dialog; a
location click describes the CLICKED location rather than forwarding the
hover's charStart and language into another file; definition.preview stays
declared but unpopulated until a consumer exists; the overflow line regains
its leading ellipsis and now renders under the banned-vocabulary sweep.

Portable viewer: the hover prop is inverted to (props, filePath) so the two
diff views import nothing new and stitching lives in App. The read-only
guide viewer bundle no longer carries the stitcher or the request builder.
2026-09-02 19:00:39 -07:00

241 lines
8.4 KiB
TypeScript

/**
* POST /api/code-nav/hover — dual-runtime (Bun + Pi).
*
* Guards two things, both of them the repo's standing two-runtime hazard:
* 1. Both servers answer the SAME response shape, including the forward
* compatibility fields a later tier fills (`source`, `symbolKind`,
* `signature`, `doc`) — a Pi mirror that drifts to `/resolve`'s shape
* would render an empty card in one runtime and a full one in the other.
* 2. A session with no local checkout is refused by both with 400, so the
* hook renders nothing rather than hovering over confidently-wrong
* results from whatever directory the process happened to start in.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startReviewServer as startBunReviewServer } from './review';
import { startReviewServer as startPiReviewServer } from '../../apps/pi-extension/server';
import { getVcsContext } from './vcs';
const originalDataDir = process.env.PLANNOTATOR_DATA_DIR;
const originalPort = process.env.PLANNOTATOR_PORT;
const tempDirs: string[] = [];
// rg is the whole backend. Where it is missing the endpoint still answers 200
// with `backend: 'unavailable'` — the shape assertions below hold either way,
// so only the enrichment assertions are gated on it.
const hasRipgrep = spawnSync('rg', ['--version']).status === 0;
function makeTempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
function git(cwd: string, args: string[]): void {
const result = spawnSync('git', args, { cwd, encoding: 'utf-8' });
if (result.status !== 0) {
throw new Error(result.stderr || `git ${args.join(' ')} failed`);
}
}
const SOURCE = [
'// Charges the card.',
'export function charge(amount, key) {',
' return gateway.post(endpoint, { amount, key });',
'}',
'',
'export function retryCharge(amount, key) {',
' return charge(amount, key);',
'}',
'',
'export function queueCharge(amount, key) {',
' return charge(amount, key);',
'}',
'',
].join('\n');
function initRepo(): string {
const repoDir = makeTempDir('plannotator-hover-endpoint-');
git(repoDir, ['init', '-q']);
git(repoDir, ['branch', '-M', 'main']);
git(repoDir, ['config', 'user.email', 'test@example.com']);
git(repoDir, ['config', 'user.name', 'Test']);
writeFileSync(join(repoDir, 'pay.js'), SOURCE);
git(repoDir, ['add', 'pay.js']);
git(repoDir, ['commit', '-q', '-m', 'initial']);
return repoDir;
}
async function reservePort(): Promise<number> {
const server = createServer();
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve());
});
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
await new Promise<void>((resolve) => server.close(() => resolve()));
return port;
}
const RAW_PATCH = [
'diff --git a/pay.js b/pay.js',
'--- a/pay.js',
'+++ b/pay.js',
'@@ -1 +1 @@',
'-old',
'+new',
].join('\n');
const HOVER_REQUEST = {
symbol: 'charge',
filePath: 'pay.js',
line: 2,
charStart: 16,
side: 'new',
language: 'javascript',
};
afterEach(() => {
if (originalDataDir === undefined) delete process.env.PLANNOTATOR_DATA_DIR;
else process.env.PLANNOTATOR_DATA_DIR = originalDataDir;
if (originalPort === undefined) delete process.env.PLANNOTATOR_PORT;
else process.env.PLANNOTATOR_PORT = originalPort;
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
describe('POST /api/code-nav/hover', () => {
for (const [runtime, startServer] of [
['Bun', startBunReviewServer],
['Pi', startPiReviewServer],
] as const) {
test(`${runtime} answers the hover shape for a local git session`, async () => {
process.env.PLANNOTATOR_DATA_DIR = makeTempDir('plannotator-hover-data-');
if (runtime === 'Pi') process.env.PLANNOTATOR_PORT = String(await reservePort());
const repoDir = initRepo();
const gitContext = await getVcsContext(repoDir, 'git');
const server = await startServer({
rawPatch: RAW_PATCH,
gitRef: 'Working tree',
diffType: 'uncommitted',
gitContext,
agentCwd: repoDir,
origin: runtime === 'Pi' ? 'pi' : 'claude-code',
htmlContent: '<!doctype html><html><body>review</body></html>',
});
try {
const res = await fetch(`${server.url}/api/code-nav/hover`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(HOVER_REQUEST),
});
expect(res.status).toBe(200);
const data = await res.json() as Record<string, unknown>;
// The shape both runtimes owe the client, tier-independent.
expect(Object.keys(data).sort()).toEqual([
'alternateDefinition',
'backend',
'capped',
'definition',
'referenceCount',
'references',
'source',
'stats',
'symbol',
]);
expect(data.source).toBe('search');
expect(data.symbol).toBe('charge');
expect(typeof (data.stats as { elapsedMs: number }).elapsedMs).toBe('number');
if (!hasRipgrep) {
expect(data.backend).toBe('unavailable');
return;
}
expect(data.backend).toBe('search');
const definition = data.definition as {
filePath: string;
line: number;
symbolKind: string | null;
signature: string | null;
signatureApproximate: boolean;
doc: string | null;
preview: { startLine: number; lines: string[] } | null;
otherCandidateCount: number;
};
expect(definition.filePath).toBe('pay.js');
expect(definition.line).toBe(2);
expect(definition.symbolKind).toBe('function');
expect(definition.signature).toBe('export function charge(amount, key) {');
expect(definition.signatureApproximate).toBe(true);
expect(definition.doc).toBe('Charges the card.');
// Present in the shape both runtimes owe, null until a consumer exists.
expect(definition.preview).toBeNull();
// The two call sites, and only those — the definition line is not
// double-counted as a reference.
expect(data.referenceCount).toBe(2);
expect(data.references).toHaveLength(2);
expect(data.capped).toBe(false);
} finally {
server.stop();
}
});
test(`${runtime} refuses a session with no local checkout`, async () => {
process.env.PLANNOTATOR_DATA_DIR = makeTempDir('plannotator-hover-data-');
if (runtime === 'Pi') process.env.PLANNOTATOR_PORT = String(await reservePort());
const server = await startServer({
rawPatch: RAW_PATCH,
gitRef: 'Piped diff',
diffType: 'uncommitted',
origin: runtime === 'Pi' ? 'pi' : 'claude-code',
htmlContent: '<!doctype html><html><body>review</body></html>',
});
try {
const res = await fetch(`${server.url}/api/code-nav/hover`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(HOVER_REQUEST),
});
expect(res.status).toBe(400);
} finally {
server.stop();
}
});
test(`${runtime} rejects a traversing filePath`, async () => {
process.env.PLANNOTATOR_DATA_DIR = makeTempDir('plannotator-hover-data-');
if (runtime === 'Pi') process.env.PLANNOTATOR_PORT = String(await reservePort());
const repoDir = initRepo();
const gitContext = await getVcsContext(repoDir, 'git');
const server = await startServer({
rawPatch: RAW_PATCH,
gitRef: 'Working tree',
diffType: 'uncommitted',
gitContext,
agentCwd: repoDir,
origin: runtime === 'Pi' ? 'pi' : 'claude-code',
htmlContent: '<!doctype html><html><body>review</body></html>',
});
try {
const res = await fetch(`${server.url}/api/code-nav/hover`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...HOVER_REQUEST, filePath: '../etc/passwd' }),
});
expect(res.status).toBe(400);
} finally {
server.stop();
}
});
}
});