mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
91ad7c95fc
* 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.
241 lines
8.4 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
}
|
|
});
|