diff --git a/__tests__/review-context.test.ts b/__tests__/review-context.test.ts
new file mode 100644
index 000000000..fe5512621
--- /dev/null
+++ b/__tests__/review-context.test.ts
@@ -0,0 +1,646 @@
+/**
+ * Review Context Tests
+ *
+ * Verifies:
+ *   - parseDiff handles standard git unified-diff shapes (modified,
+ *     added, deleted, renamed, multiple hunks).
+ *   - symbolsTouchedByHunks correctly maps line ranges to symbols.
+ *   - buildReviewContext attaches callers, callees, impact, tests
+ *     for affected symbols.
+ *   - Co-change warnings surface when a changed file's historical
+ *     co-changers were NOT touched.
+ *   - Graceful degrade: pre-#105 install (no co_changes table) and
+ *     pre-#106 install (no `tests` edges) — return empty rather than
+ *     throwing.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { parseDiff, symbolsTouchedByHunks } from '../src/review/diff-parser';
+import { buildReviewContext } from '../src/review';
+import { DatabaseConnection } from '../src/db';
+import { QueryBuilder } from '../src/db/queries';
+import { GraphTraverser } from '../src/graph/traversal';
+import { Node, Edge } from '../src/types';
+
+// =============================================================================
+// parseDiff
+// =============================================================================
+
+describe('parseDiff', () => {
+  it('parses a simple modified-file diff', () => {
+    const diff = `diff --git a/src/foo.ts b/src/foo.ts
+index abc..def 100644
+--- a/src/foo.ts
++++ b/src/foo.ts
+@@ -10,3 +10,5 @@
+ unchanged
+-old line
++new line one
++new line two
+ also unchanged`;
+    const files = parseDiff(diff);
+    expect(files).toHaveLength(1);
+    expect(files[0].path).toBe('src/foo.ts');
+    expect(files[0].status).toBe('modified');
+    expect(files[0].hunks).toEqual([
+      { oldStart: 10, oldCount: 3, newStart: 10, newCount: 5 },
+    ]);
+  });
+
+  it('detects file additions via /dev/null in the --- header', () => {
+    const diff = `diff --git a/new.ts b/new.ts
+new file mode 100644
+index 0000000..abc
+--- /dev/null
++++ b/new.ts
+@@ -0,0 +1,3 @@
++a
++b
++c`;
+    const files = parseDiff(diff);
+    expect(files).toHaveLength(1);
+    expect(files[0].status).toBe('added');
+    expect(files[0].path).toBe('new.ts');
+  });
+
+  it('detects file deletions via /dev/null in the +++ header', () => {
+    const diff = `diff --git a/gone.ts b/gone.ts
+deleted file mode 100644
+index abc..0000000
+--- a/gone.ts
++++ /dev/null
+@@ -1,3 +0,0 @@
+-a
+-b
+-c`;
+    const files = parseDiff(diff);
+    expect(files).toHaveLength(1);
+    expect(files[0].status).toBe('deleted');
+    expect(files[0].path).toBe('gone.ts');
+  });
+
+  it('detects renames and exposes oldPath', () => {
+    const diff = `diff --git a/old.ts b/new.ts
+similarity index 95%
+rename from old.ts
+rename to new.ts
+index abc..def 100644
+--- a/old.ts
++++ b/new.ts
+@@ -1,2 +1,2 @@
+-old name
++new name
+ unchanged`;
+    const files = parseDiff(diff);
+    expect(files).toHaveLength(1);
+    expect(files[0].status).toBe('renamed');
+    expect(files[0].path).toBe('new.ts');
+    expect(files[0].oldPath).toBe('old.ts');
+  });
+
+  it('handles multi-file, multi-hunk diffs', () => {
+    const diff = `diff --git a/a.ts b/a.ts
+index abc..def 100644
+--- a/a.ts
++++ b/a.ts
+@@ -10,3 +10,4 @@
+ ctx
++added
+ ctx
+ ctx
+@@ -20,2 +21,2 @@
+-old
++new
+ ctx
+diff --git a/b.ts b/b.ts
+index 111..222 100644
+--- a/b.ts
++++ b/b.ts
+@@ -5,1 +5,1 @@
+-x
++y`;
+    const files = parseDiff(diff);
+    expect(files).toHaveLength(2);
+    expect(files[0].path).toBe('a.ts');
+    expect(files[0].hunks).toHaveLength(2);
+    expect(files[1].path).toBe('b.ts');
+    expect(files[1].hunks).toHaveLength(1);
+  });
+
+  it('returns [] for empty input', () => {
+    expect(parseDiff('')).toEqual([]);
+  });
+
+  it('emits a hunk-less rename even when followed by another hunked file', () => {
+    // Regression: previously a rename-only file mid-diff was silently
+    // dropped because the EOF-only hunk-less flush never fired before
+    // the next `diff --git` header arrived.
+    const diff = `diff --git a/old.ts b/new.ts
+similarity index 100%
+rename from old.ts
+rename to new.ts
+diff --git a/other.ts b/other.ts
+index abc..def 100644
+--- a/other.ts
++++ b/other.ts
+@@ -1,1 +1,1 @@
+-x
++y`;
+    const files = parseDiff(diff);
+    expect(files).toHaveLength(2);
+    expect(files[0].status).toBe('renamed');
+    expect(files[0].path).toBe('new.ts');
+    expect(files[0].oldPath).toBe('old.ts');
+    expect(files[1].path).toBe('other.ts');
+    expect(files[1].status).toBe('modified');
+  });
+
+  it('emits a hunk-less file-mode-change followed by another file', () => {
+    const diff = `diff --git a/script.sh b/script.sh
+old mode 100644
+new mode 100755
+diff --git a/foo.ts b/foo.ts
+index abc..def 100644
+--- a/foo.ts
++++ b/foo.ts
+@@ -1,1 +1,1 @@
+-a
++b`;
+    const files = parseDiff(diff);
+    // The mode-change file has no add/delete/rename markers so it
+    // doesn't qualify as hunk-less for our purposes — it's silently
+    // skipped (current implementation). The hunked file MUST still
+    // be emitted, and that's the regression risk.
+    expect(files.find((f) => f.path === 'foo.ts')).toBeDefined();
+  });
+
+  it('strips C-style quoting from paths with spaces or special chars', () => {
+    const diff = `diff --git "a/path with spaces.ts" "b/path with spaces.ts"
+index abc..def 100644
+--- "a/path with spaces.ts"
++++ "b/path with spaces.ts"
+@@ -1,1 +1,1 @@
+-a
++b`;
+    const files = parseDiff(diff);
+    expect(files).toHaveLength(1);
+    expect(files[0].path).toBe('path with spaces.ts');
+    expect(files[0].path).not.toContain('"');
+  });
+
+  it('handles single-line hunk header (no comma)', () => {
+    // git emits `@@ -5 +5 @@` for one-line hunks (count of 1 elided).
+    const diff = `diff --git a/x.ts b/x.ts
+index abc..def 100644
+--- a/x.ts
++++ b/x.ts
+@@ -5 +5 @@
+-old
++new`;
+    const files = parseDiff(diff);
+    expect(files[0].hunks[0]).toEqual({
+      oldStart: 5,
+      oldCount: 1,
+      newStart: 5,
+      newCount: 1,
+    });
+  });
+});
+
+// =============================================================================
+// symbolsTouchedByHunks
+// =============================================================================
+
+describe('symbolsTouchedByHunks', () => {
+  const sym = (startLine: number, endLine: number, name = 'sym') => ({ startLine, endLine, name });
+
+  it('returns symbols whose range overlaps any hunk', () => {
+    const symbols = [sym(1, 5, 'a'), sym(10, 20, 'b'), sym(50, 60, 'c')];
+    const hunks = [{ oldStart: 12, oldCount: 3, newStart: 12, newCount: 3 }];
+    const out = symbolsTouchedByHunks(hunks, symbols);
+    expect(out.map((s) => s.name)).toEqual(['b']);
+  });
+
+  it('matches a symbol that fully contains the hunk', () => {
+    const symbols = [sym(1, 100, 'big')];
+    const hunks = [{ oldStart: 50, oldCount: 1, newStart: 50, newCount: 1 }];
+    expect(symbolsTouchedByHunks(hunks, symbols).map((s) => s.name)).toEqual(['big']);
+  });
+
+  it('matches a symbol fully contained by the hunk', () => {
+    const symbols = [sym(50, 55, 'small')];
+    const hunks = [{ oldStart: 10, oldCount: 100, newStart: 10, newCount: 100 }];
+    expect(symbolsTouchedByHunks(hunks, symbols).map((s) => s.name)).toEqual(['small']);
+  });
+
+  it('does not match symbols outside any hunk', () => {
+    const symbols = [sym(1, 5, 'before'), sym(50, 60, 'after')];
+    const hunks = [{ oldStart: 20, oldCount: 5, newStart: 20, newCount: 5 }];
+    expect(symbolsTouchedByHunks(hunks, symbols)).toEqual([]);
+  });
+
+  it('returns [] when hunks or symbols are empty', () => {
+    expect(symbolsTouchedByHunks([], [sym(1, 5)])).toEqual([]);
+    expect(symbolsTouchedByHunks([{ oldStart: 1, oldCount: 1, newStart: 1, newCount: 1 }], [])).toEqual([]);
+  });
+});
+
+// =============================================================================
+// buildReviewContext (integration)
+// =============================================================================
+
+function makeNode(id: string, name: string, kind: Node['kind'], filePath: string, startLine: number, endLine: number): Node {
+  return {
+    id,
+    kind,
+    name,
+    qualifiedName: `${filePath}::${name}`,
+    filePath,
+    language: 'typescript',
+    startLine,
+    endLine,
+    startColumn: 0,
+    endColumn: 0,
+    updatedAt: Date.now(),
+  };
+}
+
+describe('buildReviewContext (integration)', () => {
+  let dir: string;
+  let db: DatabaseConnection;
+  let q: QueryBuilder;
+  let traverser: GraphTraverser;
+
+  beforeEach(() => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'review-ctx-'));
+    db = DatabaseConnection.initialize(path.join(dir, 'test.db'));
+    q = new QueryBuilder(db.getDb());
+    traverser = new GraphTraverser(q);
+
+    // Set up a small graph:
+    //   src/foo.ts contains `doFoo` (lines 5-15)
+    //   src/bar.ts contains `useFoo` (lines 1-10) which calls doFoo
+    //   src/baz.ts contains `helper` (lines 20-30) which doFoo calls
+    const upsertFile = db.getDb().prepare(`
+      INSERT INTO files (path, content_hash, language, size, modified_at, indexed_at)
+      VALUES (?, '', 'typescript', 0, 0, 0)
+    `);
+    upsertFile.run('src/foo.ts');
+    upsertFile.run('src/bar.ts');
+    upsertFile.run('src/baz.ts');
+
+    q.insertNodes([
+      makeNode('foo', 'doFoo', 'function', 'src/foo.ts', 5, 15),
+      makeNode('bar', 'useFoo', 'function', 'src/bar.ts', 1, 10),
+      makeNode('baz', 'helper', 'function', 'src/baz.ts', 20, 30),
+    ]);
+
+    // Edges: useFoo -> doFoo (calls), doFoo -> helper (calls)
+    const callEdge = (source: string, target: string, line: number): Edge => ({
+      source,
+      target,
+      kind: 'calls',
+      line,
+    });
+    q.insertEdges([callEdge('bar', 'foo', 5), callEdge('foo', 'baz', 12)]);
+  });
+
+  afterEach(() => {
+    db.close();
+    if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  function modifyDoFooDiff(): string {
+    return `diff --git a/src/foo.ts b/src/foo.ts
+index abc..def 100644
+--- a/src/foo.ts
++++ b/src/foo.ts
+@@ -10,3 +10,4 @@
+ ctx
+-old impl
++new impl
++plus one
+ ctx`;
+  }
+
+  it('attaches callers and callees for affected symbols', () => {
+    const ctx = buildReviewContext(modifyDoFooDiff(), q, traverser);
+    expect(ctx.files).toHaveLength(1);
+    expect(ctx.files[0].affectedSymbols).toHaveLength(1);
+    const sym = ctx.files[0].affectedSymbols[0];
+    expect(sym.name).toBe('doFoo');
+    expect(sym.callers.map((c) => c.name)).toContain('useFoo');
+    expect(sym.callees.map((c) => c.name)).toContain('helper');
+  });
+
+  it('summarizes correctly across an added + modified + deleted set', () => {
+    const diff = `diff --git a/src/foo.ts b/src/foo.ts
+--- a/src/foo.ts
++++ b/src/foo.ts
+@@ -10,1 +10,1 @@
+-x
++y
+diff --git a/src/added.ts b/src/added.ts
+new file mode 100644
+--- /dev/null
++++ b/src/added.ts
+@@ -0,0 +1,1 @@
++content
+diff --git a/src/baz.ts b/src/baz.ts
+deleted file mode 100644
+--- a/src/baz.ts
++++ /dev/null
+@@ -1,1 +0,0 @@
+-x`;
+    const ctx = buildReviewContext(diff, q, traverser);
+    expect(ctx.summary.filesAdded).toBe(1);
+    expect(ctx.summary.filesModified).toBe(1);
+    expect(ctx.summary.filesDeleted).toBe(1);
+  });
+
+  it('reports broken incoming refs for deleted files', () => {
+    const diff = `diff --git a/src/baz.ts b/src/baz.ts
+deleted file mode 100644
+--- a/src/baz.ts
++++ /dev/null
+@@ -20,11 +0,0 @@
+-x`;
+    const ctx = buildReviewContext(diff, q, traverser);
+    const baz = ctx.files.find((f) => f.path === 'src/baz.ts')!;
+    expect(baz.status).toBe('deleted');
+    // doFoo (in foo.ts) calls helper (in baz.ts) — deleting baz.ts breaks foo.
+    expect(baz.brokenIncomingRefs?.map((r) => r.name)).toContain('doFoo');
+  });
+
+  it('dedupes brokenIncomingRefs when one caller has multiple edge types to the deleted file', () => {
+    // Add a second edge from useFoo to helper (e.g., references in
+    // addition to the existing call). Without dedup, useFoo would appear
+    // twice in brokenIncomingRefs.
+    q.insertEdges([{ source: 'bar', target: 'baz', kind: 'references', line: 7 }]);
+    // Note: bar already had a `calls` edge target=foo and now `references` target=baz.
+    // For deletion of baz.ts we look at incoming to baz's symbols (helper).
+    // We need TWO edges from the same source to helper for dedup to fire.
+    q.insertEdges([
+      { source: 'bar', target: 'baz', kind: 'imports', line: 7 },
+    ]);
+    const diff = `diff --git a/src/baz.ts b/src/baz.ts
+deleted file mode 100644
+--- a/src/baz.ts
++++ /dev/null
+@@ -20,11 +0,0 @@
+-x`;
+    const ctx = buildReviewContext(diff, q, traverser);
+    const baz = ctx.files.find((f) => f.path === 'src/baz.ts')!;
+    // useFoo should appear at most once with line=7 (we have two edges
+    // both at line 7 from bar to baz with different kinds).
+    const fromBar = baz.brokenIncomingRefs?.filter((r) => r.name === 'useFoo' && r.line === 7);
+    expect(fromBar?.length).toBe(1);
+  });
+
+  it('returns empty co-change warnings on a pre-#105 install (no co_changes table)', () => {
+    // Default DatabaseConnection.initialize() runs schema.sql which on
+    // upstream/main does NOT include the co_changes table. The helper
+    // must gracefully degrade rather than throw.
+    const ctx = buildReviewContext(modifyDoFooDiff(), q, traverser);
+    expect(ctx.coChangeWarnings).toEqual([]);
+    expect(ctx.summary.coChangeWarnings).toBe(0);
+  });
+
+  it('returns empty tests array on a pre-#106 install (no `tests` edges)', () => {
+    const ctx = buildReviewContext(modifyDoFooDiff(), q, traverser);
+    expect(ctx.files[0].tests).toEqual([]);
+  });
+
+  it('respects maxCallersPerSymbol cap', () => {
+    // Add 10 more callers of doFoo to make the cap observable.
+    const extraNodes: Node[] = [];
+    const extraEdges: Edge[] = [];
+    const upsert = db.getDb().prepare(`
+      INSERT INTO files (path, content_hash, language, size, modified_at, indexed_at)
+      VALUES (?, '', 'typescript', 0, 0, 0)
+    `);
+    for (let i = 0; i < 10; i++) {
+      const fp = `src/caller${i}.ts`;
+      upsert.run(fp);
+      const id = `caller${i}`;
+      extraNodes.push(makeNode(id, `caller${i}`, 'function', fp, 1, 5));
+      extraEdges.push({ source: id, target: 'foo', kind: 'calls', line: 1 });
+    }
+    q.insertNodes(extraNodes);
+    q.insertEdges(extraEdges);
+
+    const ctx = buildReviewContext(modifyDoFooDiff(), q, traverser, { maxCallersPerSymbol: 3 });
+    const sym = ctx.files[0].affectedSymbols[0];
+    expect(sym.callers.length).toBeLessThanOrEqual(3);
+  });
+
+  it('co-change warning surfaces when a changed file has historical co-changers not in the PR', () => {
+    // Manually create the co_changes table + add commit_count + populate.
+    // This simulates a post-#105 install. (When PR #105 lands the table
+    // exists natively; we simulate it here so the helper has data to
+    // surface.)
+    db.getDb().exec(`
+      ALTER TABLE files ADD COLUMN commit_count INTEGER NOT NULL DEFAULT 0;
+      CREATE TABLE co_changes (
+        file_a TEXT NOT NULL,
+        file_b TEXT NOT NULL,
+        count INTEGER NOT NULL,
+        PRIMARY KEY (file_a, file_b),
+        CHECK (file_a < file_b)
+      );
+    `);
+    db.getDb().prepare('UPDATE files SET commit_count = ? WHERE path = ?').run(10, 'src/foo.ts');
+    db.getDb().prepare('UPDATE files SET commit_count = ? WHERE path = ?').run(8, 'src/bar.ts');
+    db.getDb().prepare('INSERT INTO co_changes (file_a, file_b, count) VALUES (?, ?, ?)')
+      .run('src/bar.ts', 'src/foo.ts', 7);
+
+    // Re-define getCoChangedFiles via a thin shim (since we don't have
+    // PR #105's QueryBuilder method here). Use the same SQL the PR
+    // would use.
+    (q as unknown as {
+      getCoChangedFiles: typeof getCoChangedFilesShim;
+    }).getCoChangedFiles = getCoChangedFilesShim.bind(null, q);
+
+    // Diff touches src/foo.ts but NOT src/bar.ts → bar.ts should surface
+    // as a co-change warning.
+    const ctx = buildReviewContext(modifyDoFooDiff(), q, traverser, {
+      minCoChangeJaccard: 0.3,
+    });
+    expect(ctx.coChangeWarnings.length).toBeGreaterThan(0);
+    const w = ctx.coChangeWarnings[0];
+    expect(w.changedFile).toBe('src/foo.ts');
+    expect(w.expectedToChange).toBe('src/bar.ts');
+    expect(w.jaccard).toBeGreaterThan(0.3);
+  });
+
+  it('does NOT warn about files that ARE in the PR (changedPaths exclusion)', () => {
+    db.getDb().exec(`
+      ALTER TABLE files ADD COLUMN commit_count INTEGER NOT NULL DEFAULT 0;
+      CREATE TABLE co_changes (
+        file_a TEXT NOT NULL, file_b TEXT NOT NULL, count INTEGER NOT NULL,
+        PRIMARY KEY (file_a, file_b), CHECK (file_a < file_b)
+      );
+    `);
+    db.getDb().prepare('UPDATE files SET commit_count = ? WHERE path = ?').run(10, 'src/foo.ts');
+    db.getDb().prepare('UPDATE files SET commit_count = ? WHERE path = ?').run(8, 'src/bar.ts');
+    db.getDb().prepare('INSERT INTO co_changes (file_a, file_b, count) VALUES (?, ?, ?)')
+      .run('src/bar.ts', 'src/foo.ts', 7);
+    (q as unknown as { getCoChangedFiles: typeof getCoChangedFilesShim }).getCoChangedFiles
+      = getCoChangedFilesShim.bind(null, q);
+
+    // Diff includes BOTH foo and bar → no warning should appear because
+    // bar IS in the changed set.
+    const diff = `diff --git a/src/foo.ts b/src/foo.ts
+--- a/src/foo.ts
++++ b/src/foo.ts
+@@ -10,1 +10,1 @@
+-x
++y
+diff --git a/src/bar.ts b/src/bar.ts
+--- a/src/bar.ts
++++ b/src/bar.ts
+@@ -3,1 +3,1 @@
+-x
++y`;
+    const ctx = buildReviewContext(diff, q, traverser, { minCoChangeJaccard: 0.3 });
+    expect(ctx.coChangeWarnings).toEqual([]);
+  });
+});
+
+describe('serializeReviewContextWithinCap (JSON-safe truncation)', () => {
+  // Re-import the helper indirectly via the MCP tool path. To test it
+  // in isolation we'd need to export it; instead exercise it via the
+  // path: build a too-large context, call the public buildReviewContext,
+  // serialize, and verify the output is parseable JSON.
+  it('produces parseable JSON even when context exceeds the cap', async () => {
+    // Build a context with thousands of symbols by inserting many nodes
+    // and a diff that touches them all.
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'review-trunc-'));
+    const db = DatabaseConnection.initialize(path.join(dir, 'test.db'));
+    const q = new QueryBuilder(db.getDb());
+    const traverser = new GraphTraverser(q);
+
+    db.getDb().prepare(`INSERT INTO files (path, content_hash, language, size, modified_at, indexed_at) VALUES (?, '', 'typescript', 0, 0, 0)`).run('src/big.ts');
+    const nodes: Node[] = [];
+    for (let i = 0; i < 200; i++) {
+      nodes.push(makeNode(`n${i}`, `sym${i}`, 'function', 'src/big.ts', i * 5, i * 5 + 4));
+      // Long docstrings to stress the truncation
+      nodes[i].docstring = 'x'.repeat(500);
+    }
+    q.insertNodes(nodes);
+
+    // Diff that touches every line in big.ts.
+    const diff = `diff --git a/src/big.ts b/src/big.ts
+--- a/src/big.ts
++++ b/src/big.ts
+@@ -1,1000 +1,1000 @@
+-x
++y`;
+    const ctx = buildReviewContext(diff, q, traverser);
+
+    // Use the helper directly — re-create it inline (matches the MCP
+    // tool's serializeReviewContextWithinCap behavior). Verify JSON parses.
+    const json = JSON.stringify(ctx, null, 2);
+    expect(() => JSON.parse(json)).not.toThrow(); // sanity: full JSON is valid
+
+    // Now apply the same trimming logic the MCP handler uses (lift it
+    // here as a one-off — equivalent to importing the private helper).
+    const cap = 5000; // small cap to force trimming
+    const trimmed = trimContextToFitJson(ctx, cap);
+    expect(trimmed.length).toBeLessThanOrEqual(cap);
+    expect(() => JSON.parse(trimmed)).not.toThrow();
+
+    db.close();
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+});
+
+// Inline equivalent of serializeReviewContextWithinCap from src/mcp/tools.ts.
+// Kept here to avoid exporting an internal helper just for tests.
+function trimContextToFitJson(context: unknown, cap: number): string {
+  const ctx = JSON.parse(JSON.stringify(context)) as {
+    summary: Record<string, number>;
+    files: Array<{
+      affectedSymbols: Array<{
+        docstring?: string;
+        signature?: string;
+        callers?: unknown[];
+        callees?: unknown[];
+      }>;
+      _truncated?: boolean;
+    }>;
+    coChangeWarnings: unknown[];
+    _truncated?: boolean;
+  };
+  const fits = (s: string) => s.length <= cap;
+  let json = JSON.stringify(ctx, null, 2);
+  if (fits(json)) return json;
+  for (const f of ctx.files) for (const s of f.affectedSymbols) delete s.docstring;
+  json = JSON.stringify(ctx, null, 2);
+  if (fits(json)) return json;
+  for (const f of ctx.files) for (const s of f.affectedSymbols) delete s.signature;
+  json = JSON.stringify(ctx, null, 2);
+  if (fits(json)) return json;
+  for (const f of ctx.files) for (const s of f.affectedSymbols) {
+    if (Array.isArray(s.callers)) s.callers = s.callers.slice(0, 2);
+    if (Array.isArray(s.callees)) s.callees = s.callees.slice(0, 2);
+  }
+  json = JSON.stringify(ctx, null, 2);
+  if (fits(json)) return json;
+  for (const f of ctx.files) for (const s of f.affectedSymbols) {
+    delete s.callers;
+    delete s.callees;
+  }
+  json = JSON.stringify(ctx, null, 2);
+  if (fits(json)) return json;
+  while (ctx.files.length > 1) {
+    ctx.files.pop();
+    ctx._truncated = true;
+    json = JSON.stringify(ctx, null, 2);
+    if (fits(json)) return json;
+  }
+  return JSON.stringify(
+    { summary: ctx.summary, coChangeWarnings: ctx.coChangeWarnings, _truncated: true },
+    null, 2
+  );
+}
+
+/**
+ * Shim that mimics PR #105's QueryBuilder.getCoChangedFiles. Used in
+ * tests for forward-compatibility — once #105 lands, the real method
+ * exists on QueryBuilder and this shim is unnecessary.
+ */
+function getCoChangedFilesShim(
+  q: QueryBuilder,
+  filePath: string,
+  options: { limit: number; minCount: number; minJaccard: number }
+): Array<{ path: string; count: number; jaccard: number }> {
+  const { limit, minCount, minJaccard } = options;
+  const sql = `
+    WITH partners AS (
+      SELECT file_b AS path, count FROM co_changes WHERE file_a = ?
+      UNION ALL
+      SELECT file_a AS path, count FROM co_changes WHERE file_b = ?
+    ),
+    anchor AS (SELECT commit_count AS c FROM files WHERE path = ?),
+    scored AS (
+      SELECT
+        p.path AS path, p.count AS count,
+        CAST(p.count AS REAL) / NULLIF((SELECT c FROM anchor) + f.commit_count - p.count, 0) AS jaccard
+      FROM partners p
+      JOIN files f ON f.path = p.path
+      WHERE p.count >= ?
+    )
+    SELECT path, count, jaccard FROM scored
+    WHERE COALESCE(jaccard, 0) >= ?
+    ORDER BY jaccard DESC, count DESC
+    LIMIT ?
+  `;
+  const rows = (q as unknown as { db: { prepare: (sql: string) => { all: (...args: unknown[]) => Array<{ path: string; count: number; jaccard: number | null }> } } }).db
+    .prepare(sql)
+    .all(filePath, filePath, filePath, minCount, minJaccard, limit);
+  return rows.map((r) => ({ path: r.path, count: r.count, jaccard: r.jaccard ?? 0 }));
+}
diff --git a/src/index.ts b/src/index.ts
index 0ff1e090c..b70597dbd 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -49,6 +49,7 @@ import { GraphTraverser, GraphQueryManager } from './graph';
 import { ContextBuilder, createContextBuilder } from './context';
 import { Mutex, FileLock } from './utils';
 import { FileWatcher, WatchOptions } from './sync';
