From c9d2a25b73c3fc66c0f464a1ec0e0eb1cf53de65 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Fri, 22 May 2026 14:12:40 -0500 Subject: [PATCH 01/15] docs: validate Windows PRs via Parallels+SSH; gitignore .parallels Document the Mac-host -> Parallels Windows 11 SSH workflow for validating Windows-specific behavior, the win32-gated test convention (it.runIf), and guest toolchain quirks (PATH refresh, Windows-local clone, VC++ ARM64 redist). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 +++ CLAUDE.md | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/.gitignore b/.gitignore index 435882b3..f7aa9d68 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,9 @@ npm-debug.log* # Local Claude settings .claude/settings.local.json +# Parallels Windows VM SSH/connection config (local machine, see CLAUDE.md) +.parallels + # CodeGraph data directories (in test projects) .codegraph/ diff --git a/CLAUDE.md b/CLAUDE.md index d5222f37..be63c67b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,26 @@ Tests live in `__tests__/` and mirror the module they cover. Notable ones beyond Tests create temp dirs with `fs.mkdtempSync` and clean up in `afterEach`. They write real files and exercise real SQLite — there is no DB mocking. +### Windows-gated tests + +Behavior that differs by platform (path resolution, drive letters, `SENSITIVE_PATHS`, `%APPDATA%` config dirs, CRLF) must be gated, not assumed. Use `it.runIf(process.platform === 'win32')(...)` for Windows-only assertions and `it.runIf(process.platform !== 'win32')(...)` for POSIX-only ones — e.g. `/etc` is sensitive on POSIX but resolves to `C:\etc` (non-existent) on Windows, so an ungated `/etc` assertion fails on Windows. Validate the Windows side for real (see below); don't merge a Windows-gated test you haven't seen run. + +## Windows validation (Parallels + SSH) + +For any Windows-specific PR, bug, or implementation, validate it on the real Windows VM rather than guessing. Connection details live in the gitignored **`.parallels`** file at the repo root (VM name, guest IP, SSH user/key). `prlctl exec` needs Parallels Pro and is unavailable, so SSH is the bridge. + +- Connect / run from the Mac host: `ssh @ "..."`. For multi-line work, pipe PowerShell over stdin and **refresh PATH from the registry** first (sshd's session has a stale PATH after winget installs): + ``` + ssh colby@10.211.55.3 "powershell -NoProfile -ExecutionPolicy Bypass -Command -" <<'PS' + $env:Path = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Environment]::GetEnvironmentVariable("Path","User") + Set-Location C:\dev\codegraph + PS + ``` +- Clone fresh into a **Windows-local** path (`C:\dev\codegraph`) and `npm ci` there — never run npm against the shared Mac repo, since `esbuild`/`rollup` ship platform-specific binaries. +- Guest toolchain (winget): Node LTS, Git, and the **VC++ ARM64 redistributable** (required by `@rollup/rollup-win32-arm64-msvc`, which vitest pulls in). +- Fetch a contributor PR head straight from their fork to dodge `pull//head` lag: `git fetch ` then `git checkout -f FETCH_HEAD`. +- Known pre-existing Windows failure: `security.test.ts > Session marker symlink resistance > does not follow a pre-planted symlink` (symlink creation needs privileges on Windows). Unrelated to current work; don't let it mask new regressions. + ## Releases Released to npm and mirrored as [GitHub Releases](https://github.com/colbymchenry/codegraph/releases). `CHANGELOG.md` is the source of truth; GitHub Release notes are extracted from it. From 7d5dd4cda7402bb2c9f467851ceed7f7115919a3 Mon Sep 17 00:00:00 2001 From: "Leon.C" <160379708+zichen0116@users.noreply.github.com> Date: Sat, 23 May 2026 03:13:42 +0800 Subject: [PATCH 02/15] fix: remove dead try/catch in insertNode; fix SENSITIVE_PATHS case-sensitivity (#327) Drop the no-op try/catch around insertNode.run, and lowercase the Windows SENSITIVE_PATHS entries so validateProjectPath's case-insensitive check actually blocks c:\windows. Adds a validateProjectPath test (POSIX + Windows-gated); the Windows-gated case was validated on a real Windows 11 VM. Closes #327 --- __tests__/security.test.ts | 32 ++++++++++++++++++++++++- src/db/queries.ts | 48 +++++++++++++++++--------------------- src/utils.ts | 2 +- 3 files changed, 54 insertions(+), 28 deletions(-) diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts index c57158c2..abb70fe6 100644 --- a/__tests__/security.test.ts +++ b/__tests__/security.test.ts @@ -12,7 +12,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { FileLock } from '../src/utils'; +import { FileLock, validateProjectPath } from '../src/utils'; import CodeGraph from '../src/index'; import { ToolHandler, tools } from '../src/mcp/tools'; import { scanDirectory, isSourceFile } from '../src/extraction'; @@ -176,6 +176,36 @@ describe('Path Traversal Prevention', () => { }); }); +describe('validateProjectPath — sensitive directory blocking', () => { + // POSIX-only: on Windows '/etc' resolves to C:\etc (non-existent), not a + // sensitive dir — the Windows case is covered by the win32-gated test below. + it.runIf(process.platform !== 'win32')('blocks POSIX system directories (exact match)', () => { + expect(validateProjectPath('/')).toMatch(/sensitive system directory/i); + expect(validateProjectPath('/etc')).toMatch(/sensitive system directory/i); + }); + + it('allows a normal, existing directory', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-validate-')); + try { + expect(validateProjectPath(dir)).toBeNull(); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + // SENSITIVE_PATHS stores the Windows entries lowercase and validateProjectPath + // matches via resolved.toLowerCase(), so 'C:\\Windows' and 'c:\\windows' are + // both blocked. path.resolve is platform-specific, so this only runs on Windows. + it.runIf(process.platform === 'win32')( + 'blocks Windows system directories regardless of case', + () => { + expect(validateProjectPath('C:\\Windows')).toMatch(/sensitive system directory/i); + expect(validateProjectPath('c:\\windows')).toMatch(/sensitive system directory/i); + expect(validateProjectPath('C:\\WINDOWS\\System32')).toMatch(/sensitive system directory/i); + } + ); +}); + describe('MCP Input Validation', () => { let testDir: string; let cg: CodeGraph; diff --git a/src/db/queries.ts b/src/db/queries.ts index fae3b754..9419a313 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -230,32 +230,28 @@ export class QueryBuilder { // deleteNode below). this.nodeCache.delete(node.id); - try { - this.stmts.insertNode.run({ - id: node.id, - kind: node.kind, - name: node.name, - qualifiedName: node.qualifiedName ?? node.name, - filePath: node.filePath, - language: node.language, - startLine: node.startLine ?? 0, - endLine: node.endLine ?? 0, - startColumn: node.startColumn ?? 0, - endColumn: node.endColumn ?? 0, - docstring: node.docstring ?? null, - signature: node.signature ?? null, - visibility: node.visibility ?? null, - isExported: node.isExported ? 1 : 0, - isAsync: node.isAsync ? 1 : 0, - isStatic: node.isStatic ? 1 : 0, - isAbstract: node.isAbstract ? 1 : 0, - decorators: node.decorators ? JSON.stringify(node.decorators) : null, - typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null, - updatedAt: node.updatedAt ?? Date.now(), - }); - } catch (error) { - throw error; - } + this.stmts.insertNode.run({ + id: node.id, + kind: node.kind, + name: node.name, + qualifiedName: node.qualifiedName ?? node.name, + filePath: node.filePath, + language: node.language, + startLine: node.startLine ?? 0, + endLine: node.endLine ?? 0, + startColumn: node.startColumn ?? 0, + endColumn: node.endColumn ?? 0, + docstring: node.docstring ?? null, + signature: node.signature ?? null, + visibility: node.visibility ?? null, + isExported: node.isExported ? 1 : 0, + isAsync: node.isAsync ? 1 : 0, + isStatic: node.isStatic ? 1 : 0, + isAbstract: node.isAbstract ? 1 : 0, + decorators: node.decorators ? JSON.stringify(node.decorators) : null, + typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null, + updatedAt: node.updatedAt ?? Date.now(), + }); } /** diff --git a/src/utils.ts b/src/utils.ts index e75e58e0..1ee1c937 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -43,7 +43,7 @@ import * as path from 'path'; const SENSITIVE_PATHS = new Set([ '/', '/etc', '/usr', '/bin', '/sbin', '/var', '/tmp', '/dev', '/proc', '/sys', '/root', '/boot', '/lib', '/lib64', '/opt', - 'C:\\', 'C:\\Windows', 'C:\\Windows\\System32', + 'c:\\', 'c:\\windows', 'c:\\windows\\system32', ]); /** From 02ea482b3734c6eff1c0293d360fe75ea3086000 Mon Sep 17 00:00:00 2001 From: Aditya Rawat Date: Sat, 23 May 2026 00:45:02 +0530 Subject: [PATCH 03/15] fix: validate projectPath in MCP handler to block sensitive directories (#230) Validate projectPath in getCodeGraph so MCP clients can't open a codegraph in a sensitive system directory. Guarded with existsSync so nested/not-yet-created sub-paths still resolve up to the default project (preserves issue #238). Adds MCP-handler rejection tests (POSIX + Windows-gated); validated on a real Windows 11 VM. Closes #230 --- __tests__/security.test.ts | 28 ++++++++++++++++++++++++++++ src/mcp/tools.ts | 14 +++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts index abb70fe6..75ac8432 100644 --- a/__tests__/security.test.ts +++ b/__tests__/security.test.ts @@ -307,6 +307,34 @@ describe('MCP Input Validation', () => { const result = await handler.execute('codegraph_search', { query: 'example', limit: -5 }); expect(result.isError).toBeFalsy(); }); + + // #230: getCodeGraph must reject a sensitive system directory passed as + // projectPath before opening it. The error surfaces through execute()'s + // catch as an isError result. /etc is sensitive on POSIX; C:\Windows on + // Windows (path.resolve is platform-specific, so each case is gated). + it.runIf(process.platform !== 'win32')( + 'rejects a sensitive POSIX projectPath (/etc) via the MCP handler', + async () => { + const result = await handler.execute('codegraph_search', { + query: 'example', + projectPath: '/etc', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/sensitive system directory/i); + } + ); + + it.runIf(process.platform === 'win32')( + 'rejects a sensitive Windows projectPath (C:\\Windows) via the MCP handler', + async () => { + const result = await handler.execute('codegraph_search', { + query: 'example', + projectPath: 'C:\\Windows', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/sensitive system directory/i); + } + ); }); describe('Atomic Writes', () => { diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index dfd41542..deb8dfdc 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -15,7 +15,7 @@ import { readFileSync, writeSync, } from 'fs'; -import { clamp, validatePathWithinRoot } from '../utils'; +import { clamp, validatePathWithinRoot, validateProjectPath } from '../utils'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -579,6 +579,18 @@ export class ToolHandler { return this.projectCache.get(projectPath)!; } + // Reject sensitive system directories before opening. Only validate a + // path that actually exists — a nested or not-yet-created sub-path of a + // real project must still be allowed to resolve UP to its .codegraph/ + // root below (issue #238), so we don't run the existence-checking + // validator on paths that are meant to walk up. + if (existsSync(projectPath)) { + const pathError = validateProjectPath(projectPath); + if (pathError) { + throw new Error(pathError); + } + } + // Walk up parent directories to find nearest .codegraph/ const resolvedRoot = findNearestCodeGraphRoot(projectPath); From 6f4b52151202fe04a086bd999b6d6239f72fe33b Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Fri, 22 May 2026 14:23:10 -0500 Subject: [PATCH 04/15] fix(mcp): make session-marker symlink resistance work on Windows (#337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O_NOFOLLOW is undefined on Windows (libuv ignores it), so the bitwise-OR silently dropped it and markSessionConsulted would follow a pre-planted symlink at the tmp marker path — the CWE-59 gap #280 closed on POSIX but not Windows. Add a cross-platform lstatSync isSymbolicLink() refuse-check before openSync (O_NOFOLLOW stays as the atomic, TOCTOU-free guard on POSIX). The existing Session-marker-symlink-resistance test now passes on Windows. Refs #280 Co-authored-by: Claude Opus 4.7 (1M context) --- src/mcp/tools.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index deb8dfdc..16df373d 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -11,6 +11,7 @@ import { constants as fsConstants, closeSync, existsSync, + lstatSync, openSync, readFileSync, writeSync, @@ -224,6 +225,16 @@ function markSessionConsulted(sessionId: string): void { try { const hash = createHash('md5').update(sessionId).digest('hex').slice(0, 16); const markerPath = join(tmpdir(), `codegraph-consulted-${hash}`); + // Refuse to follow a pre-planted symlink at the marker path (CWE-59). + // O_NOFOLLOW (below) is the atomic, TOCTOU-free guard on POSIX, but it is + // `undefined` on Windows (libuv ignores it), so the bitwise-OR silently + // drops it and openSync would follow the link. This lstat check closes that + // gap cross-platform; ENOENT (path is free) falls through to create it. + try { + if (lstatSync(markerPath).isSymbolicLink()) return; + } catch { + // No existing entry (or stat failed) — nothing to refuse; proceed. + } // O_NOFOLLOW makes openSync throw ELOOP if markerPath is already a symlink. // O_CREAT + O_TRUNC keep the original "create-or-overwrite" semantics, and // mode 0o600 prevents readback by other local users (the marker payload is From fd6a649518d306a02d61b58c1e480ddcffbf4b21 Mon Sep 17 00:00:00 2001 From: Andrew Barnes Date: Fri, 22 May 2026 15:49:49 -0400 Subject: [PATCH 05/15] docs(readme): link support badges to sections (#326) Point the previously-dead (#) support badges at new Supported Platforms / Supported Agents sections, grouped with Supported Languages near the bottom of the README. Co-authored-by: Andrew Barnes Co-authored-by: Claude Opus 4.7 (1M context) --- README.md | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 511e2094..a2c8801b 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,15 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Self-contained](https://img.shields.io/badge/Node.js-bundled%20%C2%B7%20none%20required-brightgreen.svg)](https://nodejs.org/) -[![Windows](https://img.shields.io/badge/Windows-supported-blue.svg)](#) -[![macOS](https://img.shields.io/badge/macOS-supported-blue.svg)](#) -[![Linux](https://img.shields.io/badge/Linux-supported-blue.svg)](#) +[![Windows](https://img.shields.io/badge/Windows-supported-blue.svg)](#supported-platforms) +[![macOS](https://img.shields.io/badge/macOS-supported-blue.svg)](#supported-platforms) +[![Linux](https://img.shields.io/badge/Linux-supported-blue.svg)](#supported-platforms) -[![Claude Code](https://img.shields.io/badge/Claude_Code-supported-blueviolet.svg)](#) -[![Cursor](https://img.shields.io/badge/Cursor-supported-blueviolet.svg)](#) -[![Codex CLI](https://img.shields.io/badge/Codex_CLI-supported-blueviolet.svg)](#) -[![opencode](https://img.shields.io/badge/opencode-supported-blueviolet.svg)](#) -[![Hermes Agent](https://img.shields.io/badge/Hermes_Agent-supported-blueviolet.svg)](#) +[![Claude Code](https://img.shields.io/badge/Claude_Code-supported-blueviolet.svg)](#supported-agents) +[![Cursor](https://img.shields.io/badge/Cursor-supported-blueviolet.svg)](#supported-agents) +[![Codex CLI](https://img.shields.io/badge/Codex_CLI-supported-blueviolet.svg)](#supported-agents) +[![opencode](https://img.shields.io/badge/opencode-supported-blueviolet.svg)](#supported-agents) +[![Hermes Agent](https://img.shields.io/badge/Hermes_Agent-supported-blueviolet.svg)](#supported-agents) @@ -447,6 +447,30 @@ What that means in practice: > committed `dist/`. If you commit a dependency or build directory you don't want > in the graph, add it to `.gitignore`. +## Supported Platforms + +Every release ships a self-contained build (bundled Node runtime — nothing to +compile) for all three desktop OSes, on both Intel/AMD (x64) and ARM (arm64): + +| Platform | Architectures | Install | +|----------|---------------|---------| +| Windows | x64, arm64 | PowerShell installer or npm | +| macOS | x64, arm64 | shell installer or npm | +| Linux | x64, arm64 | shell installer or npm | + +See [Get Started](#get-started) for the one-line install commands. + +## Supported Agents + +The interactive installer auto-detects and configures each of these — wiring up +the MCP server and writing its instructions file: + +- **Claude Code** +- **Cursor** +- **Codex CLI** +- **opencode** +- **Hermes Agent** + ## Supported Languages | Language | Extension | Status | From fb45959af74851b4322242633b758a81967ad7ac Mon Sep 17 00:00:00 2001 From: Infinity_Block <105136435+evanclan@users.noreply.github.com> Date: Sat, 23 May 2026 05:02:29 +0900 Subject: [PATCH 06/15] fix(mcp): reap serve --mcp child when parent is SIGKILL'd (#286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a PPID watchdog to the MCP server so a `codegraph serve --mcp` child terminates when its host (Claude Code, opencode, …) is force-killed — OOM killer, `kill -9`, container teardown — and the stdin close handlers don't fire. The child would otherwise linger indefinitely, holding inotify watches, file descriptors, and the SQLite WAL. Also propagates the host PID across the `--liftoff-only` re-exec (CODEGRAPH_HOST_PPID) so the watchdog reaps the orphan on the from-source path too, not just the bundled launcher. Poll interval is CODEGRAPH_PPID_POLL_MS (default 5000ms, 0 disables). Resolves #277. Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 12 ++ __tests__/mcp-ppid-watchdog.test.ts | 168 +++++++++++++++++++++++++++ src/extraction/wasm-runtime-flags.ts | 15 ++- src/mcp/index.ts | 97 ++++++++++++++++ 4 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 __tests__/mcp-ppid-watchdog.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e35df64..3cfadd1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.9.4] - 2026-05-22 +### Fixed +- **Orphaned `codegraph serve --mcp` processes after a parent SIGKILL.** When + the MCP host (Claude Code, opencode, …) was force-killed — OOM killer, a + `kill -9`, a container teardown — the child kept running indefinitely on + Linux, holding inotify watches, file descriptors, and the SQLite WAL. The + kernel doesn't propagate parent death to children, and the stdin + `end`/`close` handlers we relied on don't always fire. The MCP server now + polls `process.ppid` and shuts down the moment it changes from the value + observed at startup; the poll interval is `CODEGRAPH_PPID_POLL_MS` (default + `5000`, `0` disables). Resolves + [#277](https://github.com/colbymchenry/codegraph/issues/277). + ### Added - **Release archives now ship with a `SHA256SUMS` file**, and the npm launcher verifies the bundle it downloads against it — a mismatch aborts before diff --git a/__tests__/mcp-ppid-watchdog.test.ts b/__tests__/mcp-ppid-watchdog.test.ts new file mode 100644 index 00000000..0e3dc188 --- /dev/null +++ b/__tests__/mcp-ppid-watchdog.test.ts @@ -0,0 +1,168 @@ +/** + * PPID watchdog regression test (#277). + * + * On Linux, when an MCP host (Claude Code, opencode, …) is SIGKILL'd by the + * OOM killer / a force-quit / a container teardown, the kernel does NOT + * propagate the death to its `codegraph serve --mcp` child. The child gets + * reparented to init/systemd, its stdin stays half-open in some + * configurations, and the existing `stdin.on('end' | 'close')` handlers + * never fire — the server lingers indefinitely, holding inotify watches, + * file descriptors, and the SQLite WAL. + * + * `src/mcp/index.ts` polls `process.ppid` and shuts down the moment it + * diverges from the value observed at startup. This test stands up a + * four-tier process tree (vitest → wrapper → {stdin-holder, codegraph}) and + * SIGKILL's the wrapper. The stdin-holder is a long-lived sibling whose + * `stdout` pipe is dup'd into codegraph's `stdin`. After the wrapper dies + * the pipe stays open (stdin-holder still owns the write-end), so the + * existing stdin close handlers do **not** fire — the only thing that can + * terminate codegraph then is the PPID watchdog. + * + * Windows is excluded — `process.kill(pid, 'SIGKILL')` does not actually + * deliver SIGKILL there, and the per-OS reparenting semantics the watchdog + * relies on are POSIX-specific. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function waitForExit(pid: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const start = Date.now(); + const tick = () => { + if (!isAlive(pid)) return resolve(true); + if (Date.now() - start > timeoutMs) return resolve(false); + setTimeout(tick, 100); + }; + tick(); + }); +} + +describe.skipIf(process.platform === 'win32')('MCP PPID watchdog (#277)', () => { + let wrapper: ChildProcessWithoutNullStreams | null = null; + let childPid: number | null = null; + let stdinHolderPid: number | null = null; + + afterEach(() => { + if (wrapper && !wrapper.killed) { + try { wrapper.kill('SIGKILL'); } catch { /* already gone */ } + } + // Belt and suspenders — don't leak processes if an assertion failed. + for (const pid of [childPid, stdinHolderPid]) { + if (pid !== null && isAlive(pid)) { + try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } + } + } + wrapper = null; + childPid = null; + stdinHolderPid = null; + }); + + it("shuts down when its parent is SIGKILL'd and stdin stays open", async () => { + // The wrapper: + // 1. Spawns a "stdin-holder" — a tiny long-lived node process whose + // `stdout` pipe is dup'd into codegraph's `stdin`. As long as the + // stdin-holder is alive (it is — it's an orphan after the wrapper + // dies), codegraph's stdin never sees EOF. + // 2. Spawns codegraph with that pipe as fd 0 and its stderr redirected + // to a tmp file that survives the wrapper, then reports both PIDs. + // 3. Idles until SIGKILL'd from the test. + // + // CODEGRAPH_PPID_POLL_MS=200 keeps the watchdog responsive in test; the + // production default is 5000ms. + const stderrLog = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ppid-watchdog-')), + 'codegraph.stderr.log', + ); + // The wrapper waits 800ms before reporting the PIDs so the codegraph + // child has time to finish its async start() (dynamic import + transport + // setup + watchdog registration). Otherwise the test races: it + // SIGKILL's the wrapper before the watchdog interval is installed, and + // nothing terminates codegraph. + const wrapperSrc = ` + const { spawn } = require('child_process'); + const fs = require('fs'); + const stderrFd = fs.openSync(${JSON.stringify(stderrLog)}, 'a'); + const stdinHolder = spawn(process.execPath, ['-e', 'setInterval(() => {}, 60000)'], { + stdio: ['ignore', 'pipe', 'ignore'], + detached: true, + }); + stdinHolder.unref(); + const child = spawn(process.execPath, [${JSON.stringify(BIN)}, 'serve', '--mcp'], { + stdio: [stdinHolder.stdout, 'ignore', stderrFd], + env: { ...process.env, CODEGRAPH_PPID_POLL_MS: '200' }, + detached: true, + }); + child.unref(); + setTimeout(() => { + process.stdout.write(JSON.stringify({ pid: child.pid, stdinHolderPid: stdinHolder.pid }) + '\\n'); + }, 800); + setInterval(() => {}, 60000); + `; + wrapper = spawn(process.execPath, ['-e', wrapperSrc], { + stdio: ['pipe', 'pipe', 'pipe'], + }) as ChildProcessWithoutNullStreams; + + const pids = await new Promise<{ pid: number; stdinHolderPid: number }>((resolve, reject) => { + let buf = ''; + const timer = setTimeout( + () => reject(new Error('wrapper did not report PIDs in time')), + 10000, + ); + wrapper!.stdout.on('data', (chunk: Buffer) => { + buf += chunk.toString('utf8'); + const m = buf.match(/\{"pid":(\d+),"stdinHolderPid":(\d+)\}/); + if (m) { + clearTimeout(timer); + resolve({ pid: parseInt(m[1], 10), stdinHolderPid: parseInt(m[2], 10) }); + } + }); + wrapper!.on('exit', () => { + clearTimeout(timer); + reject(new Error('wrapper exited before reporting PIDs')); + }); + }); + childPid = pids.pid; + stdinHolderPid = pids.stdinHolderPid; + + expect(isAlive(childPid)).toBe(true); + expect(isAlive(stdinHolderPid)).toBe(true); + + // SIGKILL the wrapper — no cleanup runs, just like a real OOM kill. + // codegraph and the stdin-holder both get reparented to init/systemd. + // Crucially, the pipe between them stays open, so codegraph's stdin + // doesn't close: only the watchdog can take it down. + wrapper.kill('SIGKILL'); + + // Watchdog runs every 200ms in this test → 5s gives ~25 polls of headroom. + const exited = await waitForExit(childPid, 5000); + const stderrContent = fs.existsSync(stderrLog) ? fs.readFileSync(stderrLog, 'utf-8') : ''; + expect( + exited, + `codegraph child (pid=${childPid}) did not exit within 5s after wrapper was SIGKILL'd.\nstderr:\n${stderrContent}`, + ).toBe(true); + // The watchdog announces itself before tearing down — assert that the + // shutdown came from the parent-death path, not from any other signal. + expect(stderrContent).toMatch(/Parent process exited.*shutting down/); + + // The stdin-holder is now an orphan — kill it explicitly so it doesn't + // outlive the test. It's still tracked in `stdinHolderPid` for the + // afterEach safety net, but we tidy up proactively here too. + if (isAlive(stdinHolderPid)) { + try { process.kill(stdinHolderPid, 'SIGKILL'); } catch { /* race */ } + } + }, 20000); +}); diff --git a/src/extraction/wasm-runtime-flags.ts b/src/extraction/wasm-runtime-flags.ts index f33a19ff..e44c84d8 100644 --- a/src/extraction/wasm-runtime-flags.ts +++ b/src/extraction/wasm-runtime-flags.ts @@ -46,6 +46,19 @@ export const WASM_RUNTIME_FLAGS: readonly string[] = ['--liftoff-only']; */ const RELAUNCH_GUARD_ENV = 'CODEGRAPH_WASM_RELAUNCHED'; +/** + * Env var carrying the *host* PID (the relauncher's own parent) across the + * re-exec. Without `--liftoff-only` the CLI re-execs itself once, inserting an + * intermediate process between the MCP host and the server. That intermediate + * stays alive (blocked in spawnSync) even after the host is killed, so the + * server's PPID watchdog can't detect the host's death by watching its own + * `process.ppid`. Passing the host PID through lets the watchdog poll it + * directly. Unset on the no-re-exec path (bundled launcher / flag already + * present), where the server is already a direct child of the host. See + * src/mcp/index.ts (#277). + */ +export const HOST_PPID_ENV = 'CODEGRAPH_HOST_PPID'; + /** True when every required WASM runtime flag is already present in `execArgv`. */ export function processHasWasmRuntimeFlags( execArgv: readonly string[] = process.execArgv @@ -84,7 +97,7 @@ export function relaunchWithWasmRuntimeFlagsIfNeeded(scriptPath: string): void { const argv = buildRelaunchArgv(scriptPath, process.argv.slice(2)); const result = spawnSync(process.execPath, argv, { stdio: 'inherit', - env: { ...process.env, [RELAUNCH_GUARD_ENV]: '1' }, + env: { ...process.env, [RELAUNCH_GUARD_ENV]: '1', [HOST_PPID_ENV]: String(process.ppid) }, }); if (result.error) { diff --git a/src/mcp/index.ts b/src/mcp/index.ts index c790a4bc..8d0e35d7 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -21,6 +21,7 @@ import { watchDisabledReason } from '../sync'; import { StdioTransport, JsonRpcRequest, JsonRpcNotification, ErrorCodes } from './transport'; import { tools, ToolHandler } from './tools'; import { SERVER_INSTRUCTIONS } from './server-instructions'; +import { HOST_PPID_ENV } from '../extraction/wasm-runtime-flags'; /** * Convert a file:// URI to a filesystem path. @@ -60,6 +61,51 @@ const PROTOCOL_VERSION = '2024-11-05'; */ const ROOTS_LIST_TIMEOUT_MS = 5000; +/** + * How often to poll `process.ppid` to detect parent process death (see #277). + * 5s is a deliberate trade-off: the failure mode being guarded against is rare + * (parent SIGKILL'd), and longer poll = less wakeup overhead while idle. + */ +const DEFAULT_PPID_POLL_MS = 5000; + +/** + * Resolve the PPID watchdog poll interval from an env override. A value of + * `0` disables the watchdog entirely (escape hatch for embedded scenarios + * where the parent legitimately re-parents the server on purpose). Anything + * non-numeric or negative falls back to the default. + */ +function parsePpidPollMs(raw: string | undefined): number { + if (raw === undefined || raw === '') return DEFAULT_PPID_POLL_MS; + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return DEFAULT_PPID_POLL_MS; + if (parsed < 0) return DEFAULT_PPID_POLL_MS; + return Math.floor(parsed); +} + +/** + * Parse the host PID propagated across the `--liftoff-only` re-exec + * ({@link HOST_PPID_ENV}). Returns a positive integer PID, or null when + * unset/invalid — the direct-launch path, where the watchdog falls back to + * `process.ppid` divergence. PIDs of 0/1 are rejected (0 = unknown, 1 = init, + * i.e. already orphaned), so the watchdog doesn't latch onto init. + */ +function parseHostPpid(raw: string | undefined): number | null { + if (raw === undefined || raw === '') return null; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed <= 1) return null; + return parsed; +} + +/** True if a process with `pid` currently exists (signal-0 probe). */ +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + /** * Extract the first usable filesystem path from a `roots/list` result. * Shape per MCP spec: `{ roots: [{ uri: "file:///path", name?: string }] }`. @@ -95,6 +141,19 @@ export class MCPServer { // Guards the one-shot deferred resolution (roots/list or cwd) so we don't // re-issue roots/list on every tool call. private rootsAttempted = false; + // PPID watchdog — see start(). Captured at construction so we always have a + // baseline, even if start() runs after a fork-style reparent. + private originalPpid: number = process.ppid; + // The MCP host's PID, propagated across the `--liftoff-only` re-exec (see + // HOST_PPID_ENV). When set, the watchdog polls it directly: the re-exec + // inserts an intermediate process whose *death* — not just our reparenting — + // is what we'd otherwise miss. null on the direct (bundled) launch path. + private hostPpid: number | null = parseHostPpid(process.env[HOST_PPID_ENV]); + private ppidWatchdog: ReturnType | null = null; + // Idempotency guard for stop(). Without it, the watchdog can race with the + // stdin `end`/`close` handlers (or SIGTERM/SIGINT) and double-close cg and + // the transport before process.exit() lands. + private stopped = false; constructor(projectPath?: string) { this.projectPath = projectPath || null; @@ -122,6 +181,38 @@ export class MCPServer { // Detect this and shut down gracefully to prevent orphaned processes. process.stdin.on('end', () => this.stop()); process.stdin.on('close', () => this.stop()); + + // PPID watchdog (#277). Linux doesn't propagate parent death to children, + // so when the MCP host (Claude Code, opencode, …) is SIGKILL'd by the OOM + // killer / a force-quit / a container teardown, the child is reparented to + // init/systemd and the stdin `end`/`close` events don't always fire. The + // server would then linger indefinitely, holding inotify watches, file + // descriptors, and the SQLite WAL. Poll `process.ppid` and shut down the + // moment it changes from what we observed at startup. Cross-platform: + // reparenting changes ppid on Linux *and* macOS; on Windows the value can + // also drop to 0 once the parent is gone. When the CLI re-execs itself for + // `--liftoff-only`, an intermediate process sits between us and the host and + // outlives it, so our own ppid wouldn't change — in that case we poll the + // host PID (propagated via HOST_PPID_ENV) for liveness instead. The watchdog + // is `.unref()`'d so it never holds the event loop open on its own. + const pollMs = parsePpidPollMs(process.env.CODEGRAPH_PPID_POLL_MS); + if (pollMs > 0) { + this.ppidWatchdog = setInterval(() => { + const current = process.ppid; + const ppidChanged = current !== this.originalPpid; + const hostGone = this.hostPpid !== null && !isProcessAlive(this.hostPpid); + if (ppidChanged || hostGone) { + const reason = ppidChanged + ? `ppid ${this.originalPpid} -> ${current}` + : `host pid ${this.hostPpid} exited`; + process.stderr.write( + `[CodeGraph MCP] Parent process exited (${reason}); shutting down.\n` + ); + this.stop(); + } + }, pollMs); + this.ppidWatchdog.unref(); + } } /** @@ -283,6 +374,12 @@ export class MCPServer { * Stop the server */ stop(): void { + if (this.stopped) return; + this.stopped = true; + if (this.ppidWatchdog) { + clearInterval(this.ppidWatchdog); + this.ppidWatchdog = null; + } // Close all cached cross-project connections first this.toolHandler.closeAll(); // Close the main CodeGraph instance From 1f11de73ffbc2fd31e064dec97e156d842a3ef3a Mon Sep 17 00:00:00 2001 From: zhuchaokn Date: Sat, 23 May 2026 04:18:23 +0800 Subject: [PATCH 07/15] feat(cli): add callers, callees, impact commands for CLI/MCP parity (#204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `codegraph callers`, `codegraph callees`, and `codegraph impact` CLI commands, bringing the CLI to parity with the codegraph_callers/callees/impact MCP tools — so the graph-traversal queries work in scripts, CI, and git hooks without a running MCP server. All three support `--path` and `--json`; `impact` groups output by file to match the MCP layout. Co-authored-by: Claude Opus 4.7 (1M context) --- README.md | 3 + src/bin/codegraph.ts | 261 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 264 insertions(+) diff --git a/README.md b/README.md index a2c8801b..467fdd1d 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,9 @@ codegraph status [path] # Show statistics codegraph query # Search symbols (--kind, --limit, --json) codegraph files [path] # Show file structure (--format, --filter, --max-depth, --json) codegraph context # Build context for AI (--format, --max-nodes) +codegraph callers # Find what calls a function/method (--limit, --json) +codegraph callees # Find what a function/method calls (--limit, --json) +codegraph impact # Analyze what code is affected by changing a symbol (--depth, --json) codegraph affected [files...] # Find test files affected by changes (see below) codegraph serve --mcp # Start MCP server ``` diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 711d39c8..6bc63b3f 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -16,6 +16,9 @@ * codegraph query Search for symbols * codegraph files [options] Show project file structure * codegraph context Build context for a task + * codegraph callers Find what calls a function/method + * codegraph callees Find what a function/method calls + * codegraph impact Analyze what code is affected by changing a symbol * codegraph affected [files] Find test files affected by changes */ @@ -1207,6 +1210,264 @@ program } }); +/** + * codegraph callers + * + * CLI parity with the MCP graph tools (codegraph_callers/callees/impact) so the + * traversal queries work in scripts, CI, and git hooks without a running MCP + * server. + */ +program + .command('callers ') + .description('Find all functions/methods that call a specific symbol') + .option('-p, --path ', 'Project path') + .option('-l, --limit ', 'Maximum results', '20') + .option('-j, --json', 'Output as JSON') + .action(async (symbol: string, options: { path?: string; limit?: string; json?: boolean }) => { + const projectPath = resolveProjectPath(options.path); + + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph not initialized in ${projectPath}`); + process.exit(1); + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + const limit = parseInt(options.limit || '20', 10); + + const matches = cg.searchNodes(symbol, { limit: 50 }); + if (matches.length === 0) { + info(`Symbol "${symbol}" not found`); + cg.destroy(); + return; + } + + const seen = new Set(); + const allCallers: Array<{ name: string; kind: string; filePath: string; startLine?: number }> = []; + + for (const match of matches) { + const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`); + if (!exactMatch && matches.length > 1) continue; + for (const c of cg.getCallers(match.node.id)) { + if (!seen.has(c.node.id)) { + seen.add(c.node.id); + allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine }); + } + } + } + + // Fallback: if exact filter removed everything, use the top match + if (allCallers.length === 0 && matches[0]) { + for (const c of cg.getCallers(matches[0].node.id)) { + if (!seen.has(c.node.id)) { + seen.add(c.node.id); + allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine }); + } + } + } + + const limited = allCallers.slice(0, limit); + + if (options.json) { + console.log(JSON.stringify({ symbol, callers: limited }, null, 2)); + } else if (limited.length === 0) { + info(`No callers found for "${symbol}"`); + } else { + console.log(chalk.bold(`\nCallers of "${symbol}" (${limited.length}):\n`)); + for (const node of limited) { + const loc = node.startLine ? `:${node.startLine}` : ''; + console.log( + chalk.cyan(node.kind.padEnd(12)) + + chalk.white(node.name) + ); + console.log(chalk.dim(` ${node.filePath}${loc}`)); + console.log(); + } + } + + cg.destroy(); + } catch (err) { + error(`callers failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph callees + */ +program + .command('callees ') + .description('Find all functions/methods that a specific symbol calls') + .option('-p, --path ', 'Project path') + .option('-l, --limit ', 'Maximum results', '20') + .option('-j, --json', 'Output as JSON') + .action(async (symbol: string, options: { path?: string; limit?: string; json?: boolean }) => { + const projectPath = resolveProjectPath(options.path); + + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph not initialized in ${projectPath}`); + process.exit(1); + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + const limit = parseInt(options.limit || '20', 10); + + const matches = cg.searchNodes(symbol, { limit: 50 }); + if (matches.length === 0) { + info(`Symbol "${symbol}" not found`); + cg.destroy(); + return; + } + + const seen = new Set(); + const allCallees: Array<{ name: string; kind: string; filePath: string; startLine?: number }> = []; + + for (const match of matches) { + const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`); + if (!exactMatch && matches.length > 1) continue; + for (const c of cg.getCallees(match.node.id)) { + if (!seen.has(c.node.id)) { + seen.add(c.node.id); + allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine }); + } + } + } + + if (allCallees.length === 0 && matches[0]) { + for (const c of cg.getCallees(matches[0].node.id)) { + if (!seen.has(c.node.id)) { + seen.add(c.node.id); + allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine }); + } + } + } + + const limited = allCallees.slice(0, limit); + + if (options.json) { + console.log(JSON.stringify({ symbol, callees: limited }, null, 2)); + } else if (limited.length === 0) { + info(`No callees found for "${symbol}"`); + } else { + console.log(chalk.bold(`\nCallees of "${symbol}" (${limited.length}):\n`)); + for (const node of limited) { + const loc = node.startLine ? `:${node.startLine}` : ''; + console.log( + chalk.cyan(node.kind.padEnd(12)) + + chalk.white(node.name) + ); + console.log(chalk.dim(` ${node.filePath}${loc}`)); + console.log(); + } + } + + cg.destroy(); + } catch (err) { + error(`callees failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph impact + */ +program + .command('impact ') + .description('Analyze what code is affected by changing a symbol') + .option('-p, --path ', 'Project path') + .option('-d, --depth ', 'Traversal depth', '2') + .option('-j, --json', 'Output as JSON') + .action(async (symbol: string, options: { path?: string; depth?: string; json?: boolean }) => { + const projectPath = resolveProjectPath(options.path); + + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph not initialized in ${projectPath}`); + process.exit(1); + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + const depth = Math.min(Math.max(parseInt(options.depth || '2', 10), 1), 10); + + const matches = cg.searchNodes(symbol, { limit: 50 }); + if (matches.length === 0) { + info(`Symbol "${symbol}" not found`); + cg.destroy(); + return; + } + + // Merge impact subgraphs across all exact-matching symbols + const mergedNodes = new Map(); + const seenEdges = new Set(); + let edgeCount = 0; + + for (const match of matches) { + const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`); + if (!exactMatch && matches.length > 1) continue; + const impact = cg.getImpactRadius(match.node.id, depth); + for (const [id, n] of impact.nodes) { + mergedNodes.set(id, { name: n.name, kind: n.kind, filePath: n.filePath, startLine: n.startLine }); + } + for (const e of impact.edges) { + const key = `${e.source}->${e.target}:${e.kind}`; + if (!seenEdges.has(key)) { + seenEdges.add(key); + edgeCount++; + } + } + } + + // Fallback to top match if exact filter removed everything + if (mergedNodes.size === 0 && matches[0]) { + const impact = cg.getImpactRadius(matches[0].node.id, depth); + for (const [id, n] of impact.nodes) { + mergedNodes.set(id, { name: n.name, kind: n.kind, filePath: n.filePath, startLine: n.startLine }); + } + edgeCount = impact.edges.length; + } + + if (options.json) { + console.log(JSON.stringify({ + symbol, + depth, + nodeCount: mergedNodes.size, + edgeCount, + affected: Array.from(mergedNodes.values()), + }, null, 2)); + } else if (mergedNodes.size === 0) { + info(`No affected symbols found for "${symbol}"`); + } else { + console.log(chalk.bold(`\nImpact of changing "${symbol}" — ${mergedNodes.size} affected symbols:\n`)); + + // Group by file + const byFile = new Map>(); + for (const node of mergedNodes.values()) { + const list = byFile.get(node.filePath) || []; + list.push({ name: node.name, kind: node.kind, startLine: node.startLine }); + byFile.set(node.filePath, list); + } + + for (const [file, nodes] of byFile) { + console.log(chalk.cyan(file)); + for (const node of nodes) { + const loc = node.startLine ? `:${node.startLine}` : ''; + console.log(` ${chalk.dim(node.kind.padEnd(12))}${node.name}${chalk.dim(loc)}`); + } + console.log(); + } + } + + cg.destroy(); + } catch (err) { + error(`impact failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + /** * codegraph affected [files...] * From f366222dbd6b7e43047072a9417289b1b02ae457 Mon Sep 17 00:00:00 2001 From: Aimore Date: Fri, 22 May 2026 21:23:24 +0100 Subject: [PATCH 08/15] docs(readme): add codegraph_explore to the MCP Tools table (#226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the missing `codegraph_explore` row to the 'MCP server exposes these tools' table — tools.ts exports 9 tools but the table listed 8. (The PR's Node-badge bump was dropped: that badge was replaced by 'Node.js bundled · none required' when the runtime became self-contained.) Co-authored-by: Claude Opus 4.7 (1M context) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 467fdd1d..faf357bc 100644 --- a/README.md +++ b/README.md @@ -401,6 +401,7 @@ When running as an MCP server, CodeGraph exposes these tools to Claude Code: | `codegraph_callees` | Find what a function calls | | `codegraph_impact` | Analyze what code is affected by changing a symbol | | `codegraph_node` | Get details about a specific symbol (optionally with source code) | +| `codegraph_explore` | Return source for several related symbols grouped by file, plus a relationship map, in one call | | `codegraph_files` | Get indexed file structure (faster than filesystem scanning) | | `codegraph_status` | Check index health and statistics | From 025ebc88d6d708edd3732f5cb68516148719a061 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sun, 24 May 2026 04:41:04 -0500 Subject: [PATCH 09/15] Release 0.9.4: framework-aware routing + dynamic-dispatch coverage + retrieval improvements (#365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(resolution): close dynamic-dispatch coverage holes (callback synthesis + django ORM) Static tree-sitter extraction misses calls whose target is computed or indirect, so flows through callbacks, observers, and descriptors were absent from the graph. - callback-synthesizer.ts: whole-graph pass after base resolution. Detects registrar/dispatcher channels (field-backed observers + string-keyed EventEmitters), correlates registration sites, and synthesizes dispatcher->callback `calls` edges (provenance:'heuristic'). Records the registration site (registeredAt) in edge metadata. Precision guards: named handlers only, registrar-name match, event fan-out cap. - frameworks/python.ts + resolution/{index,types}.ts: claimsReference hook + django ORM resolver (_iterable_class -> ModelIterable.__iter__). - extraction/tree-sitter.ts: extract named nested functions so inline named handlers become linkable nodes. trace(mutateElement, triggerRender) and trace(_fetch_all, execute_sql) now connect; node count stable (no explosion). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(mcp): self-sufficient flow output + fix explore budget regression - Surface synthesized-edge evidence in trace, the node trail, and context call paths: a dynamic-dispatch hop now shows "callback via onUpdate @App.tsx:3148" with the registration site inline (and trace inlines each hop's call-site source line) -- the exact glue agents previously Read/Grep'd to reconstruct. - Fix non-monotonic explore output budget: the 500-5000 file tier capped maxCharsPerFile at 2500, BELOW the <500 tier's 3800, so on god-file projects (excalidraw's 415 KB App.tsx) one explore returned <1% of the file and forced a Read. Raised to 6500/file, 28000 total. - Stop explore from inviting Read: truncation/trim notes said "use Read for more"; they now steer to another codegraph_explore and treat returned source as already Read. Measured on excalidraw: best-case flow answer went from 5 reads / 131s to 0 reads / 73s with ~3-4 codegraph calls. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(agent-eval): coverage probes, block-read hook, and design docs Dev-only validation harness for the dynamic-dispatch coverage work: - probe-{trace,node,context,explore}.mjs: drive MCP tools against a built index without a full agent run. - block-read-hook.sh + hook-settings.json: PreToolUse experiment that denies source Reads to measure codegraph sufficiency (forced Read-0). - docs/design/: callback-edge-synthesis + dynamic-dispatch-coverage playbook. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(resolution): bridge React boundaries — re-render + JSX child synthesis Closes the two dynamic-dispatch hops that broke "state mutation -> on-screen render" flows in React apps. Both are call-invisible (React-internal) but the code between them is fully call-connected, so one synthesized edge each makes the whole flow trace end-to-end. - reactRenderEdges: setState(...) re-runs the component's render(). For each class with a render method, link sibling methods calling this.setState -> render. The setState gate keeps it to React class components. - reactJsxChildEdges: a component that returns mounts Child. Link parent -> each capitalized JSX child, resolved to a component/function/class node (the resolution gate drops TS generics like Array). File-oriented, capped per parent. - Surface both in synthEdgeNote (trace + node trail) and context call-paths. Validated on excalidraw: trace(mutateElement, renderStaticScene) now connects in 6 hops across callback -> react-render -> jsx-child; 1 + 46 + 280 synthesized edges, node count stable (no explosion). Partial coverage is worse than none: react-render alone raised agent reads (revealed a hop it then drilled); adding the jsx hop closed the flow and dropped reads to 0-1. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(claude): retrieval performance contract + coverage validation methodology Add a "Retrieval performance & dynamic-dispatch coverage" section so future changes/PRs don't silently regress agent retrieval: - the explore call+output budget table by repo size, with the monotonic-per-file invariant (the bug that started this: <5000 tier's 2500 < <500 tier's 3800). - the "partial coverage is worse than none" principle. - the required validation methodology (small/medium/large x >=3 prompts per language x framework; deterministic probes + agent A/B; pass bar). - the Excalidraw worked example (before/after numbers) as the template to replicate for every language/framework. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(claude): use full n=4 measured range in Excalidraw worked example Best run 0 Read/3 cg/76s; typical ~1 Read/~4 cg; occasional over-drill outlier. Report the range, not a single run — run-to-run variance is large. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(mcp): steer flow questions to codegraph_trace first (tightens variance) codegraph_trace was absent from every steering intent map — all three guidance files routed "how does X reach Y" to context+explore, never to the trace tool. So agents used trace only by chance; when one didn't, it floundered reconstructing the path with search+callers (an 18-call run vs ~6 for trace-users). Add codegraph_trace to the intent map + a "flow" common chain (trace from->to FIRST = the whole path in one call, then ONE explore for bodies) across all three synced files (server-instructions, instructions-template, .cursor rule). Validated on excalidraw (hard "to the screen" Q, n=4 before/after): - call count 3-10 -> 3-4 (over-drill outlier gone) - duration 64-112s -> 51-74s - trace adoption 3/4 -> 4/4; search+callers path-reconstruction -> 0 - fully-clean runs (0 Read, 0 Grep) 0/4 -> 2/4; best 3 cg / 0 / 0 / 51s Co-Authored-By: Claude Opus 4.7 (1M context) * feat(resolution): Vue SFC template coverage (events + kebab components) The .vue extractor only parses + + + + diff --git a/site/src/styles/theme.css b/site/src/styles/theme.css new file mode 100644 index 00000000..74a0b0e0 --- /dev/null +++ b/site/src/styles/theme.css @@ -0,0 +1,217 @@ +/* ===================================================================== + codegraph — flat / paper editorial theme + Monochrome ink-on-paper, hairline rules, square corners. Shared by the + custom landing page (src/pages/index.astro) and the Starlight docs. + ===================================================================== */ + +/* ---- Fonts ---- */ +:root { + --sl-font: 'Archivo Variable', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif; + --sl-font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; +} + +/* ---- Starlight colour mapping: light / paper (default) ---- */ +:root, +:root[data-theme='light'] { + --sl-color-accent-low: #e2dfd5; + --sl-color-accent: #16150f; + --sl-color-accent-high: #16150f; + --sl-color-white: #16150f; + --sl-color-gray-1: #2a281f; + --sl-color-gray-2: #56544a; + --sl-color-gray-3: #6f6c61; + --sl-color-gray-4: #87847a; + --sl-color-gray-5: #b4b1a5; + --sl-color-gray-6: #d6d3c8; + --sl-color-gray-7: #e8e6dd; + --sl-color-black: #f7f6f2; + + --sl-color-bg: #f7f6f2; + --sl-color-bg-nav: #f7f6f2; + --sl-color-bg-sidebar: #f7f6f2; + --sl-color-bg-inline-code: #e8e6dd; + --sl-color-bg-accent: #16150f; + + --sl-color-text: #16150f; + --sl-color-text-accent: #16150f; + --sl-color-text-invert: #f7f6f2; + + --sl-color-hairline: #16150f; + --sl-color-hairline-light: #d6d3c8; + --sl-color-hairline-shade: #d6d3c8; + + /* shared tokens */ + --cg-paper: #f7f6f2; + --cg-paper-2: #f1efe8; + --cg-paper-press: #e8e6dd; + --cg-ink: #16150f; + --cg-ink-2: #56544a; + --cg-ink-3: #87847a; + --cg-rule: #16150f; + --cg-rule-soft: #d6d3c8; +} + +/* ---- Starlight colour mapping: dark / ink ---- */ +:root[data-theme='dark'] { + --sl-color-accent-low: #34322a; + --sl-color-accent: #f3f1ea; + --sl-color-accent-high: #f3f1ea; + --sl-color-white: #f3f1ea; + --sl-color-gray-1: #e7e5dc; + --sl-color-gray-2: #c9c6ba; + --sl-color-gray-3: #a7a499; + --sl-color-gray-4: #7c7a70; + --sl-color-gray-5: #57554c; + --sl-color-gray-6: #2c2a23; + --sl-color-gray-7: #1e1c16; + --sl-color-black: #16150f; + + --sl-color-bg: #16150f; + --sl-color-bg-nav: #16150f; + --sl-color-bg-sidebar: #16150f; + --sl-color-bg-inline-code: #23211a; + --sl-color-bg-accent: #f3f1ea; + + --sl-color-text: #f3f1ea; + --sl-color-text-accent: #f3f1ea; + --sl-color-text-invert: #16150f; + + --sl-color-hairline: #f3f1ea; + --sl-color-hairline-light: #34322a; + --sl-color-hairline-shade: #34322a; + + --cg-paper: #16150f; + --cg-paper-2: #1e1c16; + --cg-paper-press: #23211a; + --cg-ink: #f3f1ea; + --cg-ink-2: #b8b5a8; + --cg-ink-3: #87847a; + --cg-rule: #f3f1ea; + --cg-rule-soft: #34322a; +} + +/* ---- Global flat resets ---- */ +*, +*::before, +*::after { + border-radius: 0 !important; /* this design has no rounded corners, anywhere */ +} + +:root { + --sl-shadow-sm: none; + --sl-shadow-md: none; + --sl-shadow-lg: none; +} + +body { + background: var(--cg-paper); + color: var(--cg-ink); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +:where(h1, h2, h3, h4, h5) { + letter-spacing: -0.02em; +} + +/* ---- Docs chrome ---- */ + +/* Header: one crisp bottom rule. Starlight nests
inside +
, so a bare `.header { border-bottom }` draws two + lines — put the rule on the outer
only and clear the inner div. */ +.header { + background: var(--cg-paper); + -webkit-backdrop-filter: none; + backdrop-filter: none; +} +header.header { + border-bottom: 1px solid var(--cg-rule); +} +.header .header { + border-bottom: 0; +} + +/* Sidebar: crisp right rule */ +#starlight__sidebar, +.sidebar-pane { + border-inline-end: 1px solid var(--cg-rule); + background: var(--cg-paper); +} + +/* Sidebar group labels — small caps, committed editorial direction */ +.sidebar-content details > summary, +.sidebar-content > ul > li > span, +.sidebar-content .large { + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + font-size: 0.72rem; + color: var(--cg-ink-2); +} + +/* Sidebar links */ +.sidebar-content a { + color: var(--cg-ink-2); +} +.sidebar-content a:hover { + background: var(--cg-paper-press); + color: var(--cg-ink); +} +.sidebar-content a[aria-current='page'], +.sidebar-content a[aria-current='page']:hover { + background: transparent; + color: var(--cg-ink); + font-weight: 700; + border-inline-start: 2px solid var(--cg-ink); +} + +/* Right "On this page" rail */ +starlight-toc a { + color: var(--cg-ink-3); +} +starlight-toc a[aria-current='true'] { + color: var(--cg-ink); + font-weight: 600; +} + +/* Prev / next pagination: flat bordered boxes */ +.pagination-links a { + border: 1px solid var(--cg-rule); + box-shadow: none; + background: var(--cg-paper); +} +.pagination-links a:hover { + background: var(--cg-paper-press); +} + +/* Inline code */ +.sl-markdown-content :not(pre) > code { + border: 1px solid var(--cg-rule-soft); + background: var(--cg-paper-2); + font-size: 0.875em; +} + +/* Cards / asides: square, hairline */ +.card, +.starlight-aside { + border: 1px solid var(--cg-rule); + box-shadow: none; +} + +/* Search trigger */ +button[data-open-modal] { + border: 1px solid var(--cg-rule); + background: var(--cg-paper); +} + +/* Content horizontal rules */ +.sl-markdown-content hr { + border: 0; + border-top: 1px solid var(--cg-rule); +} + +/* Links in prose */ +.sl-markdown-content a { + color: var(--cg-ink); + text-underline-offset: 3px; +} diff --git a/site/tsconfig.json b/site/tsconfig.json new file mode 100644 index 00000000..8bf91d3b --- /dev/null +++ b/site/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} From 7f30c4f5c8705413b4c4641120cb947d97763795 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sun, 24 May 2026 13:25:24 -0500 Subject: [PATCH 12/15] docs(readme): link to the website & docs site (#376) Add a prominent link to https://colbymchenry.github.io/codegraph/ at the top of the README so visitors landing on the repo can reach the site. Co-authored-by: Claude Opus 4.7 (1M context) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 5d00c671..0b348cb8 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ **~35% cheaper · ~70% fewer tool calls · 100% local** +### [Documentation & Website →](https://colbymchenry.github.io/codegraph/) + [![npm version](https://img.shields.io/npm/v/@colbymchenry/codegraph.svg)](https://www.npmjs.com/package/@colbymchenry/codegraph) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Self-contained](https://img.shields.io/badge/Node.js-bundled%20%C2%B7%20none%20required-brightgreen.svg)](https://nodejs.org/) From 6a2098b19953396521389bc29526592ee87634bb Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sun, 24 May 2026 13:27:45 -0500 Subject: [PATCH 13/15] feat(site): add Docs & Languages links to the docs header (#377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the Starlight docs header to parity with the landing nav — now Docs · Languages · GitHub · star pill, in that order. Added via the SocialIcons slot (no full header rebuild, so search/theme/mobile keep working); the text links and star pill are hidden on mobile, where the sidebar already covers navigation. Co-authored-by: Claude Opus 4.7 (1M context) --- site/src/components/SocialIcons.astro | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/site/src/components/SocialIcons.astro b/site/src/components/SocialIcons.astro index a1b38270..752c241e 100644 --- a/site/src/components/SocialIcons.astro +++ b/site/src/components/SocialIcons.astro @@ -1,13 +1,18 @@ --- -// Keep Starlight's default social icons (the GitHub link) and append a live -// star-count pill, matching the landing page nav. +// Docs-header right cluster: Docs + Languages text links, Starlight's default +// social icons (the GitHub link), and a live star-count pill — same order as +// the landing nav (Docs · Languages · GitHub · Star). Done here, in the slot we +// already own, to avoid a full header rebuild that could break search/mobile. import Default from '@astrojs/starlight/components/SocialIcons.astro'; import { getStarsLabel } from '../lib/github'; const stars = await getStarsLabel(); +const base = import.meta.env.BASE_URL.replace(/\/$/, ''); const repo = 'https://github.com/colbymchenry/codegraph'; --- +Docs +Languages