From 1ec38ce0027367abcba16df2918cf8612b501384 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 3 Oct 2026 15:17:07 +0000 Subject: [PATCH 1/2] fix: name the failure when a code map cannot be loaded Commands that read graph.json treated a truncated file, invalid JSON, or an unsupported schema as a missing map, or printed a parser error that quoted the file. They now exit 1, name which failure it is, and say to rebuild with `vg build`. A missing map is still reported as missing. vg-graph/1.0 and vg-graph/1.1 still load. Fixes #274 Signed-off-by: Cursor Agent Co-authored-by: vibgrate-team --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 9 ++ DOCS.md | 6 + src/engine/index-db.ts | 9 +- src/engine/load.test.ts | 298 +++++++++++++++++++++++++++++++++++ src/engine/load.ts | 4 +- src/engine/serialize.ts | 87 +++++++++- src/engine/snapshot.ts | 19 ++- src/index.ts | 3 +- src/mcp/cross-impact.test.ts | 2 +- src/mcp/server.ts | 8 +- src/mcp/tools.ts | 9 +- src/runtime/vgd/server.ts | 17 +- test/snapshot.test.ts | 15 +- 14 files changed, 456 insertions(+), 32 deletions(-) create mode 100644 src/engine/load.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 475150f..7688ac7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -56,7 +56,7 @@ src/ │ ├── hash.ts, ids.ts Content hashing and stable, content-derived IDs │ ├── rng.ts Seeded RNG (no nondeterministic randomness) │ ├── serialize.ts Stable serialization to graph.json -│ ├── load.ts Load graph.json back into memory +│ ├── load.ts Load graph.json; truncated, invalid, or unsupported maps error with a rebuild hint │ ├── query.ts, queries.ts, lookup.ts, test-query.ts Query layer │ ├── impact.ts, paths.ts Graph algorithms │ ├── cache.ts, artifacts.ts On-disk artifacts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2ec89..1aa945b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,15 @@ backward compatible. ### Fixed +- **A damaged code map now fails with a clear error.** Commands that load the + map (`vg show`, `vg impact`, `vg ask`, and the rest) used to treat a + truncated or invalid `graph.json` as if the map were missing, or surface a + parser stack that quoted the file. They now exit `1` and name the failure — + truncated, not valid JSON, or a schema this version cannot read — and say + to rebuild with `vg build`. The message does not include the file. A map + that is simply absent is still reported as missing (exit `3`). Maps this + version already reads (`vg-graph/1.0` and `vg-graph/1.1`) load as before. + - **`vg show arch` clipped the map to a fixed viewport.** Columns that ran off the bottom of the window could not be scrolled or zoomed; the canvas is now a pannable, zoomable map (scroll or drag, pinch / Ctrl-scroll, + / −). diff --git a/DOCS.md b/DOCS.md index 6ad775e..e62c271 100644 --- a/DOCS.md +++ b/DOCS.md @@ -3369,6 +3369,12 @@ CI and agents branch on these, so they are a stable contract. The same rule is why [`vg review`](#vg-review) exits `6` when there is no code map, and why `--explain` exits `6` rather than quietly producing a review no model contributed to. +A code map that is **missing** stays exit `3` (`no map found`). A map that is +on disk but truncated, not valid JSON, or written with a schema this version +cannot read (`vg-graph/1.0` and `vg-graph/1.1` are the ones it reads) exits `1` +instead. The message names which of those it is and says to rebuild with +`vg build`. It does not include the file. + --- ## Programmatic API diff --git a/src/engine/index-db.ts b/src/engine/index-db.ts index 43b9b93..aaf539e 100644 --- a/src/engine/index-db.ts +++ b/src/engine/index-db.ts @@ -15,6 +15,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { cacheDir } from './cache.js'; import { loadGraphFileWithSnapshot } from './snapshot.js'; +import { assertSupportedGraph, isSupportedSchemaVersion } from './serialize.js'; import type { Area, EpistemicTier, @@ -351,7 +352,9 @@ export function loadGraphFromIndex(root: string): VgGraph | null { /** * Load the graph preferring the SQLite index when its corpusHash matches the * committed graph.json provenance (or when only the index exists). Falls back - * to graph.json. Returns null if neither is usable. + * to graph.json. Returns null when neither is present. A present `graph.json` + * that this version cannot read throws `GraphLoadError` (see `parseGraph`) + * instead of falling through to a stale index. */ export function loadGraphPreferIndex( root: string, @@ -363,12 +366,14 @@ export function loadGraphPreferIndex( const jsonGraph: VgGraph | null = loadGraphFileWithSnapshot(graphJsonPath); const fromIndex = loadGraphFromIndex(root); - if (fromIndex) { + if (fromIndex && isSupportedSchemaVersion(fromIndex.schemaVersion)) { if (!jsonGraph || fromIndex.provenance.corpusHash === jsonGraph.provenance.corpusHash) { return { graph: fromIndex, source: 'index' }; } // Index stale vs graph.json — prefer canonical JSON and let next build refresh. } if (jsonGraph) return { graph: jsonGraph, source: 'json' }; + // The index is the only map, and its schema is not one this version reads. + if (fromIndex) assertSupportedGraph(fromIndex); return null; } diff --git a/src/engine/load.test.ts b/src/engine/load.test.ts new file mode 100644 index 0000000..1c7da1b --- /dev/null +++ b/src/engine/load.test.ts @@ -0,0 +1,298 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildProgram } from '../cli.js'; +import { CliError, ExitCode } from '../util/exit.js'; +import { loadGraph } from './load.js'; +import { GraphLoadError, parseGraph, serializeGraph } from './serialize.js'; +import { writeGraphSnapshot } from './snapshot.js'; +import type { SupportedSchemaVersion, VgGraph } from '../schema.js'; + +/** + * A truncated, invalid, or unsupported code map must stop the commands that + * load it. A missing map stays "missing". A map this version already reads + * still loads. Messages name the failure, say to rebuild, and do not quote + * the file. + */ + +const SENTINEL = 'SECRET_SENTINEL_7f3a'; + +const TRUNCATED = 'code map is truncated — rebuild it with `vg build`'; +const INVALID = 'code map is not valid JSON — rebuild it with `vg build`'; +const UNSUPPORTED_NAMED = + 'code map uses schema vg-graph/9.0, which this version cannot read — rebuild it with `vg build`'; +const UNSUPPORTED_UNNAMED = + 'code map uses a schema this version cannot read — rebuild it with `vg build`'; + +const dirs: string[] = []; + +afterEach(() => { + for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +function tempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-load-')); + dirs.push(dir); + return dir; +} + +function supported(schemaVersion: SupportedSchemaVersion): VgGraph { + return { + schemaVersion, + generatedAt: '2026-01-01T00:00:00.000Z', + provenance: { + tool: 'vg', + version: '0.0.0-test', + grammars: {}, + resolver: ['heuristic'], + deep: false, + corpusHash: 'abc', + }, + meta: { + root: '.', + languages: ['typescript'], + counts: { nodes: 1, edges: 0, areas: 0, tests: 0, untested: 0 }, + cluster: 'none', + edgeKinds: [], + }, + nodes: [ + { + id: 'n1', + kind: 'function', + name: 'alpha', + qualifiedName: 'src/a.ts:alpha', + file: 'src/a.ts', + span: { start: 1, end: 2 }, + lang: 'typescript', + importance: 0.5, + centrality: { degree: 0, pagerank: 0, betweenness: 0, eigenvector: 0 }, + area: -1, + isHub: false, + tested: null, + }, + ], + edges: [], + areas: [], + }; +} + +/** Cut a real map after a sentinel so the file bytes include it and the JSON does not close. */ +function truncatedDocument(): string { + const full = serializeGraph(supported('vg-graph/1.1')).replace('alpha', `alpha ${SENTINEL}`); + const at = full.indexOf(SENTINEL); + if (at < 0) throw new Error('sentinel missing from fixture'); + return full.slice(0, at + SENTINEL.length); +} + +function writeMap(dir: string, body: string): string { + const file = path.join(dir, 'graph.json'); + fs.writeFileSync(file, body); + return file; +} + +async function run(args: string[]): Promise { + const program = buildProgram(); + program.exitOverride(); + const chunks: string[] = []; + const spy = vi.spyOn(process.stdout, 'write').mockImplementation((s: string | Uint8Array) => { + chunks.push(String(s)); + return true; + }); + try { + await program.parseAsync(args, { from: 'user' }); + } finally { + spy.mockRestore(); + } + return chunks.join(''); +} + +async function fail(args: string[]): Promise { + try { + await run(args); + } catch (err) { + return err; + } + throw new Error(`expected ${args.join(' ')} to fail`); +} + +function assertNoFileContents(message: string): void { + expect(message).not.toContain(SENTINEL); + expect(message).not.toContain('Unexpected token'); +} + +describe('parseGraph / loadGraph', () => { + it('rejects a truncated map without quoting the file', () => { + const body = truncatedDocument(); + expect(body).toContain(SENTINEL); + let thrown: unknown; + try { + parseGraph(body); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(GraphLoadError); + const error = thrown as GraphLoadError; + expect(error.failure).toBe('truncated'); + expect(error.code).toBe(ExitCode.ERROR); + expect(error.message).toBe(TRUNCATED); + assertNoFileContents(error.message); + }); + + it('rejects invalid JSON without quoting the file', () => { + const body = `{ "note": "${SENTINEL}" oops`; + let thrown: unknown; + try { + parseGraph(body); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(GraphLoadError); + const error = thrown as GraphLoadError; + expect(error.failure).toBe('invalid-json'); + expect(error.code).toBe(ExitCode.ERROR); + expect(error.message).toBe(INVALID); + assertNoFileContents(error.message); + }); + + it('rejects a schema this version cannot read, and does not echo an unsafe schema string', () => { + const named = JSON.stringify({ + schemaVersion: 'vg-graph/9.0', + nodes: [{ name: SENTINEL }], + edges: [], + areas: [], + }); + const namedErr = (() => { + try { + parseGraph(named); + } catch (e) { + return e; + } + throw new Error('expected named schema to fail'); + })(); + expect(namedErr).toBeInstanceOf(GraphLoadError); + expect((namedErr as GraphLoadError).failure).toBe('unsupported-schema'); + expect((namedErr as GraphLoadError).message).toBe(UNSUPPORTED_NAMED); + assertNoFileContents((namedErr as GraphLoadError).message); + + const unsafe = JSON.stringify({ schemaVersion: `${SENTINEL}\nnot-a-token`, nodes: [] }); + let unsafeErr: unknown; + try { + parseGraph(unsafe); + } catch (e) { + unsafeErr = e; + } + expect(unsafeErr).toBeInstanceOf(GraphLoadError); + expect((unsafeErr as GraphLoadError).message).toBe(UNSUPPORTED_UNNAMED); + assertNoFileContents((unsafeErr as GraphLoadError).message); + }); + + it('returns null when the map is missing', () => { + const dir = tempDir(); + const missing = path.join(dir, 'absent.json'); + expect(loadGraph(dir, missing)).toBeNull(); + }); + + it('loads maps this version already supports', () => { + for (const schema of ['vg-graph/1.0', 'vg-graph/1.1'] as const) { + const graph = supported(schema); + const text = serializeGraph(graph); + expect(parseGraph(text)).toEqual(graph); + const dir = tempDir(); + const file = writeMap(dir, text); + expect(loadGraph(dir, file)).toEqual(graph); + } + }); + + it('rejects an unsupported schema carried by a standalone snapshot', () => { + const dir = tempDir(); + const file = path.join(dir, 'graph.json'); + const graph = supported('vg-graph/1.0'); + (graph as { schemaVersion: string }).schemaVersion = 'vg-graph/9.0'; + expect(writeGraphSnapshot(file, graph, { standalone: true })).toBe(true); + expect(() => loadGraph(dir, file)).toThrow(GraphLoadError); + try { + loadGraph(dir, file); + } catch (e) { + expect((e as GraphLoadError).message).toBe(UNSUPPORTED_NAMED); + assertNoFileContents((e as GraphLoadError).message); + } + }); +}); + +describe('commands that load the code map', () => { + const commands = ['show', 'impact', 'ask'] as const; + + async function loadFailure(command: (typeof commands)[number], file: string, dir: string): Promise { + const err = await fail([command, 'alpha', '--graph', file, '--cwd', dir]); + expect(err).toBeInstanceOf(GraphLoadError); + expect(err).toBeInstanceOf(CliError); + const error = err as GraphLoadError; + expect(error.code).toBe(ExitCode.ERROR); + expect(error.code).not.toBe(0); + assertNoFileContents(error.message); + return error; + } + + it('stops with a non-zero truncated error', async () => { + const dir = tempDir(); + const file = writeMap(dir, truncatedDocument()); + for (const command of commands) { + const error = await loadFailure(command, file, dir); + expect(error.failure).toBe('truncated'); + expect(error.message).toBe(TRUNCATED); + } + }); + + it('stops with a non-zero invalid-JSON error', async () => { + const dir = tempDir(); + const file = writeMap(dir, `{ "note": "${SENTINEL}" oops`); + for (const command of commands) { + const error = await loadFailure(command, file, dir); + expect(error.failure).toBe('invalid-json'); + expect(error.message).toBe(INVALID); + } + }); + + it('stops with a non-zero unsupported-schema error', async () => { + const dir = tempDir(); + const file = writeMap( + dir, + JSON.stringify({ schemaVersion: 'vg-graph/9.0', nodes: [{ name: SENTINEL }], edges: [], areas: [] }), + ); + for (const command of commands) { + const error = await loadFailure(command, file, dir); + expect(error.failure).toBe('unsupported-schema'); + expect(error.message).toBe(UNSUPPORTED_NAMED); + } + }); + + it('still reports a missing map as missing', async () => { + const dir = tempDir(); + const missing = path.join(dir, 'absent.json'); + for (const command of commands) { + const err = await fail([command, 'alpha', '--graph', missing, '--cwd', dir]); + expect(err).toBeInstanceOf(CliError); + expect(err).not.toBeInstanceOf(GraphLoadError); + const error = err as CliError; + expect(error.code).toBe(ExitCode.NOT_FOUND); + expect(error.message).toContain('no map found'); + expect(error.message).not.toContain('truncated'); + expect(error.message).not.toContain('not valid JSON'); + expect(error.message).not.toContain('cannot read'); + expect(error.message).not.toContain('`vg build`'); + } + }); + + it('loads a supported map for show and impact', async () => { + for (const schema of ['vg-graph/1.0', 'vg-graph/1.1'] as const) { + const dir = tempDir(); + const file = writeMap(dir, serializeGraph(supported(schema))); + const shown = await run(['show', 'alpha', '--json', '--graph', file, '--cwd', dir]); + expect(JSON.parse(shown).name).toBe('src/a.ts:alpha'); + const impact = await run(['impact', 'alpha', '--json', '--graph', file, '--cwd', dir]); + expect(JSON.parse(impact).root.name).toBe('src/a.ts:alpha'); + } + }); +}); diff --git a/src/engine/load.ts b/src/engine/load.ts index c62dc63..e7db3dd 100644 --- a/src/engine/load.ts +++ b/src/engine/load.ts @@ -9,7 +9,9 @@ import type { VgGraph } from '../schema.js'; * When `graphPath` is omitted, prefers an existing global-store snapshot, then * the legacy `.vibgrate/graph.json`, matching {@link resolveGraphPath}. * Prefers the SQLite index when its corpusHash matches the committed map - * (faster cold serve on large repos). Returns null if none exists. + * (faster cold serve on large repos). Returns null when no map exists. + * Throws `GraphLoadError` when a map is present but truncated, not valid + * JSON, or from a schema this version cannot read. */ export function loadGraph(root: string, graphPath?: string): VgGraph | null { const file = resolveGraphPath(root, graphPath); diff --git a/src/engine/serialize.ts b/src/engine/serialize.ts index 157e384..29470b4 100644 --- a/src/engine/serialize.ts +++ b/src/engine/serialize.ts @@ -1,4 +1,5 @@ -import type { VgGraph } from '../schema.js'; +import { SUPPORTED_SCHEMA_VERSIONS, type SupportedSchemaVersion, type VgGraph } from '../schema.js'; +import { CliError, ExitCode } from '../util/exit.js'; /** * Deterministic serialization of `graph.json`. @@ -51,6 +52,88 @@ function sortKeys(value: unknown): unknown { return value; } +/** Why a code map on disk could not be loaded. */ +export type GraphLoadFailure = 'truncated' | 'invalid-json' | 'unsupported-schema'; + +const REBUILD_HINT = 'rebuild it with `vg build`'; + +/** + * Schema tokens safe to repeat in an error. Anything else (spaces, newlines, + * a long blob) is treated as file contents and is not echoed. + */ +const SCHEMA_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._+/-]{0,63}$/; + +/** + * A code map exists but this process cannot use it. The message names the + * failure and tells the operator to rebuild; it never includes file contents. + * Exit code is {@link ExitCode.ERROR}, distinct from a missing map. + */ +export class GraphLoadError extends CliError { + readonly failure: GraphLoadFailure; + + constructor(failure: GraphLoadFailure, schemaVersion?: string) { + super(graphLoadMessage(failure, schemaVersion), ExitCode.ERROR); + this.name = 'GraphLoadError'; + this.failure = failure; + } +} + +export function graphLoadMessage(failure: GraphLoadFailure, schemaVersion?: string): string { + switch (failure) { + case 'truncated': + return `code map is truncated — ${REBUILD_HINT}`; + case 'invalid-json': + return `code map is not valid JSON — ${REBUILD_HINT}`; + case 'unsupported-schema': + return schemaVersion + ? `code map uses schema ${schemaVersion}, which this version cannot read — ${REBUILD_HINT}` + : `code map uses a schema this version cannot read — ${REBUILD_HINT}`; + } +} + +export function isSupportedSchemaVersion(value: unknown): value is SupportedSchemaVersion { + return typeof value === 'string' && (SUPPORTED_SCHEMA_VERSIONS as readonly string[]).includes(value); +} + +/** + * Accept a parsed value only when its `schemaVersion` is one this build can + * read (`vg-graph/1.0` and `vg-graph/1.1`). Structural fields are not + * re-checked here — a supported document loads as written. + */ +export function assertSupportedGraph(value: unknown): VgGraph { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new GraphLoadError('unsupported-schema'); + } + const schemaVersion = (value as { schemaVersion?: unknown }).schemaVersion; + if (!isSupportedSchemaVersion(schemaVersion)) { + const label = typeof schemaVersion === 'string' && SCHEMA_TOKEN.test(schemaVersion) ? schemaVersion : undefined; + throw new GraphLoadError('unsupported-schema', label); + } + return value as VgGraph; +} + export function parseGraph(json: string): VgGraph { - return JSON.parse(json) as VgGraph; + let value: unknown; + try { + value = JSON.parse(json); + } catch (err) { + throw new GraphLoadError(jsonFailure(json, err)); + } + return assertSupportedGraph(value); +} + +/** + * Truncation is "the document ended before it was complete": unexpected EOF, + * an unterminated string, or a syntax error parked at the end of the input. + * A bad token in the middle is invalid JSON. The parser's own message is + * never returned — it can quote the file. + */ +function jsonFailure(text: string, err: unknown): 'truncated' | 'invalid-json' { + const message = err instanceof Error ? err.message : ''; + if (message.includes('Unexpected end of JSON input') || message.includes('Unterminated string')) { + return 'truncated'; + } + const at = /position (\d+)/.exec(message); + if (at && Number(at[1]) >= text.trimEnd().length) return 'truncated'; + return 'invalid-json'; } diff --git a/src/engine/snapshot.ts b/src/engine/snapshot.ts index 887913d..a1b6eaf 100644 --- a/src/engine/snapshot.ts +++ b/src/engine/snapshot.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import * as zlib from 'node:zlib'; import { Packr } from 'msgpackr'; -import { parseGraph } from './serialize.js'; +import { assertSupportedGraph, parseGraph } from './serialize.js'; import { VERSION } from '../version.js'; import type { VgGraph } from '../schema.js'; @@ -216,24 +216,23 @@ function readSnapshotHeader(file: string): SnapshotHeader | null { * Load a map file, preferring the binary snapshot and self-healing it. * Fast path: valid snapshot (sidecar or standalone) → decode it. Fallback: * parse `graph.json`, then (best-effort) rewrite the sidecar so the next load - * takes the fast path. Returns null only when neither representation yields a - * graph — exactly the cases the pre-snapshot code treated as "no graph". + * takes the fast path. + * + * Returns null only when no map file is there. A file that is truncated, not + * valid JSON, or written with a schema this version cannot read throws + * `GraphLoadError` — it is not reported as missing, and the parser message + * (which can quote the file) is not surfaced. */ export function loadGraphFileWithSnapshot(graphPath: string): VgGraph | null { const snap = readGraphSnapshot(graphPath); - if (snap) return snap; + if (snap) return assertSupportedGraph(snap); let json: string; try { json = fs.readFileSync(graphPath, 'utf8'); } catch { return null; } - let graph: VgGraph; - try { - graph = parseGraph(json); - } catch { - return null; - } + const graph = parseGraph(json); writeGraphSnapshot(graphPath, graph); return graph; } diff --git a/src/index.ts b/src/index.ts index 88d4fa2..86106e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,8 @@ export { resolveLimits, ResourceLimitError } from './engine/limits.js'; export type { ResourceLimits } from './engine/limits.js'; export { loadGraph } from './engine/load.js'; -export { serializeGraph, parseGraph, stableStringify } from './engine/serialize.js'; +export { serializeGraph, parseGraph, stableStringify, GraphLoadError } from './engine/serialize.js'; +export type { GraphLoadFailure } from './engine/serialize.js'; export { writeArtifacts, defaultGraphPath, diff --git a/src/mcp/cross-impact.test.ts b/src/mcp/cross-impact.test.ts index c558ac6..82ec751 100644 --- a/src/mcp/cross-impact.test.ts +++ b/src/mcp/cross-impact.test.ts @@ -35,7 +35,7 @@ function node(id: string, name: string, file: string, over: Partial = function graphWith(nodes: GraphNode[], corpusHash: string): VgGraph { return { - schemaVersion: 1, + schemaVersion: 'vg-graph/1.0', generatedAt: 'x', provenance: { corpusHash }, meta: { root: '.', languages: ['typescript'], counts: { nodes: nodes.length, edges: 0, areas: 1, tests: 0, untested: 0 }, cluster: 'none' }, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 42e0c3e..c3cbb91 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -4,7 +4,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { parseGraph } from '../engine/serialize.js'; +import { GraphLoadError, parseGraph } from '../engine/serialize.js'; import { mapFileStat } from '../engine/snapshot.js'; import { loadGraphPreferIndex } from '../engine/index-db.js'; import type { RefreshOutcome, refreshIfStale } from '../engine/refresh.js'; @@ -570,7 +570,11 @@ export function createServer(source: GraphSource, opts: ServeOptions = {}): Serv let graph: VgGraph; try { graph = await source.get(); - } catch { + } catch (err) { + // A truncated, invalid, or unsupported map is not "missing" — repeat + // the loader's message (it already says to rebuild, and it does not + // quote the file). Only a genuine absence stays the missing-map error. + if (err instanceof GraphLoadError) return errorResult(err.message); return errorResult( 'no code map found. Run `vg` in the project to build .vibgrate/graph.json, then retry.', ); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 75333fc..aa4115f 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -15,7 +15,7 @@ import { coveringTests } from '../engine/test-query.js'; import { loadOrDiscoverFederation } from '../runtime/federation.js'; import { highConfidenceBridges } from '../runtime/bridge-edges.js'; import { repositoryIdFromRoot } from '../runtime/paths.js'; -import { parseGraph } from '../engine/serialize.js'; +import { GraphLoadError, parseGraph } from '../engine/serialize.js'; import { loadVulnerabilities, filterBySeverity, resolvePackageTarget, openFixableAdvisories } from './vuln-data.js'; import { attributedInventory } from './attribution.js'; import { computeUpgradeImpact, getChangelogSignals, type VulnSeverity } from '../core-open/index.js'; @@ -622,8 +622,11 @@ export const TOOLS: VgTool[] = [ graph: { corpusHash: mg.provenance.corpusHash }, matches, }; - } catch { - return { member: m.label, root: m.root, via, graph: null, note: 'member map unreadable — rebuild with `vg`' }; + } catch (err) { + const note = err instanceof GraphLoadError + ? err.message + : 'member map unreadable — rebuild it with `vg build`'; + return { member: m.label, root: m.root, via, graph: null, note }; } }); diff --git a/src/runtime/vgd/server.ts b/src/runtime/vgd/server.ts index 3691d7e..a1dc9ab 100644 --- a/src/runtime/vgd/server.ts +++ b/src/runtime/vgd/server.ts @@ -86,10 +86,19 @@ export async function startVgdServer(options: VgdServerOptions = {}): Promise { - const graph = loadGraph(root); - if (!graph) return Promise.resolve(null); - registry.putGraph(repositoryId, gitRef, graph); - return Promise.resolve(graph.nodes?.length ?? 0); + try { + const graph = loadGraph(root); + if (!graph) return Promise.resolve(null); + registry.putGraph(repositoryId, gitRef, graph); + return Promise.resolve(graph.nodes?.length ?? 0); + } catch (err) { + // A truncated or unreadable map must not reject the watcher. The + // message already names the failure and how to rebuild. + log( + `freshness: could not reload the code map for ${repositoryId} — ${err instanceof Error ? err.message : String(err)}`, + ); + return Promise.resolve(null); + } }, select: (repositoryId, gitRef) => registry.selectGitRef(repositoryId, gitRef), }); diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index d934a6c..5af7140 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -19,13 +19,15 @@ import type { VgGraph } from '../src/schema.js'; /** * The binary snapshot is a derived cache: graph.json stays canonical, and - * every failure mode below must degrade to reading the JSON — never throw, - * never serve stale data after the JSON changed. + * every snapshot failure mode below must degrade to reading the JSON — never + * throw because a snapshot is stale or corrupt, never serve stale data after + * the JSON changed. A graph.json that is itself unreadable throws instead of + * pretending the map is missing. */ function sampleGraph(mutate?: (g: VgGraph) => void): VgGraph { const graph = { - schemaVersion: 1, + schemaVersion: 'vg-graph/1.0', generatedAt: '2026-01-01T00:00:00.000Z', provenance: { corpusHash: 'abc123' }, nodes: [ @@ -126,10 +128,13 @@ describe('graph snapshot sidecar', () => { expect(readGraphSnapshot(graphPath)).toEqual(graph); }); - it('returns null for missing or unparseable JSON (pre-snapshot behaviour)', () => { + it('returns null when the map file is missing', () => { expect(loadGraphFileWithSnapshot(graphPath)).toBeNull(); + }); + + it('throws when graph.json is not valid JSON instead of treating it as missing', () => { fs.writeFileSync(graphPath, '{ definitely not json'); - expect(loadGraphFileWithSnapshot(graphPath)).toBeNull(); + expect(() => loadGraphFileWithSnapshot(graphPath)).toThrow(/not valid JSON/); }); it('write is best-effort: unwritable sidecar directory does not throw', () => { From 1e216d51848a4b3641fc75ca58ff652b78b16810 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 3 Oct 2026 15:19:45 +0000 Subject: [PATCH 2/2] test: give the freshness fixture a schema this version can read GraphSource.get now refuses a map with no schema. The freshness tests only need a readable file so the probe can run. Signed-off-by: Cursor Agent Co-authored-by: vibgrate-team --- src/mcp/staleness.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/mcp/staleness.test.ts b/src/mcp/staleness.test.ts index 7245248..4c903ce 100644 --- a/src/mcp/staleness.test.ts +++ b/src/mcp/staleness.test.ts @@ -16,7 +16,9 @@ function makeSource(refreshImpl: RefreshImpl): { source: GraphSource; root: stri const root = fs.mkdtempSync(path.join(os.tmpdir(), 'staleness-')); const graphPath = path.join(root, '.vibgrate', 'graph.json'); fs.mkdirSync(path.dirname(graphPath), { recursive: true }); - fs.writeFileSync(graphPath, '{}'); + // A schema this version reads. `{}` is not a map, and get() must refuse it + // before the freshness probe runs. + fs.writeFileSync(graphPath, '{"schemaVersion":"vg-graph/1.1"}\n'); const source = new GraphSource(graphPath, true, { root, refreshImpl, probeIntervalMs: 0, refreshBudgetMs: 5_000 }); return { source, root }; }