diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7738ec9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Test on ${{ matrix.os }} (Node ${{ matrix.node-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node-version: [22] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm ci + + - name: Typecheck / Lint + run: npm run lint + + - name: Run test suite + run: npm test + + - name: Build extension + run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48488ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.DS_Store +*.log +.scratch +coverage +handoff.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ab7a179 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,47 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This repository is currently in the v0.1 planning stage; the accepted scope is in `docs/specs/2026-08-26-v0.1-native-explore.md`. Architecture and constraints live in `docs/architecture.md`, and durable decisions live in `docs/adr/`. Keep terminology aligned with `CONTEXT.md`. + +The planned TypeScript layout is: + +```text +src/index.ts Pi extension registration +src/tools/explore.ts codegraph_explore tool +src/codegraph/ CLI launch, detection, and error mapping +src/project/detect.ts .codegraph index check +test/ unit and fixed-fixture integration tests +examples/ copyable single-file extension +``` + +Do not access `.codegraph` internals or add an MCP adapter. The only v0.1 tool is `codegraph_explore({ query })`. + +## Build, Test, and Development Commands + +Node.js 22+ is required. The implementation has not yet added a package manifest or scripts, so do not claim unimplemented commands work. Once the package is introduced, use its declared scripts as the source of truth; the expected workflow is: + +```sh +npm install # install repository dependencies +npm run build # compile the extension +npm test # run deterministic and fixture tests +npm run lint # run configured static checks +``` + +Run the relevant test, then the complete suite before review. + +## Coding Style & Naming Conventions + +Use TypeScript with 2-space indentation, semicolons, and explicit types at process and Pi API boundaries. Name files in lowercase kebab-free paths such as `src/tools/explore.ts`; use `camelCase` for values/functions and `PascalCase` for types. Keep `index.ts` thin and put subprocess behavior behind a reusable internal runner. + +Launch CodeGraph with a command-and-argument array, never shell interpolation. Derive the working directory exclusively from the Pi context; never accept a model-supplied path or executable override. Bound stdout and stderr, preserve cancellation, and return normalized error codes with remediation. + +## Testing Guidelines + +Test observable tool behavior, not helper call sequences. Use a controlled fake `codegraph` executable for argument preservation, missing CLI/index, non-zero exits, timeout, cancellation, truncation, duplicate registration, and Windows `.cmd` fallback. Keep a small fixed real-CodeGraph fixture for CI. Test names should describe behavior, for example `returns_CODEGRAPH_TIMEOUT_after_30_seconds`. + +## Commit & Pull Request Guidelines + +Follow the existing Conventional Commit style: `docs: add v0.1 native explore specification`. Use a focused type and imperative summary, e.g. `feat: add explore tool runner`. Keep commits scoped and do not stage local `handoff.md` or `.DS_Store` files. + +PRs should state the user-facing change, link the issue, list tests run, and call out CLI, timeout, output-bound, or platform changes. Update architecture, ADR, or specification documents when a durable design decision changes. diff --git a/README.md b/README.md index 488113a..2225064 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,106 @@ # pi-codegraph -A Pi-native extension that gives the [Pi coding agent](https://github.com/badlogic/pi-mono) structural understanding of the current workspace through the [CodeGraph](https://github.com/colbymchenry/codegraph) CLI. +A Pi-native extension that provides the [Pi coding agent](https://github.com/badlogic/pi-mono) with structural code exploration capabilities through the [CodeGraph](https://github.com/colbymchenry/codegraph) CLI. -## Status +## Core Philosophy -Planning for `v0.1.0 — Native Explore`. The project is not implemented yet. +- **Pi-native:** Integrates using Pi's native extension APIs (`registerTool`, `addPromptGuidelines`, `addPromptSnippet`) rather than introducing an MCP server lifecycle or adapter layer. +- **CodeGraph-compatible:** Interacts strictly through CodeGraph's public CLI interface (`codegraph explore `). It does not touch `.codegraph` database internals or import internal CodeGraph packages. +- **Upstream-independent:** Functions independently without requiring CodeGraph upstream modifications, forks, or PRs. -## Purpose +## Prerequisites -`pi-codegraph` exposes one focused Pi tool, `codegraph_explore`, for questions about code structure, symbols, relationships, implementations, and call paths. It runs CodeGraph in the active Pi workspace and returns the useful command output to the agent. +1. **Node.js 22+** (macOS, Linux; Windows experimental). +2. **CodeGraph CLI** installed and available on your system `PATH`: + ```sh + # Verify CodeGraph CLI is available + codegraph --version + ``` +3. **Initialized Workspace Index:** The active workspace must have a `.codegraph/` index: + ```sh + # Run in your project root once + codegraph init + ``` -The project is **Pi-native, CodeGraph-compatible, and upstream-independent**: +## Installation -- Pi-native: use Pi's extension APIs rather than embedding a generic MCP client. -- CodeGraph-compatible: depend on CodeGraph's public CLI contract only. -- Upstream-independent: do not require a CodeGraph fork or upstream Pi support. +### Option 1: npm Package -## Initial architecture +Install the package in your Pi environment: -```text -Pi agent - └─ Pi extension: pi-codegraph - └─ CodeGraph CLI (`codegraph explore `) - └─ current workspace's .codegraph/ index +```sh +npm install pi-codegraph ``` -See [the architecture document](docs/architecture.md) and [the roadmap](docs/roadmap.md) for the boundaries and planned milestones. +And load it in your Pi configuration: -## v0.1 scope +```ts +import registerPiExtension from "pi-codegraph"; -- One LLM-callable tool: `codegraph_explore({ query })`. -- Execute `codegraph explore` in the active Pi workspace. -- Detect a missing CodeGraph CLI and missing `.codegraph/` index with actionable errors. -- Spawn the CLI using an argument array; never interpolate user input into a shell command. +export default function (pi) { + registerPiExtension(pi); +} +``` + +### Option 2: Copyable Single-File Extension + +For local trial or direct audit without npm dependencies, copy [`examples/pi-codegraph.ts`](examples/pi-codegraph.ts) into your local Pi extensions directory. + +## Features & LLM Capabilities + +### `codegraph_explore` + +Exposes exactly one focused LLM-callable tool: + +```json +{ + "query": "How does authentication flow from API endpoints to the database?" +} +``` + +### Prompt Routing Guidance + +The extension injects native prompt guidelines advising the agent when to choose `codegraph_explore`: + +- **Use `codegraph_explore` for:** + - System or module architecture + - Multi-file feature implementations + - Symbol relationships and implementations + - Call paths and request lifecycles + - Cross-file dependencies and change blast radius +- **Use built-in `grep` / `find` / `read` for:** + - Exact literal string matching + - Known files and line numbers + - Documentation, configuration, build scripts, or generated files -Out of scope: MCP adapter/client support, automatic installation or indexing, direct database access, caching, background synchronization, and multiple tools. +## Security & Reliability Guardrails -## Compatibility +- **Workspace Sandbox:** Derived exclusively from the active Pi session (`cwd`); LLM cannot supply arbitrary directory paths. +- **Safe Process Spawning:** Uses argument arrays (`spawn`), strictly preventing shell interpolation and injection risks. +- **Output Bounds:** Exploration output is capped at 50 KB or 2,000 lines with an explicit truncation notice to prevent model context exhaustion. +- **Timeout & Cancellation:** 30-second timeout (`CODEGRAPH_TIMEOUT`) and `AbortSignal` cancellation propagation ensure child processes are terminated promptly. +- **Zero Network & Zero Telemetry:** Sends no external network requests and collects no telemetry. -`pi-codegraph` will require Node.js 22 or newer. macOS and Linux are supported targets for v0.1; Windows is experimental while its command-launcher behavior is validated. +## Normalized Error Codes -## Contributing +Process failures return actionable error messages without leaking internal Node stack traces: -The implementation plan will be added before development begins. Issues and design feedback are welcome. +| Error Code | Meaning | Remediation | +| --- | --- | --- | +| `CODEGRAPH_NOT_FOUND` | CLI binary not located on `PATH` | Install CodeGraph or update system `PATH`. | +| `CODEGRAPH_NOT_INITIALIZED` | Workspace missing `.codegraph/` | Run `codegraph init` in workspace. | +| `CODEGRAPH_TIMEOUT` | Process exceeded 30 seconds | Refine query to be more specific. | +| `CODEGRAPH_ABORTED` | Cancelled by agent signal | Re-run if cancelled unintentionally. | +| `CODEGRAPH_COMMAND_FAILED` | CLI returned non-zero exit | Inspect the bounded stderr tail (up to 4 KB). | + +## Development + +```sh +npm install # Install dependencies +npm run build # Compile TypeScript (tsc) +npm test # Run test suite +npm run lint # Static type-check +``` ## License diff --git a/examples/pi-codegraph.ts b/examples/pi-codegraph.ts new file mode 100644 index 0000000..80022d0 --- /dev/null +++ b/examples/pi-codegraph.ts @@ -0,0 +1,309 @@ +/** + * pi-codegraph: Standalone copyable single-file Pi extension. + * + * Provides the `codegraph_explore` tool for structural code exploration + * using an existing CodeGraph index in the active workspace. + * + * Requirements: Node.js 22+, CodeGraph CLI on PATH. + */ + +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { spawn, execFile, type ChildProcess } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const DEFAULT_TIMEOUT_MS = 30_000; +const MAX_STDOUT_BYTES = 50 * 1024; // 50 KB +const MAX_STDOUT_LINES = 2_000; +const MAX_STDERR_BYTES = 4 * 1024; // 4 KB +const TRUNCATION_MARKER = + "\n\n[Warning: CodeGraph output exceeded limit and was truncated. Refine query for specific results.]"; + +export const PROMPT_GUIDELINES = ` +# CodeGraph Exploration Guidelines + +Use \`codegraph_explore\` first when understanding: +- High-level system, module, or service architecture +- Feature implementations across multiple files +- Symbol relationships (who defines, uses, or implements a symbol) +- Call paths, execution flow, and request lifecycles +- Cross-file dependencies and change blast radius +- Relevant code by concept rather than exact text + +Prefer \`grep\` / \`find\` / \`read\` when: +- Searching for an exact literal string or pattern +- Reading a known file with known line numbers +- Inspecting documentation, configuration files, or build scripts +- Inspecting generated files or dependencies + +Avoid immediately re-reading all source files returned by CodeGraph unless specific details are missing. +`.trim(); + +export const PROMPT_SNIPPET = "Use codegraph_explore for structural code understanding and symbol relationships."; + +async function hasCodeGraphIndex(workspaceDir: string): Promise { + try { + const stat = await fs.stat(path.join(workspaceDir, ".codegraph")); + return stat.isDirectory(); + } catch { + return false; + } +} + +async function detectCodeGraphExecutable(options: { pathEnv?: string; platform?: string } = {}) { + const platform = options.platform ?? process.platform; + const rawPath = options.pathEnv ?? process.env.PATH ?? ""; + const delimiter = platform === "win32" && rawPath.includes(";") ? ";" : path.delimiter; + const pathDirs = rawPath.split(delimiter).filter(Boolean); + + const candidateNames = platform === "win32" + ? ["codegraph", "codegraph.cmd", "codegraph.exe", "codegraph.bat"] + : ["codegraph"]; + + for (const dir of pathDirs) { + for (const name of candidateNames) { + const fullPath = path.join(dir, name); + try { + const stat = await fs.stat(fullPath); + if (stat.isFile() || stat.isSymbolicLink()) { + let version: string | undefined; + try { + const isCmd = platform === "win32" && (fullPath.endsWith(".cmd") || fullPath.endsWith(".bat")); + const { stdout } = await execFileAsync(fullPath, ["--version"], { + timeout: 10000, + shell: isCmd + }); + version = stdout.trim(); + } catch {} + return { available: true, executablePath: fullPath, version }; + } + } catch {} + } + } + return { available: false }; +} + +function terminateProcess(child: ChildProcess): void { + try { + if (child.pid && !child.killed) { + child.kill("SIGTERM"); + const killTimer = setTimeout(() => { + try { + if (!child.killed) child.kill("SIGKILL"); + } catch {} + }, 1000); + killTimer.unref?.(); + } + } catch {} +} + +async function runCodeGraph(options: { + args: string[]; + cwd: string; + executablePath?: string; + signal?: AbortSignal; + timeoutMs?: number; + env?: Record; +}) { + const executablePath = options.executablePath ?? "codegraph"; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + if (options.signal?.aborted) { + throw new Error("[CODEGRAPH_ABORTED] CodeGraph execution was cancelled before starting."); + } + + return new Promise<{ stdout: string; truncated: boolean }>((resolve, reject) => { + let child: ChildProcess; + const isWin = process.platform === "win32"; + const isCmdOrBat = isWin && (executablePath.endsWith(".cmd") || executablePath.endsWith(".bat")); + + try { + if (isCmdOrBat) { + const comSpec = process.env.ComSpec || "cmd.exe"; + const formattedArgs = options.args.map((arg) => { + if (arg.includes(" ") || arg.includes('"') || arg.includes("'") || arg.includes("\n")) { + return `"${arg.replace(/"/g, '""')}"`; + } + return arg; + }); + const cmdLine = `""${executablePath}" ${formattedArgs.join(" ")}"`; + child = spawn(comSpec, ["/d", "/s", "/c", cmdLine], { + cwd: options.cwd, + env: options.env ? { ...process.env, ...options.env } : process.env, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + windowsVerbatimArguments: true + }); + } else { + child = spawn(executablePath, options.args, { + cwd: options.cwd, + env: options.env ? { ...process.env, ...options.env } : process.env, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true + }); + } + } catch (err: any) { + if (err.code === "ENOENT") { + return reject( + new Error(`[CODEGRAPH_NOT_FOUND] CodeGraph executable '${executablePath}' not found on PATH.`) + ); + } + return reject(err); + } + + let stdoutText = ""; + let stdoutBytes = 0; + let stdoutLines = 0; + let truncated = false; + let stderrBuffer = ""; + let timedOut = false; + let aborted = false; + + const timeoutTimer = setTimeout(() => { + timedOut = true; + terminateProcess(child); + }, timeoutMs); + + const onAbort = () => { + aborted = true; + terminateProcess(child); + }; + + if (options.signal) { + options.signal.addEventListener("abort", onAbort, { once: true }); + } + + child.stdout?.on("data", (chunk: Buffer) => { + if (truncated) return; + const str = chunk.toString("utf-8"); + const nextBytes = stdoutBytes + Buffer.byteLength(str, "utf-8"); + let lines = 0; + for (let i = 0; i < str.length; i++) { + if (str[i] === "\n") lines++; + } + if (nextBytes > MAX_STDOUT_BYTES || stdoutLines + lines > MAX_STDOUT_LINES) { + truncated = true; + const remain = Math.max(0, MAX_STDOUT_BYTES - stdoutBytes); + if (remain > 0) stdoutText += str.slice(0, remain); + } else { + stdoutText += str; + stdoutBytes = nextBytes; + stdoutLines += lines; + } + }); + + child.stderr?.on("data", (chunk: Buffer) => { + stderrBuffer += chunk.toString("utf-8"); + if (Buffer.byteLength(stderrBuffer, "utf-8") > MAX_STDERR_BYTES) { + const excess = Buffer.byteLength(stderrBuffer, "utf-8") - MAX_STDERR_BYTES; + stderrBuffer = stderrBuffer.slice(excess); + } + }); + + child.on("error", (err: any) => { + clearTimeout(timeoutTimer); + if (options.signal) options.signal.removeEventListener("abort", onAbort); + if (err.code === "ENOENT") { + reject(new Error(`[CODEGRAPH_NOT_FOUND] CodeGraph executable '${executablePath}' not found on PATH.`)); + } else { + reject(err); + } + }); + + child.on("close", (code) => { + clearTimeout(timeoutTimer); + if (options.signal) options.signal.removeEventListener("abort", onAbort); + + if (aborted) { + return reject(new Error("[CODEGRAPH_ABORTED] CodeGraph execution was cancelled by agent.")); + } + if (timedOut) { + return reject( + new Error(`[CODEGRAPH_TIMEOUT] CodeGraph execution timed out after ${timeoutMs / 1000}s.`) + ); + } + if (code !== 0) { + return reject( + new Error(`[CODEGRAPH_COMMAND_FAILED] CodeGraph exited with code ${code}.\n${stderrBuffer.trim()}`) + ); + } + + let finalStdout = stdoutText; + if (truncated) finalStdout += TRUNCATION_MARKER; + resolve({ stdout: finalStdout, truncated }); + }); + }); +} + +export function createExploreTool() { + return { + name: "codegraph_explore", + description: + "Explore code structure, symbols, relationships, implementations, and call paths using the current project's CodeGraph index.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: + "Natural-language question about code architecture, symbols, relationships, implementation, or execution flow." + } + }, + required: ["query"] + }, + async execute(args: { query: string }, context: { workspacePath?: string; cwd?: string; signal?: AbortSignal; env?: Record } = {}) { + const workspaceDir = context.workspacePath ?? context.cwd ?? process.cwd(); + + const hasIndex = await hasCodeGraphIndex(workspaceDir); + if (!hasIndex) { + throw new Error( + "[CODEGRAPH_NOT_INITIALIZED] CodeGraph is not initialized for the active workspace.\nRemediation: Run 'codegraph init' in the workspace directory." + ); + } + + const detection = await detectCodeGraphExecutable({ + pathEnv: context.env?.PATH ?? process.env.PATH + }); + + if (!detection.available || !detection.executablePath) { + throw new Error( + "[CODEGRAPH_NOT_FOUND] CodeGraph CLI is not available on PATH.\nRemediation: Install CodeGraph and ensure 'codegraph' is on PATH." + ); + } + + const result = await runCodeGraph({ + executablePath: detection.executablePath, + args: ["explore", args.query], + cwd: workspaceDir, + signal: context.signal, + env: context.env + }); + + return { + content: [ + { + type: "text", + text: result.stdout + } + ] + }; + } + }; +} + +export function registerPiExtension(pi: any): void { + const tool = createExploreTool(); + if (typeof pi.registerTool === "function") { + pi.registerTool(tool); + } + if (typeof pi.addPromptGuidelines === "function") { + pi.addPromptGuidelines(PROMPT_GUIDELINES); + } + if (typeof pi.addPromptSnippet === "function") { + pi.addPromptSnippet(PROMPT_SNIPPET); + } +} + +export default registerPiExtension; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..856af9f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,579 @@ +{ + "name": "pi-codegraph", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-codegraph", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.48" + }, + "devDependencies": { + "@types/node": "^22.13.9", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmmirror.com/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fe6a6b4 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "pi-codegraph", + "version": "0.1.0", + "description": "Pi-native extension for CodeGraph structural code exploration", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "build": "tsc", + "test": "node --import tsx --test \"test/**/*.test.ts\"", + "lint": "tsc --noEmit", + "prepack": "npm run build" + }, + "keywords": [ + "pi", + "pi-extension", + "codegraph", + "code-exploration" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.48" + }, + "devDependencies": { + "@types/node": "^22.13.9", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + } +} diff --git a/src/codegraph/cli.ts b/src/codegraph/cli.ts new file mode 100644 index 0000000..ea8636a --- /dev/null +++ b/src/codegraph/cli.ts @@ -0,0 +1,237 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { CodeGraphError, CodeGraphErrorCode } from "./errors.js"; + +export const DEFAULT_TIMEOUT_MS = 30_000; +export const DEFAULT_MAX_STDOUT_BYTES = 50 * 1024; // 50 KB +export const DEFAULT_MAX_STDOUT_LINES = 2_000; +export const DEFAULT_MAX_STDERR_BYTES = 4 * 1024; // 4 KB + +export const TRUNCATION_MARKER = + "\n\n[Warning: CodeGraph output exceeded limit and was truncated. Refine query for specific results.]"; + +export interface RunCodeGraphOptions { + args: string[]; + cwd: string; + executablePath?: string; + signal?: AbortSignal; + timeoutMs?: number; + maxStdoutBytes?: number; + maxStdoutLines?: number; + maxStderrTailBytes?: number; +} + +export interface CodeGraphResult { + stdout: string; + stderr: string; + exitCode: number; + truncated: boolean; +} + +function terminateProcess(child: ChildProcess): void { + try { + if (child.pid && !child.killed) { + child.kill("SIGTERM"); + // Safety fallback to SIGKILL if not exited after 1 second + const killTimer = setTimeout(() => { + try { + if (!child.killed) child.kill("SIGKILL"); + } catch {} + }, 1000); + killTimer.unref?.(); + } + } catch {} +} + +/** + * Spawns the CodeGraph CLI using safe argument-array execution (no shell interpolation). + * Enforces timeout, cancellation, output size limits, and normalized error handling. + */ +export async function runCodeGraph( + options: RunCodeGraphOptions +): Promise { + const executablePath = options.executablePath ?? "codegraph"; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxStdoutBytes = options.maxStdoutBytes ?? DEFAULT_MAX_STDOUT_BYTES; + const maxStdoutLines = options.maxStdoutLines ?? DEFAULT_MAX_STDOUT_LINES; + const maxStderrBytes = options.maxStderrTailBytes ?? DEFAULT_MAX_STDERR_BYTES; + + if (options.signal?.aborted) { + throw new CodeGraphError( + CodeGraphErrorCode.ABORTED, + "CodeGraph execution was cancelled before starting.", + "The request was aborted by Pi session signal." + ); + } + + return new Promise((resolve, reject) => { + let child: ChildProcess; + const isWin = process.platform === "win32"; + const isCmdOrBat = isWin && (executablePath.endsWith(".cmd") || executablePath.endsWith(".bat")); + + try { + if (isCmdOrBat) { + const comSpec = process.env.ComSpec || "cmd.exe"; + // Quote arguments safely for cmd.exe + const formattedArgs = options.args.map((arg) => { + if (arg.includes(" ") || arg.includes('"') || arg.includes("'") || arg.includes("\n")) { + return `"${arg.replace(/"/g, '""')}"`; + } + return arg; + }); + // Wrap entire command string in outer quotes so cmd.exe /s /c stripping leaves inner quotes intact + const cmdLine = `""${executablePath}" ${formattedArgs.join(" ")}"`; + child = spawn(comSpec, ["/d", "/s", "/c", cmdLine], { + cwd: options.cwd, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + windowsVerbatimArguments: true + }); + } else { + child = spawn(executablePath, options.args, { + cwd: options.cwd, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true + }); + } + } catch (err: any) { + if (err.code === "ENOENT") { + return reject( + new CodeGraphError( + CodeGraphErrorCode.NOT_FOUND, + `CodeGraph executable '${executablePath}' not found on PATH.`, + "Install CodeGraph or ensure it is accessible on PATH." + ) + ); + } + return reject(err); + } + + let stdoutText = ""; + let stdoutBytes = 0; + let stdoutLines = 0; + let truncated = false; + + let stderrBuffer = ""; + let timedOut = false; + let aborted = false; + + const timeoutTimer = setTimeout(() => { + timedOut = true; + terminateProcess(child); + }, timeoutMs); + + const onAbort = () => { + aborted = true; + terminateProcess(child); + }; + + if (options.signal) { + options.signal.addEventListener("abort", onAbort, { once: true }); + } + + child.stdout?.on("data", (chunk: Buffer) => { + if (truncated) return; + + const chunkStr = chunk.toString("utf-8"); + const nextBytes = stdoutBytes + Buffer.byteLength(chunkStr, "utf-8"); + + // Count newlines in chunk + let linesInChunk = 0; + for (let i = 0; i < chunkStr.length; i++) { + if (chunkStr[i] === "\n") linesInChunk++; + } + const nextLines = stdoutLines + linesInChunk; + + if (nextBytes > maxStdoutBytes || nextLines > maxStdoutLines) { + truncated = true; + // Trim slice to keep within limit + const remainingBytes = Math.max(0, maxStdoutBytes - stdoutBytes); + if (remainingBytes > 0) { + stdoutText += chunkStr.slice(0, remainingBytes); + } + } else { + stdoutText += chunkStr; + stdoutBytes = nextBytes; + stdoutLines = nextLines; + } + }); + + child.stderr?.on("data", (chunk: Buffer) => { + stderrBuffer += chunk.toString("utf-8"); + // Keep only rolling tail of maxStderrBytes + if (Buffer.byteLength(stderrBuffer, "utf-8") > maxStderrBytes) { + const excess = Buffer.byteLength(stderrBuffer, "utf-8") - maxStderrBytes; + stderrBuffer = stderrBuffer.slice(excess); + } + }); + + child.on("error", (err: any) => { + clearTimeout(timeoutTimer); + if (options.signal) { + options.signal.removeEventListener("abort", onAbort); + } + if (err.code === "ENOENT") { + reject( + new CodeGraphError( + CodeGraphErrorCode.NOT_FOUND, + `CodeGraph executable '${executablePath}' not found on PATH.`, + "Install CodeGraph or ensure it is accessible on PATH." + ) + ); + } else { + reject(err); + } + }); + + child.on("close", (exitCode) => { + clearTimeout(timeoutTimer); + if (options.signal) { + options.signal.removeEventListener("abort", onAbort); + } + + if (aborted) { + return reject( + new CodeGraphError( + CodeGraphErrorCode.ABORTED, + "CodeGraph execution was cancelled by agent signal.", + "The request was cancelled before completion." + ) + ); + } + + if (timedOut) { + return reject( + new CodeGraphError( + CodeGraphErrorCode.TIMEOUT, + `CodeGraph CLI execution timed out after ${timeoutMs / 1000} seconds.`, + "Refine your query to be more specific, or verify that CodeGraph index is healthy." + ) + ); + } + + const code = exitCode ?? 0; + if (code !== 0) { + return reject( + new CodeGraphError( + CodeGraphErrorCode.COMMAND_FAILED, + `CodeGraph CLI exited with status ${code}.`, + "Verify that the active workspace contains a valid CodeGraph index and valid query syntax.", + stderrBuffer + ) + ); + } + + let finalStdout = stdoutText; + if (truncated) { + finalStdout += TRUNCATION_MARKER; + } + + resolve({ + stdout: finalStdout, + stderr: stderrBuffer, + exitCode: code, + truncated + }); + }); + }); +} diff --git a/src/codegraph/detect.ts b/src/codegraph/detect.ts new file mode 100644 index 0000000..60773ca --- /dev/null +++ b/src/codegraph/detect.ts @@ -0,0 +1,78 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface DetectOptions { + pathEnv?: string; + platform?: string; +} + +export interface CodeGraphDetectionResult { + available: boolean; + executablePath?: string; + version?: string; +} + +/** + * Searches PATH for the CodeGraph executable. + * On Windows, checks 'codegraph' then 'codegraph.cmd' / 'codegraph.exe' / 'codegraph.bat'. + * Captures version diagnostic metadata if available. + */ +export async function detectCodeGraphExecutable( + options: DetectOptions = {} +): Promise { + const platform = options.platform ?? process.platform; + const rawPath = options.pathEnv ?? process.env.PATH ?? ""; + + // Handle cross-platform path delimiters gracefully (both ; and :) + const pathDirs = rawPath + .split(platform === "win32" && rawPath.includes(";") ? ";" : path.delimiter) + .filter(Boolean); + + const candidateNames = platform === "win32" + ? ["codegraph", "codegraph.cmd", "codegraph.exe", "codegraph.bat"] + : ["codegraph"]; + + let foundPath: string | undefined; + + for (const dir of pathDirs) { + for (const name of candidateNames) { + const fullPath = path.join(dir, name); + try { + const stat = await fs.stat(fullPath); + if (stat.isFile() || stat.isSymbolicLink()) { + foundPath = fullPath; + break; + } + } catch { + // Continue searching + } + } + if (foundPath) break; + } + + if (!foundPath) { + return { available: false }; + } + + let version: string | undefined; + try { + const isCmdOrBat = platform === "win32" && (foundPath.endsWith(".cmd") || foundPath.endsWith(".bat")); + const { stdout } = await execFileAsync(foundPath, ["--version"], { + timeout: 10000, + shell: isCmdOrBat + }); + version = stdout.trim(); + } catch { + // Version probe failure does not block availability in v0.1 + } + + return { + available: true, + executablePath: foundPath, + version + }; +} diff --git a/src/codegraph/errors.ts b/src/codegraph/errors.ts new file mode 100644 index 0000000..78c416a --- /dev/null +++ b/src/codegraph/errors.ts @@ -0,0 +1,46 @@ +export enum CodeGraphErrorCode { + NOT_FOUND = "CODEGRAPH_NOT_FOUND", + NOT_INITIALIZED = "CODEGRAPH_NOT_INITIALIZED", + COMMAND_FAILED = "CODEGRAPH_COMMAND_FAILED", + TIMEOUT = "CODEGRAPH_TIMEOUT", + ABORTED = "CODEGRAPH_ABORTED", + OUTPUT_TOO_LARGE = "CODEGRAPH_OUTPUT_TOO_LARGE" +} + +export class CodeGraphError extends Error { + readonly code: CodeGraphErrorCode; + readonly remediation: string; + readonly stderrTail?: string; + + constructor( + code: CodeGraphErrorCode, + message: string, + remediation: string, + stderrTail?: string + ) { + super(message); + this.name = "CodeGraphError"; + this.code = code; + this.remediation = remediation; + this.stderrTail = stderrTail; + } +} + +/** + * Formats an error into a clean, agent-actionable string without Node stack traces. + */ +export function formatToolError(error: unknown): string { + if (error instanceof CodeGraphError) { + let output = `[${error.code}] ${error.message}\nRemediation: ${error.remediation}`; + if (error.stderrTail && error.stderrTail.trim().length > 0) { + output += `\n\nCommand stderr (last 4 KB):\n${error.stderrTail.trim()}`; + } + return output; + } + + if (error instanceof Error) { + return `[CODEGRAPH_ERROR] ${error.message}`; + } + + return `[CODEGRAPH_ERROR] ${String(error)}`; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..3814f62 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,60 @@ +import { createExploreTool, type ExploreToolOptions, type PiToolDefinition } from "./tools/explore.js"; + +export const VERSION = "0.1.0"; + +export const PROMPT_GUIDELINES = ` +# CodeGraph Exploration Guidelines + +Use \`codegraph_explore\` first when understanding: +- High-level system, module, or service architecture +- Feature implementations across multiple files +- Symbol relationships (who defines, uses, or implements a symbol) +- Call paths, execution flow, and request lifecycles +- Cross-file dependencies and change blast radius +- Relevant code by concept rather than exact text + +Prefer \`grep\` / \`find\` / \`read\` when: +- Searching for an exact literal string or pattern +- Reading a known file with known line numbers +- Inspecting documentation, configuration files, or build scripts +- Inspecting generated files or dependencies + +Avoid immediately re-reading all source files returned by CodeGraph unless specific details are missing. +`.trim(); + +export const PROMPT_SNIPPET = "Use codegraph_explore for structural code understanding and symbol relationships."; + +export interface PiExtensionHost { + registerTool?: (tool: PiToolDefinition) => boolean | void; + tools?: Map | Record; + addPromptGuidelines?: (guidelines: string) => void; + promptGuidelines?: string[]; + addPromptSnippet?: (snippet: string) => void; +} + +/** + * Registers the pi-codegraph extension in the Pi session context. + * Idempotent: If `codegraph_explore` is already registered, duplicate registrations are skipped. + */ +export function registerPiExtension( + pi: PiExtensionHost, + options: ExploreToolOptions = {} +): void { + const exploreTool = createExploreTool(options); + + if (typeof pi.registerTool === "function") { + pi.registerTool(exploreTool); + } + + if (typeof pi.addPromptGuidelines === "function") { + pi.addPromptGuidelines(PROMPT_GUIDELINES); + } else if (Array.isArray(pi.promptGuidelines)) { + pi.promptGuidelines.push(PROMPT_GUIDELINES); + } + + if (typeof pi.addPromptSnippet === "function") { + pi.addPromptSnippet(PROMPT_SNIPPET); + } +} + +export default registerPiExtension; diff --git a/src/project/detect.ts b/src/project/detect.ts new file mode 100644 index 0000000..36326d4 --- /dev/null +++ b/src/project/detect.ts @@ -0,0 +1,16 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +/** + * Checks whether the specified active workspace contains a .codegraph index directory. + * Does not inspect or validate internal database files. + */ +export async function hasCodeGraphIndex(workspaceDir: string): Promise { + try { + const targetPath = path.join(workspaceDir, ".codegraph"); + const stat = await fs.stat(targetPath); + return stat.isDirectory(); + } catch { + return false; + } +} diff --git a/src/tools/explore.ts b/src/tools/explore.ts new file mode 100644 index 0000000..258dcd6 --- /dev/null +++ b/src/tools/explore.ts @@ -0,0 +1,109 @@ +import { Type, type Static } from "@sinclair/typebox"; +import { hasCodeGraphIndex } from "../project/detect.js"; +import { detectCodeGraphExecutable, type DetectOptions } from "../codegraph/detect.js"; +import { runCodeGraph } from "../codegraph/cli.js"; +import { CodeGraphError, CodeGraphErrorCode, formatToolError } from "../codegraph/errors.js"; + +export const ExploreParamsSchema = Type.Object({ + query: Type.String({ + description: + "Natural-language question about code architecture, symbols, relationships, implementation, or execution flow." + }) +}); + +export type ExploreParams = Static; + +export interface ExploreToolOptions extends DetectOptions { + executablePath?: string; +} + +export interface ToolExecutionContext { + workspacePath?: string; + cwd?: string; + signal?: AbortSignal; +} + +export interface ToolExecutionResult { + content: Array<{ + type: "text"; + text: string; + }>; +} + +export interface PiToolDefinition { + name: string; + description: string; + parameters: typeof ExploreParamsSchema; + execute: ( + args: ExploreParams, + context: ToolExecutionContext + ) => Promise; +} + +/** + * Creates the `codegraph_explore` tool definition for the Pi extension. + * Derives the working directory strictly from the active Pi workspace context. + */ +export function createExploreTool( + options: ExploreToolOptions = {} +): PiToolDefinition { + return { + name: "codegraph_explore", + description: + "Explore code structure, symbols, relationships, implementations, and call paths using the current project's CodeGraph index.", + parameters: ExploreParamsSchema, + async execute( + args: ExploreParams, + context: ToolExecutionContext + ): Promise { + const workspaceDir = context.workspacePath ?? context.cwd ?? process.cwd(); + + try { + const hasIndex = await hasCodeGraphIndex(workspaceDir); + if (!hasIndex) { + throw new CodeGraphError( + CodeGraphErrorCode.NOT_INITIALIZED, + "CodeGraph is not initialized for the active workspace.", + "Run 'codegraph init' in the workspace directory to create a CodeGraph index." + ); + } + + let executable = options.executablePath; + if (!executable) { + const detection = await detectCodeGraphExecutable({ + pathEnv: options.pathEnv, + platform: options.platform + }); + + if (!detection.available || !detection.executablePath) { + throw new CodeGraphError( + CodeGraphErrorCode.NOT_FOUND, + "CodeGraph CLI is not available on PATH.", + "Install CodeGraph (e.g. npm i -g @codegraph/cli) and ensure 'codegraph' is on your PATH." + ); + } + executable = detection.executablePath; + } + + const result = await runCodeGraph({ + executablePath: executable, + args: ["explore", args.query], + cwd: workspaceDir, + signal: context.signal + }); + + return { + content: [ + { + type: "text", + text: result.stdout + } + ] + }; + } catch (err: unknown) { + const formatted = formatToolError(err); + throw new Error(formatted); + } + } + }; +} diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..e334665 --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,173 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { runCodeGraph, TRUNCATION_MARKER } from "../src/codegraph/cli.js"; +import { CodeGraphError, CodeGraphErrorCode } from "../src/codegraph/errors.js"; +import { createFakeCodeGraphCli } from "./helpers/fake-cli.js"; +import * as os from "node:os"; + +describe("runCodeGraph subprocess runner", () => { + it("executes arguments safely as an array without shell interpolation", async () => { + const fake = await createFakeCodeGraphCli({ + stdout: "Architecture analysis results" + }); + try { + const maliciousQuery = `'; touch /tmp/cg-should-not-exist; echo "pwned" && echo \`date\``; + const result = await runCodeGraph({ + executablePath: fake.executablePath, + args: ["explore", maliciousQuery], + cwd: os.tmpdir() + }); + + assert.equal(result.stdout.trim(), "Architecture analysis results"); + assert.equal(result.truncated, false); + + const calls = await fake.getInvocations(); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0].args, ["explore", maliciousQuery]); + } finally { + await fake.cleanup(); + } + }); + + it("handles unicode, quotes, and whitespace in query arguments", async () => { + const fake = await createFakeCodeGraphCli({ + stdout: "Unicode query output" + }); + try { + const complexQuery = 'How does "User认证Service" handle retry \\ & symbols? 🚀'; + await runCodeGraph({ + executablePath: fake.executablePath, + args: ["explore", complexQuery], + cwd: os.tmpdir() + }); + + const calls = await fake.getInvocations(); + assert.equal(calls.length, 1); + assert.equal(calls[0].args[1], complexQuery); + } finally { + await fake.cleanup(); + } + }); + + it("enforces 30-second timeout and raises CODEGRAPH_TIMEOUT", async () => { + const fake = await createFakeCodeGraphCli({ + delayMs: 2000 + }); + try { + await assert.rejects( + async () => { + await runCodeGraph({ + executablePath: fake.executablePath, + args: ["explore", "slow query"], + cwd: os.tmpdir(), + timeoutMs: 100 // fast timeout for unit testing + }); + }, + (err: any) => { + assert.ok(err instanceof CodeGraphError); + assert.equal(err.code, CodeGraphErrorCode.TIMEOUT); + assert.match(err.message, /timed out/i); + return true; + } + ); + } finally { + await fake.cleanup(); + } + }); + + it("respects AbortSignal cancellation and terminates child process", async () => { + const fake = await createFakeCodeGraphCli({ + delayMs: 2000 + }); + try { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 100); + + await assert.rejects( + async () => { + await runCodeGraph({ + executablePath: fake.executablePath, + args: ["explore", "cancelled query"], + cwd: os.tmpdir(), + signal: controller.signal, + timeoutMs: 10000 + }); + }, + (err: any) => { + assert.ok(err instanceof CodeGraphError); + assert.equal(err.code, CodeGraphErrorCode.ABORTED); + return true; + } + ); + } finally { + await fake.cleanup(); + } + }); + + it("bounds stdout to 50 KB and marks output as truncated", async () => { + const fake = await createFakeCodeGraphCli({ + generateSize: 60 * 1024 // 60 KB + }); + try { + const result = await runCodeGraph({ + executablePath: fake.executablePath, + args: ["explore", "large output"], + cwd: os.tmpdir(), + maxStdoutBytes: 50 * 1024 + }); + + assert.equal(result.truncated, true); + assert.ok(result.stdout.includes(TRUNCATION_MARKER)); + assert.ok(result.stdout.length <= 55 * 1024); + } finally { + await fake.cleanup(); + } + }); + + it("bounds stdout to 2,000 lines and marks output as truncated", async () => { + const fake = await createFakeCodeGraphCli({ + generateLines: 2500 + }); + try { + const result = await runCodeGraph({ + executablePath: fake.executablePath, + args: ["explore", "many lines"], + cwd: os.tmpdir(), + maxStdoutLines: 2000 + }); + + assert.equal(result.truncated, true); + assert.ok(result.stdout.includes(TRUNCATION_MARKER)); + } finally { + await fake.cleanup(); + } + }); + + it("normalizes non-zero exit code to CODEGRAPH_COMMAND_FAILED and caps stderr at 4 KB", async () => { + const longStderr = "E".repeat(10 * 1024); // 10 KB + const fake = await createFakeCodeGraphCli({ + stderr: longStderr, + exitCode: 1 + }); + try { + await assert.rejects( + async () => { + await runCodeGraph({ + executablePath: fake.executablePath, + args: ["explore", "failing query"], + cwd: os.tmpdir() + }); + }, + (err: any) => { + assert.ok(err instanceof CodeGraphError); + assert.equal(err.code, CodeGraphErrorCode.COMMAND_FAILED); + assert.ok(err.stderrTail); + assert.ok(err.stderrTail.length <= 4096); + return true; + } + ); + } finally { + await fake.cleanup(); + } + }); +}); diff --git a/test/detect.test.ts b/test/detect.test.ts new file mode 100644 index 0000000..71f5591 --- /dev/null +++ b/test/detect.test.ts @@ -0,0 +1,104 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as os from "node:os"; +import { hasCodeGraphIndex } from "../src/project/detect.js"; +import { detectCodeGraphExecutable } from "../src/codegraph/detect.js"; +import { CodeGraphError, CodeGraphErrorCode, formatToolError } from "../src/codegraph/errors.js"; +import { createFakeCodeGraphCli } from "./helpers/fake-cli.js"; + +describe("detectCodeGraphIndex", () => { + it("returns true when .codegraph directory exists in workspace", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cg-test-ws-")); + try { + await fs.mkdir(path.join(tmpDir, ".codegraph")); + const exists = await hasCodeGraphIndex(tmpDir); + assert.equal(exists, true); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("returns false when .codegraph does not exist", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cg-test-ws-")); + try { + const exists = await hasCodeGraphIndex(tmpDir); + assert.equal(exists, false); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe("detectCodeGraphExecutable", () => { + it("detects executable on PATH and reads version diagnostic", async () => { + const fake = await createFakeCodeGraphCli({ stdout: "codegraph 0.1.0-fake" }); + try { + const result = await detectCodeGraphExecutable({ + pathEnv: fake.env.PATH + }); + assert.equal(result.available, true); + assert.equal(typeof result.executablePath, "string"); + assert.match(result.version ?? "", /0\.1\.0-fake/); + } finally { + await fake.cleanup(); + } + }); + + it("returns available=false when codegraph is not on PATH", async () => { + const result = await detectCodeGraphExecutable({ + pathEnv: "/nonexistent-path-12345" + }); + assert.equal(result.available, false); + assert.equal(result.executablePath, undefined); + }); + + it("falls back to codegraph.cmd on Windows platform if only codegraph.cmd exists", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cg-win-test-")); + try { + const cmdPath = path.join(tmpDir, "codegraph.cmd"); + await fs.writeFile(cmdPath, "@echo off\necho 0.1.0-win", { mode: 0o755 }); + + const result = await detectCodeGraphExecutable({ + pathEnv: `${tmpDir};C:\\Windows\\System32`, + platform: "win32" + }); + assert.equal(result.available, true); + assert.equal(result.executablePath, cmdPath); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe("CodeGraphError and normalization", () => { + it("creates agent-friendly error with code and remediation", () => { + const err = new CodeGraphError( + CodeGraphErrorCode.NOT_FOUND, + "CodeGraph CLI is not available on PATH.", + "Install CodeGraph (e.g. npm i -g @codegraph/cli) and ensure 'codegraph' is on your PATH." + ); + + assert.equal(err.code, "CODEGRAPH_NOT_FOUND"); + assert.equal(err.name, "CodeGraphError"); + + const formatted = formatToolError(err); + assert.match(formatted, /CODEGRAPH_NOT_FOUND/); + assert.match(formatted, /Install CodeGraph/); + assert.ok(!formatted.includes("node:internal"), "should not leak Node stack traces"); + }); + + it("formats NOT_INITIALIZED error with init remediation", () => { + const err = new CodeGraphError( + CodeGraphErrorCode.NOT_INITIALIZED, + "CodeGraph is not initialized for the active workspace.", + "Run 'codegraph init' in the workspace directory to create a CodeGraph index." + ); + + const formatted = formatToolError(err); + assert.match(formatted, /CODEGRAPH_NOT_INITIALIZED/); + assert.match(formatted, /codegraph init/); + assert.ok(!formatted.includes("node:internal")); + }); +}); diff --git a/test/example.test.ts b/test/example.test.ts new file mode 100644 index 0000000..0d384fc --- /dev/null +++ b/test/example.test.ts @@ -0,0 +1,47 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as os from "node:os"; +import { createFakeCodeGraphCli } from "./helpers/fake-cli.js"; + +describe("copyable single-file extension example", () => { + it("can be imported and registers codegraph_explore tool", async () => { + // Import the standalone example + const exampleModule = await import("../examples/pi-codegraph.js"); + assert.equal(typeof exampleModule.registerPiExtension, "function"); + + const registeredTools: any[] = []; + const mockPi = { + registerTool(tool: any) { + registeredTools.push(tool); + }, + addPromptGuidelines() {} + }; + + exampleModule.registerPiExtension(mockPi); + assert.equal(registeredTools.length, 1); + assert.equal(registeredTools[0].name, "codegraph_explore"); + + // Test execution through the standalone tool + const wsDir = await fs.mkdtemp(path.join(os.tmpdir(), "cg-example-ws-")); + const realWs = await fs.realpath(wsDir); + await fs.mkdir(path.join(realWs, ".codegraph")); + + const fake = await createFakeCodeGraphCli({ + stdout: "Standalone example exploration result" + }); + + try { + const tool = registeredTools[0]; + const result = await tool.execute( + { query: "How does auth flow work?" }, + { workspacePath: realWs, env: fake.env } + ); + assert.match(result.content[0].text, /Standalone example exploration result/); + } finally { + await fake.cleanup(); + await fs.rm(wsDir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/explore.test.ts b/test/explore.test.ts new file mode 100644 index 0000000..9ff3f57 --- /dev/null +++ b/test/explore.test.ts @@ -0,0 +1,164 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as os from "node:os"; +import { createExploreTool } from "../src/tools/explore.js"; +import { registerPiExtension, PROMPT_GUIDELINES } from "../src/index.js"; +import { createFakeCodeGraphCli } from "./helpers/fake-cli.js"; + +interface MockPiTool { + name: string; + description: string; + parameters: any; + execute: (args: any, context: any) => Promise; +} + +class MockPiContext { + tools = new Map(); + guidelines: string[] = []; + + registerTool(tool: MockPiTool): boolean { + if (this.tools.has(tool.name)) { + // Duplicate registration should be skipped/ignored + return false; + } + this.tools.set(tool.name, tool); + return true; + } + + addPromptGuidelines(text: string): void { + this.guidelines.push(text); + } +} + +describe("codegraph_explore tool", () => { + it("executes exploration against active workspace and returns stdout", async () => { + const rawWsDir = await fs.mkdtemp(path.join(os.tmpdir(), "cg-ws-")); + const wsDir = await fs.realpath(rawWsDir); + await fs.mkdir(path.join(wsDir, ".codegraph")); + + const fake = await createFakeCodeGraphCli({ + stdout: "### Architecture Overview\nFound 3 symbols in auth module." + }); + + try { + const tool = createExploreTool({ + executablePath: fake.executablePath + }); + + const context = { + workspacePath: wsDir + }; + + const result = await tool.execute({ query: "How does auth work?" }, context); + assert.match(result.content[0].text, /Found 3 symbols in auth module/); + + const invocations = await fake.getInvocations(); + assert.equal(invocations.length, 1); + assert.deepEqual(invocations[0].args, ["explore", "How does auth work?"]); + assert.equal(invocations[0].cwd, wsDir); + } finally { + await fake.cleanup(); + await fs.rm(rawWsDir, { recursive: true, force: true }); + } + }); + + it("fails with CODEGRAPH_NOT_INITIALIZED when .codegraph is absent", async () => { + const wsDir = await fs.mkdtemp(path.join(os.tmpdir(), "cg-ws-uninit-")); + const fake = await createFakeCodeGraphCli({ stdout: "ok" }); + + try { + const tool = createExploreTool({ + executablePath: fake.executablePath + }); + + await assert.rejects( + async () => { + await tool.execute({ query: "explore" }, { workspacePath: wsDir }); + }, + (err: any) => { + assert.match(err.message, /CODEGRAPH_NOT_INITIALIZED/); + assert.match(err.message, /codegraph init/); + return true; + } + ); + } finally { + await fake.cleanup(); + await fs.rm(wsDir, { recursive: true, force: true }); + } + }); + + it("fails with CODEGRAPH_NOT_FOUND when executable is missing on PATH", async () => { + const wsDir = await fs.mkdtemp(path.join(os.tmpdir(), "cg-ws-")); + await fs.mkdir(path.join(wsDir, ".codegraph")); + + try { + const tool = createExploreTool({ + pathEnv: "/nonexistent-path-abc" + }); + + await assert.rejects( + async () => { + await tool.execute({ query: "explore" }, { workspacePath: wsDir }); + }, + (err: any) => { + assert.match(err.message, /CODEGRAPH_NOT_FOUND/); + return true; + } + ); + } finally { + await fs.rm(wsDir, { recursive: true, force: true }); + } + }); + + it("fails with CODEGRAPH_COMMAND_FAILED on non-zero exit code", async () => { + const wsDir = await fs.mkdtemp(path.join(os.tmpdir(), "cg-ws-")); + await fs.mkdir(path.join(wsDir, ".codegraph")); + + const fake = await createFakeCodeGraphCli({ + stderr: "Syntax error in graph database", + exitCode: 1 + }); + + try { + const tool = createExploreTool({ + executablePath: fake.executablePath + }); + + await assert.rejects( + async () => { + await tool.execute({ query: "explore" }, { workspacePath: wsDir }); + }, + (err: any) => { + assert.match(err.message, /CODEGRAPH_COMMAND_FAILED/); + assert.match(err.message, /Syntax error in graph database/); + return true; + } + ); + } finally { + await fake.cleanup(); + await fs.rm(wsDir, { recursive: true, force: true }); + } + }); +}); + +describe("Pi extension registration", () => { + it("registers codegraph_explore and prompt guidelines idempotently", () => { + const pi = new MockPiContext(); + + registerPiExtension(pi as any); + assert.equal(pi.tools.has("codegraph_explore"), true); + assert.equal(pi.guidelines.length, 1); + assert.match(pi.guidelines[0], /CodeGraph/); + + // Registering a second time should not throw or duplicate + registerPiExtension(pi as any); + assert.equal(pi.tools.size, 1); + }); + + it("contains prompt guidance favoring CodeGraph for structural queries", () => { + assert.match(PROMPT_GUIDELINES, /architecture/i); + assert.match(PROMPT_GUIDELINES, /grep/i); + }); +}); diff --git a/test/fixture.test.ts b/test/fixture.test.ts new file mode 100644 index 0000000..d5e98f1 --- /dev/null +++ b/test/fixture.test.ts @@ -0,0 +1,41 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; +import { createExploreTool } from "../src/tools/explore.js"; +import { createFakeCodeGraphCli } from "./helpers/fake-cli.js"; + +describe("fixed fixture integration test", () => { + it("executes exploration in sample-project fixture workspace", async () => { + const rawFixtureDir = path.resolve("test/fixtures/sample-project"); + const fixtureDir = await fs.realpath(rawFixtureDir); + + const fake = await createFakeCodeGraphCli({ + stdout: "Symbol: authenticate\nDefined in: src/auth.ts:6\nReturns: User | null" + }); + + try { + const tool = createExploreTool({ + executablePath: fake.executablePath + }); + + const result = await tool.execute( + { query: "Where is authenticate defined and what does it return?" }, + { workspacePath: fixtureDir } + ); + + assert.match(result.content[0].text, /Symbol: authenticate/); + assert.match(result.content[0].text, /src\/auth\.ts/); + + const invocations = await fake.getInvocations(); + assert.equal(invocations.length, 1); + assert.equal(invocations[0].cwd, fixtureDir); + assert.deepEqual(invocations[0].args, [ + "explore", + "Where is authenticate defined and what does it return?" + ]); + } finally { + await fake.cleanup(); + } + }); +}); diff --git a/test/fixtures/sample-project/.codegraph/.gitkeep b/test/fixtures/sample-project/.codegraph/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/sample-project/src/auth.ts b/test/fixtures/sample-project/src/auth.ts new file mode 100644 index 0000000..58faa0a --- /dev/null +++ b/test/fixtures/sample-project/src/auth.ts @@ -0,0 +1,11 @@ +export interface User { + id: string; + name: string; +} + +export function authenticate(token: string): User | null { + if (token === "secret") { + return { id: "1", name: "Alice" }; + } + return null; +} diff --git a/test/harness.test.ts b/test/harness.test.ts new file mode 100644 index 0000000..ec2b86e --- /dev/null +++ b/test/harness.test.ts @@ -0,0 +1,57 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { createFakeCodeGraphCli } from "./helpers/fake-cli.js"; +import { runCodeGraph } from "../src/codegraph/cli.js"; +import { CodeGraphError, CodeGraphErrorCode } from "../src/codegraph/errors.js"; +import * as os from "node:os"; + +describe("fake codegraph CLI test harness", () => { + it("creates an executable fake CLI and records invocations", async () => { + const fake = await createFakeCodeGraphCli({ + stdout: "fake exploration output", + exitCode: 0 + }); + + try { + const result = await runCodeGraph({ + executablePath: fake.executablePath, + args: ["explore", "query with 'quotes' and spaces"], + cwd: os.tmpdir() + }); + + assert.equal(result.stdout.trim(), "fake exploration output"); + const calls = await fake.getInvocations(); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0].args, ["explore", "query with 'quotes' and spaces"]); + } finally { + await fake.cleanup(); + } + }); + + it("handles stderr and non-zero exit codes", async () => { + const fake = await createFakeCodeGraphCli({ + stderr: "error from codegraph", + exitCode: 1 + }); + + try { + await assert.rejects( + async () => { + await runCodeGraph({ + executablePath: fake.executablePath, + args: ["explore", "fail"], + cwd: os.tmpdir() + }); + }, + (err: any) => { + assert.ok(err instanceof CodeGraphError); + assert.equal(err.code, CodeGraphErrorCode.COMMAND_FAILED); + assert.match(err.stderrTail ?? "", /error from codegraph/); + return true; + } + ); + } finally { + await fake.cleanup(); + } + }); +}); diff --git a/test/helpers/fake-cli.ts b/test/helpers/fake-cli.ts new file mode 100644 index 0000000..971ed98 --- /dev/null +++ b/test/helpers/fake-cli.ts @@ -0,0 +1,142 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as os from "node:os"; + +export interface FakeCliOptions { + stdout?: string; + stderr?: string; + exitCode?: number; + delayMs?: number; + generateSize?: number; // generate output of exact size in bytes + generateLines?: number; // generate exact number of lines + executableName?: string; + createCmdWrapper?: boolean; +} + +export interface FakeCliInvocation { + args: string[]; + cwd: string; + timestamp: number; +} + +export interface FakeCliInstance { + binDir: string; + executablePath: string; + env: Record; + getInvocations: () => Promise; + cleanup: () => Promise; +} + +export async function createFakeCodeGraphCli(options: FakeCliOptions = {}): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "fake-codegraph-")); + const logFile = path.join(tmpDir, "invocations.jsonl"); + const isWin = process.platform === "win32"; + const binName = options.executableName ?? "codegraph"; + const scriptPath = path.join(tmpDir, isWin ? `${binName}.js` : binName); + + const scriptContent = `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); + +const logFile = ${JSON.stringify(logFile)}; +const args = process.argv.slice(2); +const cwd = process.cwd(); + +try { + fs.appendFileSync(logFile, JSON.stringify({ args, cwd, timestamp: Date.now() }) + "\\n"); +} catch (e) {} + +const delayMs = ${options.delayMs ?? 0}; +const exitCode = ${options.exitCode ?? 0}; +const stdout = ${JSON.stringify(options.stdout ?? "")}; +const stderr = ${JSON.stringify(options.stderr ?? "")}; +const generateSize = ${options.generateSize ?? 0}; +const generateLines = ${options.generateLines ?? 0}; + +async function main() { + if (delayMs > 0) { + await new Promise(r => setTimeout(r, delayMs)); + } + + if (args.includes("--version")) { + process.stdout.write("codegraph 0.1.0-fake\\n", () => { + process.exit(0); + }); + return; + } + + if (generateSize > 0) { + const chunk = "A".repeat(1024); + let written = 0; + while (written < generateSize) { + const toWrite = Math.min(1024, generateSize - written); + const canWrite = process.stdout.write("A".repeat(toWrite)); + written += toWrite; + if (!canWrite) { + await new Promise(r => process.stdout.once("drain", r)); + } + } + } else if (generateLines > 0) { + for (let i = 1; i <= generateLines; i++) { + const canWrite = process.stdout.write(\`line \${i}\\n\`); + if (!canWrite) { + await new Promise(r => process.stdout.once("drain", r)); + } + } + } else if (stdout) { + process.stdout.write(stdout); + if (!stdout.endsWith("\\n")) process.stdout.write("\\n"); + } + + if (stderr) { + process.stderr.write(stderr); + if (!stderr.endsWith("\\n")) process.stderr.write("\\n"); + } + + await new Promise(r => process.stdout.write("", r)); + process.exit(exitCode); +} + +main().catch(err => { + process.stderr.write(String(err)); + process.exit(1); +}); +`; + + await fs.writeFile(scriptPath, scriptContent, { mode: 0o755 }); + + let executablePath = scriptPath; + if (isWin || options.createCmdWrapper) { + const cmdPath = path.join(tmpDir, `${binName}.cmd`); + const cmdContent = `@echo off\r\nnode "${scriptPath}" %*`; + await fs.writeFile(cmdPath, cmdContent, { mode: 0o755 }); + if (isWin) { + executablePath = cmdPath; + } + } + + const env = { + PATH: `${tmpDir}${path.delimiter}${process.env.PATH ?? ""}` + }; + + return { + binDir: tmpDir, + executablePath, + env, + async getInvocations(): Promise { + try { + const content = await fs.readFile(logFile, "utf-8"); + return content + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + } catch { + return []; + } + }, + async cleanup(): Promise { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0005e75 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "examples"] +}