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
@@ -266,14 +266,44 @@ describe('codegraph ui server', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('read-only', () => {
|
||||
it('refuses every method that is not GET or HEAD', async () => {
|
||||
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
|
||||
describe('methods', () => {
|
||||
it('refuses every method it has never answered', async () => {
|
||||
for (const method of ['PUT', 'PATCH', 'OPTIONS', 'TRACE']) {
|
||||
const res = await request(server.port, '/', { method });
|
||||
expect(res.status, method).toBe(405);
|
||||
expect(res.headers['allow']).toBe('GET, HEAD, POST, DELETE');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The static side stayed a pure reader when `/api/trails` gained a write
|
||||
* (CG-60). A POST at an asset path is 405 with `Allow: GET, HEAD` — the
|
||||
* narrower answer, since nothing under the viewer bundle will ever take
|
||||
* one.
|
||||
*/
|
||||
it('refuses a write outside /api/, whatever it carries', async () => {
|
||||
for (const method of ['POST', 'DELETE']) {
|
||||
const res = await request(server.port, '/', {
|
||||
method,
|
||||
headers: { 'X-CodeGraph-UI': '1' },
|
||||
});
|
||||
expect(res.status, method).toBe(405);
|
||||
expect(res.headers['allow']).toBe('GET, HEAD');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Under `/api/` a write is answered as JSON even when refused — the viewer
|
||||
* parses these, and a text/plain body surfaces as a parse error rather than
|
||||
* the refusal it is. No API is mounted on this server, so the refusal is
|
||||
* the boundary's own and not an endpoint's.
|
||||
*/
|
||||
it('refuses an unmarked write under /api/ as JSON', async () => {
|
||||
const res = await request(server.port, '/api/trails', { method: 'POST' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.headers['content-type']).toContain('application/json');
|
||||
expect(JSON.parse(res.body).code).toBe('refused');
|
||||
});
|
||||
});
|
||||
|
||||
describe('paths outside the asset root', () => {
|
||||
|
||||
Reference in New Issue
Block a user