opencode-codegraph 0.1.0

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 ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "opencode-codegraph",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin for CodeGraph CPG-powered code analysis",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "scripts": {
14
+ "typecheck": "tsc --noEmit",
15
+ "build": "tsc"
16
+ },
17
+ "keywords": [
18
+ "opencode",
19
+ "codegraph",
20
+ "cpg",
21
+ "code-analysis",
22
+ "security"
23
+ ],
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "@opencode-ai/plugin": "^1.2.22"
27
+ },
28
+ "devDependencies": {
29
+ "typescript": "^5.8.0",
30
+ "@types/node": "^22.0.0"
31
+ },
32
+ "peerDependencies": {
33
+ "@opencode-ai/plugin": ">=1.2.0"
34
+ }
35
+ }
package/src/api.ts ADDED
@@ -0,0 +1,265 @@
1
+ /**
2
+ * CodeGraph REST API client for the OpenCode plugin.
3
+ *
4
+ * Communicates with the CodeGraph API server (default: http://localhost:8000)
5
+ * to retrieve CPG data, trigger updates, and run analysis.
6
+ */
7
+
8
+ export class CodeGraphAPI {
9
+ private baseUrl: string
10
+
11
+ constructor(baseUrl: string) {
12
+ this.baseUrl = baseUrl.replace(/\/+$/, "")
13
+ }
14
+
15
+ /**
16
+ * Get project summary for system prompt injection.
17
+ * Returns a markdown block with file count, top hotspots, and open security findings.
18
+ */
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
+ ])
27
+
28
+ const stats =
29
+ statsRes.status === "fulfilled" ? statsRes.value : null
30
+ const findings =
31
+ findingsRes.status === "fulfilled" ? findingsRes.value : null
32
+
33
+ if (!stats && !findings) return null
34
+
35
+ const lines: string[] = [
36
+ "## CodeGraph CPG Context",
37
+ "",
38
+ ]
39
+
40
+ if (stats) {
41
+ lines.push(
42
+ `- **Files**: ${stats.total_files ?? "?"}`,
43
+ `- **Methods**: ${stats.total_methods ?? "?"}`,
44
+ `- **Security findings**: ${stats.security_findings ?? "?"}`,
45
+ )
46
+ if (stats.top_complex_methods?.length) {
47
+ lines.push("", "### Top complex methods")
48
+ for (const m of stats.top_complex_methods.slice(0, 5)) {
49
+ lines.push(`- \`${m.name}\` (CC=${m.cyclomatic_complexity}, fan_in=${m.fan_in})`)
50
+ }
51
+ }
52
+ }
53
+
54
+ if (findings?.results?.length) {
55
+ lines.push("", "### Open security findings")
56
+ for (const f of findings.results.slice(0, 5)) {
57
+ lines.push(`- **${f.rule_id}**: ${f.message} (${f.filename}:${f.line})`)
58
+ }
59
+ }
60
+
61
+ return lines.join("\n")
62
+ }
63
+
64
+ /**
65
+ * Get CPG context for a specific file.
66
+ * Returns markdown with methods, callers, complexity, and security findings.
67
+ */
68
+ 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
+ ])
77
+
78
+ const methods =
79
+ methodsRes.status === "fulfilled" ? methodsRes.value : null
80
+ const findings =
81
+ findingsRes.status === "fulfilled" ? findingsRes.value : null
82
+
83
+ if (!methods?.results?.length && !findings?.results?.length) return null
84
+
85
+ const lines: string[] = [
86
+ `### CPG context: \`${filePath}\``,
87
+ "",
88
+ ]
89
+
90
+ if (methods?.results?.length) {
91
+ lines.push(`**${methods.results.length} methods** in file:`)
92
+ for (const m of methods.results.slice(0, 15)) {
93
+ const flags: string[] = []
94
+ if (m.is_entry_point) flags.push("entry")
95
+ if (m.is_external) flags.push("external")
96
+ if (m.fan_in === 0 && !m.is_entry_point && !m.is_external && !m.is_test) flags.push("DEAD")
97
+ const flagStr = flags.length ? ` [${flags.join(", ")}]` : ""
98
+ lines.push(
99
+ `- \`${m.name}\` CC=${m.cyclomatic_complexity} fan_in=${m.fan_in} fan_out=${m.fan_out}${flagStr}`,
100
+ )
101
+ }
102
+ }
103
+
104
+ if (findings?.results?.length) {
105
+ lines.push("", `**${findings.results.length} security findings:**`)
106
+ for (const f of findings.results.slice(0, 10)) {
107
+ lines.push(`- **${f.rule_id}** L${f.line}: ${f.message}`)
108
+ }
109
+ }
110
+
111
+ return lines.join("\n")
112
+ }
113
+
114
+ /**
115
+ * Trigger incremental CPG update after a git commit.
116
+ */
117
+ async triggerIncrementalUpdate(projectId: string, directory: string): Promise<void> {
118
+ await this.fetch("/api/v1/webhooks/local", {
119
+ method: "POST",
120
+ body: JSON.stringify({
121
+ event_type: "push",
122
+ project_id: projectId,
123
+ directory,
124
+ }),
125
+ })
126
+ }
127
+
128
+ /**
129
+ * Review code changes using CodeGraph analysis.
130
+ */
131
+ async reviewChanges(projectId: string, diff: string, baseRef: string): Promise<string> {
132
+ try {
133
+ const result = await this.fetch("/api/v1/review", {
134
+ method: "POST",
135
+ body: JSON.stringify({
136
+ project_id: projectId,
137
+ diff,
138
+ base_ref: baseRef,
139
+ }),
140
+ })
141
+
142
+ if (!result) return "CodeGraph review: no results."
143
+
144
+ const lines: string[] = ["## CodeGraph Review Results", ""]
145
+
146
+ if (result.security_findings?.length) {
147
+ lines.push(`### Security (${result.security_findings.length} findings)`)
148
+ for (const f of result.security_findings) {
149
+ lines.push(`- **${f.rule_id}**: ${f.message} (${f.filename}:${f.line})`)
150
+ }
151
+ lines.push("")
152
+ }
153
+
154
+ if (result.impact_analysis) {
155
+ lines.push("### Impact Analysis")
156
+ lines.push(`- Affected methods: ${result.impact_analysis.affected_methods ?? 0}`)
157
+ lines.push(`- Affected callers: ${result.impact_analysis.affected_callers ?? 0}`)
158
+ if (result.impact_analysis.taint_paths?.length) {
159
+ lines.push(`- Taint paths: ${result.impact_analysis.taint_paths.length}`)
160
+ }
161
+ lines.push("")
162
+ }
163
+
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
+ }
172
+
173
+ return lines.length > 2 ? lines.join("\n") : "CodeGraph review: no issues found."
174
+ } catch {
175
+ return "CodeGraph review: API not available. Run `codegraph-api` first."
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Explain a function using CPG data.
181
+ */
182
+ async explainFunction(projectId: string, functionName: string): Promise<string> {
183
+ try {
184
+ const params = new URLSearchParams({ name: functionName })
185
+ if (projectId) params.set("project_id", projectId)
186
+
187
+ const result = await this.fetch(`/api/v1/cpg/method-detail?${params}`)
188
+ if (!result) return `Function '${functionName}' not found in CPG.`
189
+
190
+ const lines: string[] = [
191
+ `## \`${result.name}\``,
192
+ "",
193
+ `| Metric | Value |`,
194
+ `|--------|-------|`,
195
+ `| File | \`${result.filename}\` |`,
196
+ `| Line | ${result.line_number} |`,
197
+ `| Cyclomatic complexity | ${result.cyclomatic_complexity} |`,
198
+ `| Fan-in (callers) | ${result.fan_in} |`,
199
+ `| Fan-out (callees) | ${result.fan_out} |`,
200
+ `| LOC | ${result.loc} |`,
201
+ `| Parameters | ${result.parameters} |`,
202
+ `| Entry point | ${result.is_entry_point ? "Yes" : "No"} |`,
203
+ `| External | ${result.is_external ? "Yes" : "No"} |`,
204
+ ]
205
+
206
+ if (result.callers?.length) {
207
+ lines.push("", "### Callers")
208
+ for (const c of result.callers.slice(0, 20)) {
209
+ lines.push(`- \`${c.caller_name}\` (${c.caller_filename}:${c.caller_line})`)
210
+ }
211
+ }
212
+
213
+ if (result.callees?.length) {
214
+ lines.push("", "### Callees")
215
+ for (const c of result.callees.slice(0, 20)) {
216
+ lines.push(`- \`${c.callee_name}\` (${c.callee_filename}:${c.callee_line})`)
217
+ }
218
+ }
219
+
220
+ if (result.security_findings?.length) {
221
+ lines.push("", "### Security Findings")
222
+ for (const f of result.security_findings) {
223
+ lines.push(`- **${f.rule_id}**: ${f.message}`)
224
+ }
225
+ }
226
+
227
+ if (result.taint_paths?.length) {
228
+ lines.push("", "### Taint Paths")
229
+ for (const t of result.taint_paths.slice(0, 5)) {
230
+ lines.push(`- ${t.source} -> ${t.sink} (${t.path_length} hops)`)
231
+ }
232
+ }
233
+
234
+ return lines.join("\n")
235
+ } catch {
236
+ return `CodeGraph: API not available. Run \`codegraph-api\` first.`
237
+ }
238
+ }
239
+
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
+ }
package/src/index.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * OpenCode plugin for CodeGraph CPG-powered code analysis.
3
+ *
4
+ * Provides:
5
+ * - Auto-enrichment of chat messages with CPG context for mentioned files
6
+ * - System prompt injection with project summary (hotspots, security findings)
7
+ * - Post-commit CPG incremental update trigger
8
+ * - Custom tools: codegraph_review, codegraph_explain_function
9
+ * - Auto-allow for codegraph_* MCP tools
10
+ * - Toast notifications on CPG update completion
11
+ */
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"
18
+
19
+ const codegraphPlugin: Plugin = async (input) => {
20
+ const { client, directory, $ } = input
21
+
22
+ // Detect CodeGraph API URL from environment or default
23
+ const apiUrl = process.env.CODEGRAPH_API_URL || "http://localhost:8000"
24
+ const api = new CodeGraphAPI(apiUrl)
25
+
26
+ // Detect project ID from environment or opencode.json
27
+ const projectId = process.env.CODEGRAPH_PROJECT || ""
28
+
29
+ return {
30
+ // -----------------------------------------------------------------
31
+ // 1. System prompt: inject CPG project summary
32
+ // -----------------------------------------------------------------
33
+ "experimental.chat.system.transform": async (_inp, output) => {
34
+ try {
35
+ const summary = await api.getProjectSummary(projectId)
36
+ if (summary) {
37
+ output.system.push(summary)
38
+ }
39
+ } catch {
40
+ // CodeGraph API not available — skip silently
41
+ }
42
+ },
43
+
44
+ // -----------------------------------------------------------------
45
+ // 2. Chat message: auto-enrich with CPG context for mentioned files
46
+ // -----------------------------------------------------------------
47
+ "chat.message": async (_inp, output) => {
48
+ const files = extractFileRefs(output.parts)
49
+ if (files.length === 0) return
50
+
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
+ },
65
+
66
+ // -----------------------------------------------------------------
67
+ // 3. Post-commit: trigger incremental CPG update
68
+ // -----------------------------------------------------------------
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
+ }
84
+ },
85
+
86
+ // -----------------------------------------------------------------
87
+ // 4. Custom tools
88
+ // -----------------------------------------------------------------
89
+ tool: {
90
+ codegraph_review: tool({
91
+ description:
92
+ "Run CodeGraph security + impact analysis on current changes. " +
93
+ "Analyzes git diff using CPG call graph, taint analysis, and pattern matching.",
94
+ args: {
95
+ base_ref: tool.schema
96
+ .string()
97
+ .optional()
98
+ .describe("Base git ref to compare against (default: HEAD~1)"),
99
+ },
100
+ async execute(args) {
101
+ const baseRef = args.base_ref || "HEAD~1"
102
+ const diff = await $`git diff ${baseRef}`.quiet().text()
103
+ if (!diff.trim()) {
104
+ return "No changes found."
105
+ }
106
+
107
+ const result = await api.reviewChanges(projectId, diff, baseRef)
108
+ return result
109
+ },
110
+ }),
111
+
112
+ codegraph_explain_function: tool({
113
+ description:
114
+ "Deep analysis of a function using CPG. Shows callers, callees, " +
115
+ "complexity, taint paths, and security findings.",
116
+ args: {
117
+ name: tool.schema.string().describe("Function or method name to analyze"),
118
+ },
119
+ async execute(args) {
120
+ const result = await api.explainFunction(projectId, args.name)
121
+ return result
122
+ },
123
+ }),
124
+ },
125
+
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
+ },
134
+ }
135
+ }
136
+
137
+ export default codegraphPlugin
package/src/util.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Utility functions for the CodeGraph OpenCode plugin.
3
+ */
4
+
5
+ /**
6
+ * Extract file references from message parts.
7
+ * Looks for file paths in text parts (e.g., `src/api/main.py`, @filename patterns).
8
+ */
9
+ export function extractFileRefs(parts: Array<{ type: string; text?: string }>): string[] {
10
+ const files: string[] = []
11
+ const seen = new Set<string>()
12
+
13
+ // Common file path patterns
14
+ const patterns = [
15
+ // Explicit @file references (OpenCode convention)
16
+ /(?<!\w)@(\.?[\w/\\.-]+\.\w+)/g,
17
+ // Backtick-quoted paths
18
+ /`([\w/\\.-]+\.\w{1,10})`/g,
19
+ // Common source file patterns in plain text
20
+ /\b((?:src|lib|app|tests?|pkg|cmd|internal)\/[\w/\\.-]+\.\w{1,10})\b/g,
21
+ ]
22
+
23
+ for (const part of parts) {
24
+ if (part.type !== "text" || !part.text) continue
25
+
26
+ for (const pattern of patterns) {
27
+ // Reset lastIndex for global regex
28
+ pattern.lastIndex = 0
29
+ let match
30
+ while ((match = pattern.exec(part.text)) !== null) {
31
+ const file = match[1]
32
+ if (file && !seen.has(file) && isSourceFile(file)) {
33
+ seen.add(file)
34
+ files.push(file)
35
+ }
36
+ }
37
+ }
38
+ }
39
+
40
+ return files
41
+ }
42
+
43
+ /**
44
+ * Check if a shell command output indicates a git commit was made.
45
+ */
46
+ export function isGitCommit(output: string): boolean {
47
+ if (!output) return false
48
+
49
+ // Common git commit success patterns
50
+ return (
51
+ /\[[\w/.-]+\s+[a-f0-9]+\]/.test(output) || // [main abc1234] commit message
52
+ /^\s*create mode/m.test(output) || // create mode 100644
53
+ /^\s*\d+ files? changed/m.test(output) // 3 files changed, 42 insertions
54
+ )
55
+ }
56
+
57
+ // File extensions recognized as source code
58
+ const SOURCE_EXTENSIONS = new Set([
59
+ "py",
60
+ "go",
61
+ "js",
62
+ "ts",
63
+ "jsx",
64
+ "tsx",
65
+ "java",
66
+ "kt",
67
+ "kts",
68
+ "c",
69
+ "h",
70
+ "cpp",
71
+ "hpp",
72
+ "cc",
73
+ "cxx",
74
+ "cs",
75
+ "php",
76
+ "rb",
77
+ "rs",
78
+ "swift",
79
+ "sql",
80
+ "yaml",
81
+ "yml",
82
+ "json",
83
+ "toml",
84
+ "xml",
85
+ "html",
86
+ "css",
87
+ "scss",
88
+ "md",
89
+ "proto",
90
+ "graphql",
91
+ "gql",
92
+ ])
93
+
94
+ function isSourceFile(path: string): boolean {
95
+ const ext = path.split(".").pop()?.toLowerCase()
96
+ return ext ? SOURCE_EXTENSIONS.has(ext) : false
97
+ }