opencode-codegraph 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codegraph",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "OpenCode plugin for CodeGraph CPG-powered code analysis",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/api.ts CHANGED
@@ -6,24 +6,31 @@
6
6
  */
7
7
 
8
8
  export class CodeGraphAPI {
9
- private baseUrl: string
10
-
11
- constructor(baseUrl: string) {
12
- this.baseUrl = baseUrl.replace(/\/+$/, "")
9
+ private baseUrl: string
10
+
11
+ constructor(baseUrl: string) {
12
+ const trimmed = baseUrl.replace(/\/+$/, "")
13
+ this.baseUrl = trimmed.replace(/\/api\/v1$/i, "")
13
14
  }
14
15
 
15
16
  /**
16
17
  * Get project summary for system prompt injection.
17
18
  * Returns a markdown block with file count, top hotspots, and open security findings.
18
19
  */
19
- async getProjectSummary(projectId: string): Promise<string | null> {
20
- const params = projectId ? `?project_id=${encodeURIComponent(projectId)}` : ""
21
-
22
- // Fetch multiple endpoints in parallel
23
- const [statsRes, findingsRes] = await Promise.allSettled([
24
- this.fetch(`/api/v1/cpg/stats${params}`),
25
- this.fetch(`/api/v1/cpg/pattern-results${params}&category=security&limit=10`),
26
- ])
20
+ async getProjectSummary(projectId: string): Promise<string | null> {
21
+ const params = new URLSearchParams()
22
+ if (projectId) params.set("project_id", projectId)
23
+ const statsPath = params.toString() ? `/api/v1/cpg/stats?${params}` : "/api/v1/cpg/stats"
24
+ const findingsParams = new URLSearchParams(params)
25
+ findingsParams.set("category", "security")
26
+ findingsParams.set("limit", "10")
27
+ const findingsPath = `/api/v1/cpg/pattern-results?${findingsParams}`
28
+
29
+ // Fetch multiple endpoints in parallel
30
+ const [statsRes, findingsRes] = await Promise.allSettled([
31
+ this.fetch(statsPath),
32
+ this.fetch(findingsPath),
33
+ ])
27
34
 
28
35
  const stats =
29
36
  statsRes.status === "fulfilled" ? statsRes.value : null
@@ -66,14 +73,16 @@ export class CodeGraphAPI {
66
73
  * Returns markdown with methods, callers, complexity, and security findings.
67
74
  */
68
75
  async getFileCPGContext(projectId: string, filePath: string): Promise<string | null> {
69
- const params = new URLSearchParams()
70
- if (projectId) params.set("project_id", projectId)
71
- params.set("filename", filePath)
72
-
73
- const [methodsRes, findingsRes] = await Promise.allSettled([
74
- this.fetch(`/api/v1/cpg/methods?${params}`),
75
- this.fetch(`/api/v1/cpg/pattern-results?${params}&category=security`),
76
- ])
76
+ const params = new URLSearchParams()
77
+ if (projectId) params.set("project_id", projectId)
78
+ params.set("filename", filePath)
79
+ const findingsParams = new URLSearchParams(params)
80
+ findingsParams.set("category", "security")
81
+
82
+ const [methodsRes, findingsRes] = await Promise.allSettled([
83
+ this.fetch(`/api/v1/cpg/methods?${params}`),
84
+ this.fetch(`/api/v1/cpg/pattern-results?${findingsParams.toString()}`),
85
+ ])
77
86
 
78
87
  const methods =
79
88
  methodsRes.status === "fulfilled" ? methodsRes.value : null
@@ -161,14 +170,18 @@ export class CodeGraphAPI {
161
170
  lines.push("")
162
171
  }
163
172
 
164
- if (result.complexity_changes?.length) {
165
- lines.push("### Complexity Changes")
166
- for (const c of result.complexity_changes) {
167
- const delta = c.new_cc - c.old_cc
168
- const sign = delta > 0 ? "+" : ""
169
- lines.push(`- \`${c.method}\`: ${c.old_cc} -> ${c.new_cc} (${sign}${delta})`)
170
- }
171
- }
173
+ if (result.complexity_changes?.length) {
174
+ lines.push("### Complexity Changes")
175
+ for (const c of result.complexity_changes) {
176
+ if ((c.old_cc ?? 0) > 0) {
177
+ const delta = c.new_cc - c.old_cc
178
+ const sign = delta > 0 ? "+" : ""
179
+ lines.push(`- \`${c.method}\`: ${c.old_cc} -> ${c.new_cc} (${sign}${delta})`)
180
+ } else {
181
+ lines.push(`- \`${c.method}\`: current CC ${c.new_cc}`)
182
+ }
183
+ }
184
+ }
172
185
 
173
186
  return lines.length > 2 ? lines.join("\n") : "CodeGraph review: no issues found."
174
187
  } catch {
@@ -237,29 +250,60 @@ export class CodeGraphAPI {
237
250
  }
238
251
  }
239
252
 
240
- // -------------------------------------------------------------------
241
- // Private helpers
242
- // -------------------------------------------------------------------
243
-
244
- private async fetch(path: string, init?: RequestInit): Promise<any> {
245
- const url = `${this.baseUrl}${path}`
246
- const headers: Record<string, string> = {
247
- "Content-Type": "application/json",
248
- ...(init?.headers as Record<string, string> | undefined),
249
- }
250
-
251
- const response = await globalThis.fetch(url, {
252
- ...init,
253
- headers,
254
- })
255
-
256
- if (!response.ok) {
257
- throw new Error(`CodeGraph API ${response.status}: ${response.statusText}`)
258
- }
259
-
260
- const text = await response.text()
261
- if (!text) return null
262
-
263
- return JSON.parse(text)
264
- }
265
- }
253
+ // -------------------------------------------------------------------
254
+ // Private helpers
255
+ // -------------------------------------------------------------------
256
+
257
+ private resolveUrl(path: string): string {
258
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`
259
+ return new URL(normalizedPath, `${this.baseUrl}/`).toString()
260
+ }
261
+
262
+ private async parseJsonBody(response: Response): Promise<any> {
263
+ const text = await response.text()
264
+ if (!text) return null
265
+ return JSON.parse(text)
266
+ }
267
+
268
+ private async fetch(path: string, init?: RequestInit): Promise<any> {
269
+ const headers: Record<string, string> = {
270
+ "Content-Type": "application/json",
271
+ ...(init?.headers as Record<string, string> | undefined),
272
+ }
273
+
274
+ const response = await globalThis.fetch(this.resolveUrl(path), {
275
+ ...init,
276
+ headers,
277
+ })
278
+
279
+ if (response.status === 404 && path.startsWith("/api/v1/cpg/pattern-results")) {
280
+ const fallbackPath = path.replace(
281
+ "/api/v1/cpg/pattern-results",
282
+ "/api/v1/patterns/findings",
283
+ )
284
+ const fallbackResponse = await globalThis.fetch(this.resolveUrl(fallbackPath), {
285
+ ...init,
286
+ headers,
287
+ })
288
+
289
+ if (fallbackResponse.ok) {
290
+ const payload = await this.parseJsonBody(fallbackResponse)
291
+ const findings = Array.isArray(payload?.findings) ? payload.findings : []
292
+ return {
293
+ results: findings.map((finding: any) => ({
294
+ ...finding,
295
+ line: finding.line ?? finding.line_number ?? 0,
296
+ })),
297
+ }
298
+ }
299
+
300
+ throw new Error(`CodeGraph API ${fallbackResponse.status}: ${fallbackResponse.statusText}`)
301
+ }
302
+
303
+ if (!response.ok) {
304
+ throw new Error(`CodeGraph API ${response.status}: ${response.statusText}`)
305
+ }
306
+
307
+ return this.parseJsonBody(response)
308
+ }
309
+ }
package/src/index.ts CHANGED
@@ -10,11 +10,17 @@
10
10
  * - Toast notifications on CPG update completion
11
11
  */
12
12
 
13
- import type { Plugin } from "@opencode-ai/plugin"
14
- import { tool } from "@opencode-ai/plugin/tool"
15
-
16
- import { CodeGraphAPI } from "./api"
17
- import { extractFileRefs, isGitCommit } from "./util"
13
+ import type { Plugin } from "@opencode-ai/plugin"
14
+ import { tool } from "@opencode-ai/plugin/tool"
15
+
16
+ import { CodeGraphAPI } from "./api"
17
+ import {
18
+ extractFileRefs,
19
+ formatReviewTraceSummary,
20
+ getHeadCommit,
21
+ isGitCommit,
22
+ readReviewTraceSnapshot,
23
+ } from "./util"
18
24
 
19
25
  const codegraphPlugin: Plugin = async (input) => {
20
26
  const { client, directory, $ } = input
@@ -48,39 +54,63 @@ const codegraphPlugin: Plugin = async (input) => {
48
54
  const files = extractFileRefs(output.parts)
49
55
  if (files.length === 0) return
50
56
 
51
- try {
52
- for (const file of files.slice(0, 5)) {
53
- const context = await api.getFileCPGContext(projectId, file)
54
- if (context) {
55
- output.parts.push({
56
- type: "text",
57
- text: context,
58
- })
59
- }
60
- }
61
- } catch {
62
- // CodeGraph API not available — skip silently
63
- }
64
- },
57
+ try {
58
+ for (const file of files.slice(0, 5)) {
59
+ const context = await api.getFileCPGContext(projectId, file)
60
+ if (context) {
61
+ output.parts.push({
62
+ type: "text",
63
+ text: context,
64
+ } as any)
65
+ }
66
+ }
67
+ } catch {
68
+ // CodeGraph API not available — skip silently
69
+ }
70
+ },
65
71
 
66
72
  // -----------------------------------------------------------------
67
73
  // 3. Post-commit: trigger incremental CPG update
68
74
  // -----------------------------------------------------------------
69
- "tool.execute.after": async (inp, output) => {
70
- if (inp.tool !== "bash") return
71
- if (!isGitCommit(output.output)) return
72
-
73
- try {
74
- await api.triggerIncrementalUpdate(projectId, directory)
75
- // Notify user via output metadata (visible in OpenCode UI)
76
- output.title = "CodeGraph: CPG update triggered"
77
- output.metadata = {
78
- ...output.metadata,
79
- codegraph_cpg_update: "triggered",
80
- }
81
- } catch {
82
- // Best-effort — don't break the workflow
83
- }
75
+ "tool.execute.after": async (inp, output) => {
76
+ if (inp.tool !== "bash") return
77
+ if (!isGitCommit(output.output)) return
78
+
79
+ try {
80
+ const commit = await getHeadCommit($)
81
+ await api.triggerIncrementalUpdate(projectId, directory)
82
+
83
+ let traceSummary: string | null = null
84
+ if (commit) {
85
+ const traceSnapshot = await readReviewTraceSnapshot(directory, commit)
86
+ if (traceSnapshot) {
87
+ traceSummary = formatReviewTraceSummary(traceSnapshot)
88
+ output.metadata = {
89
+ ...output.metadata,
90
+ codegraph_review_trace_status: traceSnapshot.status || "unknown",
91
+ codegraph_review_trace_phase: traceSnapshot.phase || "unknown",
92
+ codegraph_review_trace_findings: traceSnapshot.review_findings_count ?? null,
93
+ codegraph_review_trace_recommendations:
94
+ traceSnapshot.review_recommendations?.slice(0, 3) || [],
95
+ }
96
+ }
97
+ }
98
+
99
+ // Notify user via output metadata (visible in OpenCode UI)
100
+ output.title = traceSummary
101
+ ? "CodeGraph: CPG update triggered + review trace found"
102
+ : "CodeGraph: CPG update triggered"
103
+ output.metadata = {
104
+ ...output.metadata,
105
+ codegraph_cpg_update: "triggered",
106
+ }
107
+ if (traceSummary) {
108
+ const existingOutput = output.output?.trimEnd() || ""
109
+ output.output = existingOutput ? `${existingOutput}\n\n${traceSummary}` : traceSummary
110
+ }
111
+ } catch {
112
+ // Best-effort — don't break the workflow
113
+ }
84
114
  },
85
115
 
86
116
  // -----------------------------------------------------------------
@@ -123,14 +153,15 @@ const codegraphPlugin: Plugin = async (input) => {
123
153
  }),
124
154
  },
125
155
 
126
- // -----------------------------------------------------------------
127
- // 5. Permissions: auto-allow codegraph_* MCP tools
128
- // -----------------------------------------------------------------
129
- "permission.ask": async (inp, output) => {
130
- if (inp.tool?.startsWith("codegraph_")) {
131
- output.status = "allow"
132
- }
133
- },
156
+ // -----------------------------------------------------------------
157
+ // 5. Permissions: auto-allow codegraph_* MCP tools
158
+ // -----------------------------------------------------------------
159
+ "permission.ask": async (inp, output) => {
160
+ const toolName = (inp as any)?.tool as string | undefined
161
+ if (toolName?.startsWith("codegraph_")) {
162
+ output.status = "allow"
163
+ }
164
+ },
134
165
  }
135
166
  }
136
167
 
package/src/util.ts CHANGED
@@ -1,6 +1,20 @@
1
- /**
2
- * Utility functions for the CodeGraph OpenCode plugin.
3
- */
1
+ import { readFile } from "node:fs/promises"
2
+ import path from "node:path"
3
+
4
+ /**
5
+ * Utility functions for the CodeGraph OpenCode plugin.
6
+ */
7
+
8
+ export type ReviewTraceSnapshot = {
9
+ commit?: string
10
+ status?: string
11
+ phase?: string
12
+ updated_at?: string
13
+ review_findings_count?: number
14
+ review_severity_counts?: Record<string, number>
15
+ review_recommendations?: string[]
16
+ error?: string | null
17
+ }
4
18
 
5
19
  /**
6
20
  * Extract file references from message parts.
@@ -43,7 +57,7 @@ export function extractFileRefs(parts: Array<{ type: string; text?: string }>):
43
57
  /**
44
58
  * Check if a shell command output indicates a git commit was made.
45
59
  */
46
- export function isGitCommit(output: string): boolean {
60
+ export function isGitCommit(output: string): boolean {
47
61
  if (!output) return false
48
62
 
49
63
  // Common git commit success patterns
@@ -52,10 +66,77 @@ export function isGitCommit(output: string): boolean {
52
66
  /^\s*create mode/m.test(output) || // create mode 100644
53
67
  /^\s*\d+ files? changed/m.test(output) // 3 files changed, 42 insertions
54
68
  )
55
- }
56
-
57
- // File extensions recognized as source code
58
- const SOURCE_EXTENSIONS = new Set([
69
+ }
70
+
71
+ export async function getHeadCommit($: any): Promise<string | null> {
72
+ try {
73
+ const sha = (await $`git rev-parse HEAD`.quiet().text()).trim()
74
+ return sha || null
75
+ } catch {
76
+ return null
77
+ }
78
+ }
79
+
80
+ export async function readReviewTraceSnapshot(
81
+ directory: string,
82
+ commit: string,
83
+ attempts = 4,
84
+ delayMs = 500,
85
+ ): Promise<ReviewTraceSnapshot | null> {
86
+ const statusPath = path.join(directory, "data", "reviews", `${commit}.status.json`)
87
+
88
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
89
+ try {
90
+ const raw = await readFile(statusPath, "utf-8")
91
+ return JSON.parse(raw) as ReviewTraceSnapshot
92
+ } catch {
93
+ if (attempt === attempts - 1) return null
94
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
95
+ }
96
+ }
97
+
98
+ return null
99
+ }
100
+
101
+ export function formatReviewTraceSummary(snapshot: ReviewTraceSnapshot): string | null {
102
+ const status = snapshot.status || "unknown"
103
+ const phase = snapshot.phase || "unknown"
104
+ const findingsCount = snapshot.review_findings_count
105
+ const recommendations = Array.isArray(snapshot.review_recommendations)
106
+ ? snapshot.review_recommendations.filter(Boolean)
107
+ : []
108
+
109
+ const lines = ["## CodeGraph Review Trace", "", `- Status: ${status}`, `- Phase: ${phase}`]
110
+
111
+ if (typeof findingsCount === "number") {
112
+ lines.push(`- Findings: ${findingsCount}`)
113
+ }
114
+
115
+ const severityCounts = snapshot.review_severity_counts || {}
116
+ const severitySummary = Object.entries(severityCounts)
117
+ .filter(([, count]) => typeof count === "number" && count > 0)
118
+ .map(([severity, count]) => `${severity}:${count}`)
119
+ .join(", ")
120
+ if (severitySummary) {
121
+ lines.push(`- Severity: ${severitySummary}`)
122
+ }
123
+
124
+ if (snapshot.error) {
125
+ lines.push(`- Error: ${snapshot.error}`)
126
+ }
127
+
128
+ if (recommendations.length) {
129
+ lines.push("", "### Recommendations")
130
+ for (const recommendation of recommendations.slice(0, 3)) {
131
+ lines.push(`- ${recommendation}`)
132
+ }
133
+ }
134
+
135
+ return lines.length > 2 ? lines.join("\n") : null
136
+ }
137
+
138
+ // File extensions recognized as source code
139
+ const SOURCE_EXTENSIONS = new Set([
59
140
  "py",
60
141
  "go",
61
142
  "js",