+import { buildReviewContext, ReviewContext, ReviewContextOptions } from './review';
 
 // Re-export types for consumers
 export * from './types';
@@ -497,6 +498,23 @@ export class CodeGraph {
     return this.indexMutex.isLocked();
   }
 
+  /**
+   * Build structured review context from a unified diff.
+   *
+   * Maps each hunk back to the symbols whose line ranges it touches,
+   * then attaches per-symbol callers / callees / impact-radius / tests
+   * plus historical co-change warnings (files that historically change
+   * together with a changed file but are NOT in this PR — the kind of
+   * coupling violation that catches "you changed schema.sql but didn't
+   * update migrations.ts").
+   *
+   * Returns pure data; no synthesis. Designed for an LLM consumer to
+   * turn into a code review.
+   */
+  buildReviewContext(diff: string, options: ReviewContextOptions = {}): ReviewContext {
+    return buildReviewContext(diff, this.queries, this.traverser, options);
+  }
+
   // ===========================================================================
   // File Watching
   // ===========================================================================
diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts
index 53713145c..552e59beb 100644
--- a/src/mcp/tools.ts
+++ b/src/mcp/tools.ts
@@ -283,6 +283,42 @@ export const tools: ToolDefinition[] = [
       },
     },
   },
+  {
+    name: 'codegraph_review_context',
+    description:
+      'PR REVIEW HELPER: Given a unified diff, return structured context an LLM reviewer needs. Maps each hunk to the symbols it touches and attaches per-symbol callers, callees, impact-radius count, and tests covering the file. Also surfaces co-change warnings — files that historically change together with a changed file but were NOT included in this PR (catches "you changed schema.sql but not migrations.ts" type coupling violations). Returns JSON; the caller does the synthesis.',
+    inputSchema: {
+      type: 'object',
+      properties: {
+        diff: {
+          type: 'string',
+          description: 'Unified-diff text (e.g., the output of `git diff`, `gh pr diff <n>`).',
+        },
+        maxCallersPerSymbol: {
+          type: 'number',
+          description: 'Cap callers shown per affected symbol. Default 5.',
+          default: 5,
+        },
+        maxCalleesPerSymbol: {
+          type: 'number',
+          description: 'Cap callees shown per affected symbol. Default 5.',
+          default: 5,
+        },
+        maxCoChangeWarnings: {
+          type: 'number',
+          description: 'Cap co-change warnings per changed file. 0 disables. Default 3.',
+          default: 3,
+        },
+        minCoChangeJaccard: {
+          type: 'number',
+          description: 'Minimum Jaccard for a co-change warning. 0.4 catches meaningful coupling without flooding noise. Default 0.4.',
+          default: 0.4,
+        },
+        projectPath: projectPathProperty,
+      },
+      required: ['diff'],
+    },
+  },
 ];
 
 /**
@@ -427,6 +463,8 @@ export class ToolHandler {
           return await this.handleStatus(args);
         case 'codegraph_files':
           return await this.handleFiles(args);
+        case 'codegraph_review_context':
+          return await this.handleReviewContext(args);
         default:
           return this.errorResult(`Unknown tool: ${toolName}`);
       }
@@ -1265,6 +1303,44 @@ export class ToolHandler {
     return { nodes: exactMatches.map(r => r.node), note };
   }
 
+  /**
+   * Handle codegraph_review_context. Returns the review context as
+   * formatted JSON (LLMs parse JSON cleanly; markdown would be
+   * lossier here).
+   */
+  private async handleReviewContext(args: Record<string, unknown>): Promise<ToolResult> {
+    const diff = this.validateString(args.diff, 'diff');
+    if (typeof diff !== 'string') return diff;
+
+    const cg = this.getCodeGraph(args.projectPath as string | undefined);
+
+    const context = cg.buildReviewContext(diff, {
+      maxCallersPerSymbol: args.maxCallersPerSymbol != null
+        ? clamp(Number(args.maxCallersPerSymbol), 0, 50)
+        : undefined,
+      maxCalleesPerSymbol: args.maxCalleesPerSymbol != null
+        ? clamp(Number(args.maxCalleesPerSymbol), 0, 50)
+        : undefined,
+      maxCoChangeWarnings: args.maxCoChangeWarnings != null
+        ? clamp(Number(args.maxCoChangeWarnings), 0, 20)
+        : undefined,
+      minCoChangeJaccard: args.minCoChangeJaccard != null
+        ? clamp(Number(args.minCoChangeJaccard), 0, 1)
+        : undefined,
+    });
+
+    if (context.summary.symbolsAffected === 0 && context.files.length === 0) {
+      return this.textResult(
+        'No indexed symbols overlap the diff hunks. Either the affected files are not indexed, the diff is empty, or it touches files that were added/deleted entirely.'
+      );
+    }
+
+    // Serialize with progressive trimming so the result stays valid JSON
+    // when it would otherwise exceed MAX_OUTPUT_LENGTH. Mid-string slice
+    // would corrupt JSON; trim low-value fields instead.
+    return this.textResult(serializeReviewContextWithinCap(context, MAX_OUTPUT_LENGTH));
+  }
+
   /**
    * Truncate output if it exceeds the maximum length
    */
