diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eebcc4d..3afb70b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,14 +5,19 @@ on: push: branches: [main] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: 22 cache: npm - - run: npm ci + - run: npm ci --ignore-scripts - run: npm run ci diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6f12894..d202b64 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,31 +7,53 @@ on: concurrency: release permissions: - contents: write - pull-requests: write - id-token: write + contents: read jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 22 + cache: npm + + - run: node --version && npm --version + - run: npm ci --ignore-scripts + - run: npm run ci + release: runs-on: ubuntu-latest + timeout-minutes: 20 + needs: verify + permissions: + contents: write + pull-requests: write + id-token: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 + persist-credentials: false - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: 24 registry-url: https://registry.npmjs.org cache: npm - - run: npm install -g npm@latest - run: node --version && npm --version - - run: npm ci - - run: npm run ci + - run: npm ci --ignore-scripts - name: Create version PR or publish - uses: changesets/action@v1 + uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b with: version: npm run version-packages publish: npm run publish-packages diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d253251 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,8 @@ +repos: + - repo: local + hooks: + - id: npm-ci + name: npm run ci + entry: npm run ci + language: system + pass_filenames: false diff --git a/extensions/codegraph.ts b/extensions/codegraph.ts index 906622b..c293c85 100644 --- a/extensions/codegraph.ts +++ b/extensions/codegraph.ts @@ -135,6 +135,10 @@ const ToolDefinitions = [ type ToolName = (typeof ToolDefinitions)[number]["name"]; type ToolParams = Record & { projectPath?: string }; type JsonRpcRequest = (method: string, params: Record) => Promise; +type PendingJsonRpcRequests = Map void; + reject: (error: Error) => void; +}>; const MaxDiagnosticLength = 1000; @@ -194,67 +198,106 @@ async function runJsonRpcSession( signal: AbortSignal | undefined, fn: (request: JsonRpcRequest) => Promise, ): Promise { - let nextId = 1; - let stdout = ""; - let stderr = ""; - const pending = new Map void; - reject: (error: Error) => void; - }>(); - - const cleanup = () => { - for (const entry of pending.values()) { - entry.reject(new Error("CodeGraph MCP process closed before responding.")); - } - pending.clear(); - if (!child.killed) child.kill(); - }; - + const pending: PendingJsonRpcRequests = new Map(); + const stderr = { value: "" }; + const cleanup = () => cleanupJsonRpcChild(child, pending); const onAbort = () => cleanup(); + signal?.addEventListener("abort", onAbort, { once: true }); + attachJsonRpcHandlers(child, pending, stderr); + + try { + const sendRequest = createJsonRpcRequestSender(child, pending); + await initializeJsonRpcSession(cwd, sendRequest, sendJsonRpcNotification.bind(undefined, child)); + return await fn(sendRequest); + } finally { + signal?.removeEventListener("abort", onAbort); + cleanup(); + } +} + +function cleanupJsonRpcChild( + child: ChildProcessWithoutNullStreams, + pending: PendingJsonRpcRequests, +): void { + rejectPendingJsonRpcRequests( + pending, + new Error("CodeGraph MCP process closed before responding."), + ); + if (!child.killed) child.kill(); +} + +function rejectPendingJsonRpcRequests( + pending: PendingJsonRpcRequests, + error: Error, +): void { + for (const entry of pending.values()) entry.reject(error); + pending.clear(); +} + +function attachJsonRpcHandlers( + child: ChildProcessWithoutNullStreams, + pending: PendingJsonRpcRequests, + stderr: { value: string }, +): void { + const stdout = { value: "" }; child.stdout.on("data", (chunk) => { - stdout += chunk.toString("utf-8"); - let newline; - while ((newline = stdout.indexOf("\n")) !== -1) { - const line = stdout.slice(0, newline).trim(); - stdout = stdout.slice(newline + 1); - if (!line) continue; - - let msg: any; - try { - msg = JSON.parse(line); - } catch { - continue; - } - - if (msg.id !== undefined && pending.has(msg.id)) { - const { resolve, reject } = pending.get(msg.id)!; - pending.delete(msg.id); - if (msg.error) reject(new Error(msg.error.message || JSON.stringify(msg.error))); - else resolve(msg.result); - } - } + handleJsonRpcStdout(chunk, stdout, pending); }); - child.stderr.on("data", (chunk) => { - stderr += chunk.toString("utf-8"); + stderr.value += chunk.toString("utf-8"); }); + child.on("error", (err) => rejectPendingJsonRpcRequests(pending, err)); + child.on("exit", (code) => rejectPendingJsonRpcOnExit(pending, stderr.value, code)); +} - child.on("error", (err) => { - for (const entry of pending.values()) entry.reject(err); - pending.clear(); - }); +function handleJsonRpcStdout( + chunk: Buffer, + stdout: { value: string }, + pending: PendingJsonRpcRequests, +): void { + stdout.value += chunk.toString("utf-8"); + let newline; + while ((newline = stdout.value.indexOf("\n")) !== -1) { + const line = stdout.value.slice(0, newline).trim(); + stdout.value = stdout.value.slice(newline + 1); + if (line) resolveJsonRpcLine(line, pending); + } +} - child.on("exit", (code) => { - if (pending.size === 0) return; - const diagnostic = sanitizeDiagnostic(stderr.trim()); - const msg = diagnostic || `CodeGraph MCP process exited with code ${code}`; - for (const entry of pending.values()) entry.reject(new Error(msg)); - pending.clear(); - }); +function resolveJsonRpcLine(line: string, pending: PendingJsonRpcRequests): void { + let msg: any; + try { + msg = JSON.parse(line); + } catch { + return; + } + + if (msg.id === undefined || !pending.has(msg.id)) return; + const { resolve, reject } = pending.get(msg.id)!; + pending.delete(msg.id); + if (msg.error) reject(new Error(msg.error.message || JSON.stringify(msg.error))); + else resolve(msg.result); +} + +function rejectPendingJsonRpcOnExit( + pending: PendingJsonRpcRequests, + stderr: string, + code: number | null, +): void { + if (pending.size === 0) return; + const diagnostic = sanitizeDiagnostic(stderr.trim()); + const msg = diagnostic || `CodeGraph MCP process exited with code ${code}`; + rejectPendingJsonRpcRequests(pending, new Error(msg)); +} - const sendRequest: JsonRpcRequest = (method, params) => { +function createJsonRpcRequestSender( + child: ChildProcessWithoutNullStreams, + pending: PendingJsonRpcRequests, +): JsonRpcRequest { + let nextId = 1; + return (method, params) => { const id = nextId++; const payload = { jsonrpc: "2.0", id, method, params }; const promise = new Promise((resolve, reject) => { @@ -263,26 +306,30 @@ async function runJsonRpcSession( child.stdin.write(`${JSON.stringify(payload)}\n`); return promise; }; +} - const sendNotification = (method: string, params: Record) => { - child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`); - }; +function sendJsonRpcNotification( + child: ChildProcessWithoutNullStreams, + method: string, + params: Record, +): void { + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`); +} - try { - const rootUri = pathToFileURL(cwd).href; - await sendRequest("initialize", { - protocolVersion: "2024-11-05", - rootUri, - workspaceFolders: [{ uri: rootUri, name: cwd.split(/[\\/]/).pop() || cwd }], - capabilities: {}, - clientInfo: { name: "pi-codegraph", version: "0.1.0" }, - }); - sendNotification("initialized", {}); - return await fn(sendRequest); - } finally { - signal?.removeEventListener("abort", onAbort); - cleanup(); - } +async function initializeJsonRpcSession( + cwd: string, + sendRequest: JsonRpcRequest, + sendNotification: (method: string, params: Record) => void, +): Promise { + const rootUri = pathToFileURL(cwd).href; + await sendRequest("initialize", { + protocolVersion: "2024-11-05", + rootUri, + workspaceFolders: [{ uri: rootUri, name: cwd.split(/[\\/]/).pop() || cwd }], + capabilities: {}, + clientInfo: { name: "pi-codegraph", version: "0.1.0" }, + }); + sendNotification("initialized", {}); } export async function callCodeGraphTool( diff --git a/package.json b/package.json index 41f37dc..e7a4425 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "ci": "npm run typecheck && npm test && npm run compat:codegraph && npm pack --dry-run", "compat:codegraph": "codegraph --version", "local-release": "changeset version && changeset publish", - "publish-packages": "node -e \"const {execSync}=require('node:child_process'); const p=require('./package.json'); const spec=p.name+'@'+p.version; try { execSync('npm view '+spec+' version', {stdio:'ignore'}); console.log(spec+' already published; skipping.'); } catch { execSync('npm publish --access public --provenance', {stdio:'inherit'}); }\"", + "publish-packages": "node -e \"const {execSync}=require('node:child_process'); const p=require('./package.json'); const spec=p.name+'@'+p.version; try { execSync('npm view '+spec+' version', {stdio:'ignore'}); console.log(spec+' already published; skipping.'); } catch { execSync('npm publish --access public --provenance --ignore-scripts', {stdio:'inherit'}); }\"", "prepack": "npm run typecheck && npm test", "prepublishOnly": "npm run ci", "typecheck": "tsc --noEmit", diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..edb2a9f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[tool.skylos] +complexity = 10 +nesting = 3 +max_args = 5 +max_lines = 50 +ignore = [] +exclude = ["package-lock.json"] + +[tool.skylos.languages.typescript] +complexity = 15 +nesting = 4 + +[tool.skylos.gate] +fail_on_critical = true +max_security = 0 +max_quality = 0 +strict = true