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 +1 -1
- package/src/api.ts +98 -54
- package/src/index.ts +73 -42
- package/src/util.ts +89 -8
package/package.json
CHANGED
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
|
-
|
|
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 =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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 {
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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",
|