@@ -1377,3 +1453,81 @@ export class ToolHandler {
     };
   }
 }
+
+/**
+ * Serialize a ReviewContext to JSON within `cap` chars, dropping
+ * lowest-value fields first if needed so the output stays valid JSON
+ * (rather than mid-string-truncating which corrupts the tree).
+ *
+ * Trim order, in escalating severity:
+ *   1. Drop `docstring` from every affected symbol (often the longest field)
+ *   2. Drop `signature`
+ *   3. Cap callers/callees to 2 each
+ *   4. Drop `callers` and `callees` arrays entirely
+ *   5. Truncate the `files` array (oldest first) and add a `_truncated` flag
+ *
+ * If even step 5 doesn't fit, return summary + warnings only.
+ */
+function serializeReviewContextWithinCap(context: unknown, cap: number): string {
+  // Defensively clone so we don't mutate the caller's object.
+  const ctx = JSON.parse(JSON.stringify(context)) as {
+    summary: Record<string, number>;
+    files: Array<{
+      affectedSymbols: Array<{
+        docstring?: string;
+        signature?: string;
+        callers?: unknown[];
+        callees?: unknown[];
+      }>;
+      _truncated?: boolean;
+    }>;
+    coChangeWarnings: unknown[];
+    _truncated?: boolean;
+  };
+
+  const fits = (s: string) => s.length <= cap;
+
+  let json = JSON.stringify(ctx, null, 2);
+  if (fits(json)) return json;
+
+  // Step 1: drop docstrings.
+  for (const f of ctx.files) for (const s of f.affectedSymbols) delete s.docstring;
+  json = JSON.stringify(ctx, null, 2);
+  if (fits(json)) return json;
+
+  // Step 2: drop signatures.
+  for (const f of ctx.files) for (const s of f.affectedSymbols) delete s.signature;
+  json = JSON.stringify(ctx, null, 2);
+  if (fits(json)) return json;
+
+  // Step 3: cap callers/callees to 2.
+  for (const f of ctx.files) for (const s of f.affectedSymbols) {
+    if (Array.isArray(s.callers)) s.callers = s.callers.slice(0, 2);
+    if (Array.isArray(s.callees)) s.callees = s.callees.slice(0, 2);
+  }
+  json = JSON.stringify(ctx, null, 2);
+  if (fits(json)) return json;
+
+  // Step 4: drop callers/callees entirely.
+  for (const f of ctx.files) for (const s of f.affectedSymbols) {
+    delete s.callers;
+    delete s.callees;
+  }
+  json = JSON.stringify(ctx, null, 2);
+  if (fits(json)) return json;
+
+  // Step 5: drop files from the end until fits, then mark truncated.
+  while (ctx.files.length > 1) {
+    ctx.files.pop();
+    ctx._truncated = true;
+    json = JSON.stringify(ctx, null, 2);
+    if (fits(json)) return json;
+  }
+
+  // Last resort: summary + warnings only.
+  return JSON.stringify(
+    { summary: ctx.summary, coChangeWarnings: ctx.coChangeWarnings, _truncated: true },
+    null,
+    2
+  );
+}
diff --git a/src/review/diff-parser.ts b/src/review/diff-parser.ts
new file mode 100644
index 000000000..0a57d35df
--- /dev/null
+++ b/src/review/diff-parser.ts
@@ -0,0 +1,226 @@
+/**
+ * Unified Diff Parser
+ *
+ * Minimal parser for the subset of unified-diff syntax git emits:
+ * file headers (`diff --git a/x b/y`), index lines, mode lines, and
+ * hunk headers (`@@ -OLD,COUNT +NEW,COUNT @@`). Body lines are not
+ * preserved — callers only need file + hunk metadata to map changes
+ * back to symbols via line-range overlap.
+ *
+ * Pure module: no DB or filesystem access. Safe to test in isolation.
+ */
+
+export type FileStatus = 'added' | 'modified' | 'deleted' | 'renamed';
+
+export interface Hunk {
+  /** Old file: starting line number (1-indexed). 0 if file was added. */
+  oldStart: number;
+  /** Number of lines from the old file in this hunk. 0 for added file. */
+  oldCount: number;
+  /** New file: starting line number (1-indexed). 0 if file was deleted. */
+  newStart: number;
+  /** Number of lines in the new file. 0 for deleted file. */
+  newCount: number;
+}
+
+export interface DiffFile {
+  /**
+   * File path as it appears in the new tree (or the old tree for deletions).
+   * Always normalized to forward slashes; the leading `a/` or `b/` prefix
+   * git emits is stripped.
+   */
+  path: string;
+  /** Pre-rename path (only set when status === 'renamed'). */
+  oldPath?: string;
+  status: FileStatus;
+  hunks: Hunk[];
+}
+
+const HUNK_RE =
+  /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
+
+// Matches both unquoted (`diff --git a/x b/y`) and C-style-quoted
+// (`diff --git "a/x with space" "b/y"`) git diff headers. The capture
+// groups always include the `a/` / `b/` prefix or the surrounding
+// quotes; both are stripped via `unquote` before use.
+const DIFF_HEADER_RE = /^diff --git (?:"a\/(.+)"|a\/(\S+)) (?:"b\/(.+)"|b\/(\S+))$/;
+
+/**
+ * Parse a unified diff into a flat list of files with hunk metadata.
+ *
+ * Tolerates extra noise lines (binary file markers, similarity index
+ * lines, etc.) by skipping anything that doesn't match a known prefix.
+ */
+export function parseDiff(text: string): DiffFile[] {
+  const files: DiffFile[] = [];
+  let current: DiffFile | null = null;
+  let oldPath: string | null = null;
+  let newPath: string | null = null;
+  let isAddition = false;
+  let isDeletion = false;
+  let isRename = false;
+  let renamedFrom: string | null = null;
+  let renamedTo: string | null = null;
+
+  // Strip git's C-style quoting on paths with special characters
+  // (e.g., `"path with spaces.ts"` → `path with spaces.ts`).
+  const unquote = (p: string | null): string | null => {
+    if (!p) return p;
+    if (p.startsWith('"') && p.endsWith('"')) {
+      try { return JSON.parse(p) as string; } catch { return p.slice(1, -1); }
+    }
+    return p;
+  };
+
+  const flushCurrent = () => {
+    if (!current) return;
+    files.push(current);
+    current = null;
+  };
+
+  // Emit a file entry for a header that produced no hunks (pure rename,
+  // mode change, or empty add/delete). Without this, such files silently
+  // disappear when followed by another `diff --git` header.
+  const flushHunkless = () => {
+    if (current !== null) return; // a hunked file already emitted
+    if (!isRename && !isAddition && !isDeletion) return;
+    const status: FileStatus = isRename
+      ? 'renamed'
+      : isAddition
+        ? 'added'
+        : 'deleted';
+    const path = isDeletion
+      ? unquote(oldPath) ?? renamedFrom ?? '?'
+      : unquote(newPath) ?? renamedTo ?? '?';
+    const f: DiffFile = { path, status, hunks: [] };
+    if (status === 'renamed' && renamedFrom) f.oldPath = renamedFrom;
+    files.push(f);
+  };
+
+  const lines = text.split('\n');
+  for (const line of lines) {
+    // Start of a new file block.
+    const headerMatch = DIFF_HEADER_RE.exec(line);
+    if (headerMatch) {
+      flushCurrent();
+      flushHunkless(); // emit any hunk-less file from the previous header
+      // headerMatch slots: 1=quoted-a, 2=unquoted-a, 3=quoted-b, 4=unquoted-b.
+      // Whichever side matched is the path; the others are undefined.
+      oldPath = headerMatch[1] ?? headerMatch[2] ?? null;
+      newPath = headerMatch[3] ?? headerMatch[4] ?? null;
+      isAddition = false;
+      isDeletion = false;
+      isRename = false;
+      renamedFrom = null;
+      renamedTo = null;
+      continue;
+    }
+
+    if (line.startsWith('new file mode')) {
+      isAddition = true;
+      continue;
+    }
+    if (line.startsWith('deleted file mode')) {
+      isDeletion = true;
+      continue;
+    }
+    if (line.startsWith('rename from ')) {
+      isRename = true;
+      renamedFrom = line.substring('rename from '.length).trim();
+      continue;
+    }
+    if (line.startsWith('rename to ')) {
+      renamedTo = line.substring('rename to '.length).trim();
+      continue;
+    }
+
+    // The old/new path lines (--- / +++) confirm paths and detect
+    // /dev/null sentinels that mean add or delete.
+    if (line.startsWith('--- ')) {
+      const p = line.substring(4).trim();
+      if (p === '/dev/null') isAddition = true;
+      continue;
+    }
+    if (line.startsWith('+++ ')) {
+      const p = line.substring(4).trim();
+      if (p === '/dev/null') isDeletion = true;
+      continue;
+    }
+
+    // First hunk seen — finalize the file header into a DiffFile.
+    const hunkMatch = HUNK_RE.exec(line);
+    if (hunkMatch) {
+      if (!current) {
+        const status: FileStatus = isRename
+          ? 'renamed'
+          : isAddition
+            ? 'added'
+            : isDeletion
+              ? 'deleted'
+              : 'modified';
+
+        const path = isDeletion
+          ? unquote(oldPath) ?? renamedFrom ?? '?'
+          : unquote(newPath) ?? renamedTo ?? '?';
+
+        current = {
+          path,
+          status,
+          hunks: [],
+        };
+        if (status === 'renamed' && renamedFrom) {
+          current.oldPath = renamedFrom;
+        }
+        // Reset add/delete/rename flags now that they've been consumed
+        // into `current.status`. Otherwise they leak into the next header
+        // and trigger a phantom hunk-less file emit.
+        isAddition = false;
+        isDeletion = false;
+        isRename = false;
+      }
+      current.hunks.push({
+        oldStart: parseInt(hunkMatch[1] ?? '0', 10),
+        oldCount: hunkMatch[2] !== undefined ? parseInt(hunkMatch[2], 10) : 1,
+        newStart: parseInt(hunkMatch[3] ?? '0', 10),
+        newCount: hunkMatch[4] !== undefined ? parseInt(hunkMatch[4], 10) : 1,
+      });
+      continue;
+    }
+
+    // Pure-rename or pure-mode-change blocks have no hunks. They get
+    // emitted via flushHunkless on the next header transition, or here
+    // at EOF.
+  }
+
+  flushCurrent();
+  flushHunkless();
+
+  return files;
+}
+
+/**
+ * Convert a DiffFile + the file's symbol nodes (with start/end line
+ * ranges) into the subset of symbols whose lines overlap any hunk.
+ *
+ * For added/deleted files there are no meaningful pre-existing symbols
+ * to intersect — caller should treat the entire file as affected.
+ */
+export function symbolsTouchedByHunks<T extends { startLine: number; endLine: number }>(
+  hunks: Hunk[],
+  symbols: T[]
+): T[] {
+  if (hunks.length === 0 || symbols.length === 0) return [];
+  const out: T[] = [];
+  for (const s of symbols) {
+    for (const h of hunks) {
+      // Overlap is checked against the new-file line range. A hunk that
+      // adds 5 lines starting at newStart=10 occupies lines [10, 14].
+      const hunkEnd = h.newStart + Math.max(h.newCount - 1, 0);
+      if (s.startLine <= hunkEnd && s.endLine >= h.newStart) {
+        out.push(s);
+        break;
+      }
+    }
+  }
+  return out;
+}
diff --git a/src/review/index.ts b/src/review/index.ts
new file mode 100644
index 000000000..c612a8b14
--- /dev/null
+++ b/src/review/index.ts
@@ -0,0 +1,325 @@
+/**
+ * Review Context Builder
+ *
+ * Takes a unified diff and returns the structured context an LLM-driven
+ * code reviewer needs to evaluate it: per-symbol callers / callees /
+ * tests / impact, plus historical co-change warnings (files that
+ * historically change together but were NOT both touched in this PR).
+ *
+ * Designed to be the substrate under PR-review tooling (Greptile,
+ * CodeRabbit, custom Claude Code agents). Not a reviewer itself —
+ * synthesis stays with the LLM consumer.
+ */
+
+import { Node, NodeKind } from '../types';
+import { QueryBuilder } from '../db/queries';
+import { GraphTraverser } from '../graph/traversal';
+import { parseDiff, symbolsTouchedByHunks, DiffFile, FileStatus } from './diff-parser';
+
+export { parseDiff, type DiffFile, type Hunk, type FileStatus } from './diff-parser';
+
+export interface ReviewContextOptions {
+  /**
+   * Per-symbol caller / callee fan-out cap. Reviewer only needs a handful
+   * to decide "is this a hot-path function or an internal helper", not
+   * every reference.
+   */
+  maxCallersPerSymbol?: number;
+  maxCalleesPerSymbol?: number;
+
+  /**
+   * For each changed file, surface up to N co-changers that historically
+   * change together but are NOT in this PR. Set 0 to disable.
+   */
+  maxCoChangeWarnings?: number;
+
+  /**
+   * Minimum Jaccard for a co-change warning to be reported. 0.4 catches
+   * meaningfully-coupled pairs without flooding the result with weak
+   * historical co-occurrence.
+   */
+  minCoChangeJaccard?: number;
+}
+
+interface SymbolRef {
+  name: string;
+  filePath: string;
+  line?: number;
+}
+
+export interface AffectedSymbol {
+  symbolId: string;
+  name: string;
+  kind: NodeKind;
+  qualifiedName: string;
+  startLine: number;
+  endLine: number;
+  signature?: string;
+  docstring?: string;
+  /** Direct callers (incoming `calls`/`references`/`imports` edges). */
+  callers: SymbolRef[];
+  /** Direct callees (outgoing `calls`/`references`/`imports` edges). */
+  callees: SymbolRef[];
+  /** Number of nodes in the impact radius (depth 2). */
+  impactCount: number;
+}
+
+export interface ReviewedFile {
+  path: string;
+  status: FileStatus;
+  oldPath?: string;
+  /** Symbols whose line ranges overlap the diff hunks. */
+  affectedSymbols: AffectedSymbol[];
+  /** Test files that cover this source file (via PR #106 `tests` edges). */
+  tests: string[];
+  /** Note when status == 'deleted' — incoming edges to symbols that vanish. */
+  brokenIncomingRefs?: SymbolRef[];
+}
+
+export interface CoChangeWarning {
+  changedFile: string;
+  expectedToChange: string;
+  jaccard: number;
+  historicalCount: number;
+  note: string;
+}
+
+export interface ReviewContext {
+  summary: {
+    filesAdded: number;
+    filesModified: number;
+    filesDeleted: number;
+    filesRenamed: number;
+    symbolsAffected: number;
+    coChangeWarnings: number;
+  };
+  files: ReviewedFile[];
+  coChangeWarnings: CoChangeWarning[];
+}
+
+const DEFAULTS: Required<ReviewContextOptions> = {
+  maxCallersPerSymbol: 5,
+  maxCalleesPerSymbol: 5,
+  maxCoChangeWarnings: 3,
+  minCoChangeJaccard: 0.4,
+};
+
+/**
+ * Build a review-context bundle from a unified diff. Pure data — the
+ * caller (typically an LLM) decides what to do with it.
+ */
+export function buildReviewContext(
+  diff: string,
+  queries: QueryBuilder,
+  traverser: GraphTraverser,
+  options: ReviewContextOptions = {}
+): ReviewContext {
+  const opts = { ...DEFAULTS, ...options };
+  const diffFiles = parseDiff(diff);
+  const changedPaths = new Set(diffFiles.map((f) => f.path));
+
+  const reviewedFiles: ReviewedFile[] = [];
+  let totalSymbols = 0;
+
+  for (const df of diffFiles) {
+    const reviewed = reviewFile(df, queries, traverser, opts);
+    totalSymbols += reviewed.affectedSymbols.length;
+    reviewedFiles.push(reviewed);
+  }
+
+  // Co-change warnings — for each changed file, find historical
+  // co-changers NOT touched in this PR. This is the genuinely novel
+  // signal: catches "you changed X but didn't update Y which always
+  // changes with X" (schema + migration, code + test, config + reader).
+  const coChangeWarnings: CoChangeWarning[] = [];
+  if (opts.maxCoChangeWarnings > 0) {
+    for (const df of diffFiles) {
+      // Skip pure deletions — querying their co-changers tells us nothing
+      // useful about what should also have been touched in this PR.
+      if (df.status === 'deleted') continue;
+      const partners = safeGetCoChangedFiles(queries, df.path, {
+        limit: opts.maxCoChangeWarnings * 3,
+        minCount: 2,
+        minJaccard: opts.minCoChangeJaccard,
+      });
+      const missing = partners.filter((p) => !changedPaths.has(p.path)).slice(0, opts.maxCoChangeWarnings);
+      for (const m of missing) {
+        coChangeWarnings.push({
+          changedFile: df.path,
+          expectedToChange: m.path,
+          jaccard: round2(m.jaccard),
+          historicalCount: m.count,
+          note: 'Historically changes together with the changed file but is not included in this PR. Verify whether it should be updated.',
+        });
+      }
+    }
+  }
+
+  const counts = reviewedFiles.reduce(
+    (acc, f) => {
+      if (f.status === 'added') acc.added++;
+      else if (f.status === 'modified') acc.modified++;
+      else if (f.status === 'deleted') acc.deleted++;
+      else if (f.status === 'renamed') acc.renamed++;
+      return acc;
+    },
+    { added: 0, modified: 0, deleted: 0, renamed: 0 }
+  );
+
+  return {
+    summary: {
+      filesAdded: counts.added,
+      filesModified: counts.modified,
+      filesDeleted: counts.deleted,
+      filesRenamed: counts.renamed,
+      symbolsAffected: totalSymbols,
+      coChangeWarnings: coChangeWarnings.length,
+    },
+    files: reviewedFiles,
+    coChangeWarnings,
+  };
+}
+
+function reviewFile(
+  df: DiffFile,
+  queries: QueryBuilder,
+  traverser: GraphTraverser,
+  opts: Required<ReviewContextOptions>
+): ReviewedFile {
+  const reviewed: ReviewedFile = {
+    path: df.path,
+    status: df.status,
+    affectedSymbols: [],
+    tests: safeGetTestsForFile(queries, df.path),
+  };
+  if (df.oldPath) reviewed.oldPath = df.oldPath;
+
+  const fileSymbols = queries.getNodesByFile(df.path);
+
+  // For deleted files: list every symbol that vanishes plus every
+  // distinct incoming reference to those symbols (the "what just broke"
+  // picture). Dedup by (name, filePath, line) so a caller with two
+  // different edge types to the same deleted file isn't double-listed.
+  if (df.status === 'deleted') {
+    const seen = new Set<string>();
+    const broken: SymbolRef[] = [];
+    for (const sym of fileSymbols) {
+      const incoming = queries.getIncomingEdges(sym.id, ['calls', 'references', 'imports', 'extends', 'implements']);
+      for (const edge of incoming) {
+        const sourceNode = queries.getNodeById(edge.source);
+        if (!sourceNode) continue;
+        const key = `${sourceNode.filePath}|${sourceNode.name}|${edge.line ?? ''}`;
+        if (seen.has(key)) continue;
+        seen.add(key);
+        broken.push({
+          name: sourceNode.name,
+          filePath: sourceNode.filePath,
+          line: edge.line,
+        });
+      }
+      // Skip the per-symbol details for deleted files — affected lists
+      // would all be empty since the symbol's gone.
+      reviewed.affectedSymbols.push(toAffected(sym, [], [], 0));
+    }
+    if (broken.length > 0) reviewed.brokenIncomingRefs = broken;
+    return reviewed;
+  }
+
+  // For added files: every top-level symbol is "affected" (newly created).
+  // For modified files: symbols whose line range overlaps a hunk.
+  const touched = df.status === 'added'
+    ? fileSymbols
+    : symbolsTouchedByHunks(df.hunks, fileSymbols);
+
+  for (const sym of touched) {
+    const callers = traverser
+      .getCallers(sym.id, 1)
+      .slice(0, opts.maxCallersPerSymbol)
+      .map((r) => ({
+        name: r.node.name,
+        filePath: r.node.filePath,
+        line: r.edge.line,
+      }));
+
+    const callees = traverser
+      .getCallees(sym.id, 1)
+      .slice(0, opts.maxCalleesPerSymbol)
+      .map((r) => ({
+        name: r.node.name,
+        filePath: r.node.filePath,
+        line: r.edge.line,
+      }));
+
+    const impactCount = traverser.getImpactRadius(sym.id, 2).nodes.size;
+    reviewed.affectedSymbols.push(toAffected(sym, callers, callees, impactCount));
+  }
+
+  return reviewed;
+}
+
+function toAffected(
+  sym: Node,
+  callers: SymbolRef[],
+  callees: SymbolRef[],
+  impactCount: number
+): AffectedSymbol {
+  const out: AffectedSymbol = {
+    symbolId: sym.id,
+    name: sym.name,
+    kind: sym.kind,
+    qualifiedName: sym.qualifiedName,
+    startLine: sym.startLine,
+    endLine: sym.endLine,
+    callers,
+    callees,
+    impactCount,
+  };
+  if (sym.signature) out.signature = sym.signature;
+  if (sym.docstring) out.docstring = sym.docstring;
+  return out;
+}
+
+/**
+ * Co-change query — graceful degradation if PR #105's co_changes table
+ * isn't present. Returns [] without throwing, so the review context
+ * still works on a pre-#105 install.
+ */
+function safeGetCoChangedFiles(
+  queries: QueryBuilder,
+  filePath: string,
+  options: { limit: number; minCount: number; minJaccard: number }
+): Array<{ path: string; count: number; jaccard: number }> {
+  const q = queries as unknown as {
+    getCoChangedFiles?: (
+      p: string,
+      o: { limit: number; minCount: number; minJaccard: number }
+    ) => Array<{ path: string; count: number; jaccard: number }>;
+  };
+  if (typeof q.getCoChangedFiles !== 'function') return [];
+  try {
+    return q.getCoChangedFiles(filePath, options);
+  } catch {
+    return [];
+  }
+}
+
+/**
+ * Tests-edges query — graceful degradation if PR #106's `tests` edges
+ * aren't present. Falls back to a direct edges-table query so we don't
+ * need the public API surface to exist yet.
+ */
+function safeGetTestsForFile(queries: QueryBuilder, filePath: string): string[] {
+  try {
+    const incoming = queries.getIncomingEdges(`file:${filePath}`, ['tests' as never]);
+    return incoming
+      .map((e) => e.source)
+      .filter((id) => id.startsWith('file:'))
+      .map((id) => id.slice('file:'.length));
+  } catch {
+    return [];
+  }
+}
+
+function round2(n: number): number {
+  return Math.round(n * 100) / 100;
+}
