Repository navigation
chore: opencode - Wire graft and graphify code graphs (#3569) #3567
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
5950983
Wire graft and graphify code graphs into OpenCode
eleshar 6df2ab0
Clarify that code graphs are built per checkout
eleshar 8a95beb
Address review findings on code graph wiring
eleshar 7b73034
Make code graph tooling optional for all agents
eleshar b8d327d
Fix documentation lint on code graph wiring
eleshar b007543
Apply Qodo review findings to code graph wiring
eleshar 373443b
Apply second Qodo review to code graph wiring
eleshar 6845398
Quote the graph path in the graphify reminder
eleshar 3a75fe3
Single-quote the graph path for both POSIX shells and PowerShell
eleshar 5c2f08c
Merge branch 'develop' into chore/opencode-graph-tooling
mergify[bot] ae6d90e
Merge branch 'develop' into chore/opencode-graph-tooling
mergify[bot] 27c4e5c
Merge branch 'develop' into chore/opencode-graph-tooling
mergify[bot] d8a2ea6
Merge branch 'develop' into chore/opencode-graph-tooling
mergify[bot] 7d51e51
Merge branch 'develop' into chore/opencode-graph-tooling
mergify[bot] 3ffa3d7
Merge branch 'develop' into chore/opencode-graph-tooling
mergify[bot] bf8074e
fix: stop guessing the shell for the graphify graph path (#3567)
eleshar 4df0179
fix: close the apostrophe case, make the graphify test portable, dedu…
eleshar c9f35a4
chore: adopt develop .gitattributes
eleshar 4b90f95
Merge remote-tracking branch 'origin/develop' into HEAD
eleshar 13becd3
fix: normalise Windows separators before choosing graph path quoting
eleshar 7e6047f
fix: stop guessing the graph path shell entirely, covering cmd.exe
eleshar 4893ba5
fix: treat a percent sign as hostile when quoting the graph path
eleshar 7dd9f45
fix: treat a bang as hostile when quoting the graph path
eleshar File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| # graft's cards are gitignored but should stay greppable: ripgrep reads | ||
| # .ignore before .gitignore, so this re-admits the tree to search only. Anchored so a | ||
| # nested directory of the same name is not re-admitted. | ||
| !/graft/ | ||
| graft/.cache/ | ||
| graft/.graph/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| const fs = require("fs"); | ||
| const os = require("os"); | ||
| const path = require("path"); | ||
|
|
||
| const { GraphifyPlugin } = require("../graphify.js"); | ||
|
|
||
| // The plugin only ever appends advisory text to the agent's output; it never | ||
| // runs the command. These tests therefore assert on the reminder the agent would | ||
| // copy, which is where a mis-quoted graph path does its damage. Expectations are | ||
| // built from os.tmpdir() rather than a literal /tmp, and mirror the separator | ||
| // handling the plugin applies, so the suite holds on Windows and macOS too. | ||
|
|
||
| // shellQuote normalises Windows separators to forward slashes, so the expected | ||
| // value has to be normalised the same way or the comparison fails on a native | ||
| // Windows runner. | ||
| const expectedGraph = (root) => { | ||
| const joined = path.join(root, "graphify-out", "graph.json"); | ||
| return process.platform === "win32" ? joined.replace(/\\/g, "/") : joined; | ||
| }; | ||
|
|
||
| async function reminderFor(prefix) { | ||
| const root = fs.mkdtempSync(path.join(os.tmpdir(), `graphify-${prefix}-`)); | ||
| fs.mkdirSync(path.join(root, "graphify-out")); | ||
| fs.writeFileSync(path.join(root, "graphify-out", "graph.json"), "{}"); | ||
|
|
||
| const plugin = await GraphifyPlugin({ directory: root, worktree: root }); | ||
| const output = { output: "bash done" }; | ||
| await plugin["tool.execute.after"]( | ||
| { tool: "bash", sessionID: `s-${prefix}` }, | ||
| output, | ||
| ); | ||
| return { root, output: output.output }; | ||
| } | ||
|
|
||
| // The quoted value may itself contain spaces, so capture up to the clause that | ||
| // follows rather than to the next whitespace. | ||
| const graphArg = (reminder) => reminder.match(/--graph (.+?) \(scoped subgraph/)?.[1]; | ||
|
|
||
| describe("graphify reminder quotes the graph path safely", () => { | ||
| test("leaves a plain path unquoted, so no shell has to be guessed", async () => { | ||
| const { root, output } = await reminderFor("plain"); | ||
| expect(graphArg(output)).toBe(expectedGraph(root)); | ||
| }); | ||
|
|
||
| test("double-quotes a path with a space, which every shell groups alike", async () => { | ||
| const { root, output } = await reminderFor("with space"); | ||
| expect(graphArg(output)).toBe(`"${expectedGraph(root)}"`); | ||
| }); | ||
|
|
||
| test("double-quotes a path with an apostrophe rather than guessing a shell", async () => { | ||
| // PowerShell escapes an apostrophe as '' and POSIX as '\'' , and cmd.exe does | ||
| // not treat single quotes as quoting at all, so picking by platform corrupted | ||
| // the argument on the other shell. Double quotes group in all three. | ||
| const { root, output } = await reminderFor("owner's repo"); | ||
| expect(root).toContain("'"); | ||
| expect(graphArg(output)).toBe(`"${expectedGraph(root)}"`); | ||
| }); | ||
|
|
||
| test("normalises Windows separators so a native path needs no quoting at all", async () => { | ||
| // A native Windows path is full of backslashes, which are double-quote | ||
| // unsafe, so without normalisation every Windows path would drop out of the | ||
| // portable tiers. A backslash is a legal filename character on Linux, so the | ||
| // same input can be built here and the platform mocked. | ||
| const real = process.platform; | ||
| Object.defineProperty(process, "platform", { | ||
| value: "win32", | ||
| configurable: true, | ||
| }); | ||
| try { | ||
| const outer = fs.mkdtempSync(path.join(os.tmpdir(), "graphify-win-")); | ||
| const root = path.join(outer, "win\\style"); | ||
| fs.mkdirSync(path.join(root, "graphify-out"), { recursive: true }); | ||
| fs.writeFileSync(path.join(root, "graphify-out", "graph.json"), "{}"); | ||
|
|
||
| const plugin = await GraphifyPlugin({ directory: root, worktree: root }); | ||
| const output = { output: "bash done" }; | ||
| await plugin["tool.execute.after"]( | ||
| { tool: "bash", sessionID: "win" }, | ||
| output, | ||
| ); | ||
|
|
||
| // Forward slashes and no quoting: correct in cmd, PowerShell and Bash. | ||
| const arg = graphArg(output.output); | ||
| expect(root).toContain("\\"); | ||
| expect(arg).not.toContain("\\"); | ||
| expect(arg).toBe(expectedGraph(root)); | ||
| } finally { | ||
| Object.defineProperty(process, "platform", { | ||
| value: real, | ||
| configurable: true, | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| test("offers no runnable example for a percent sign, which cmd.exe expands", async () => { | ||
| // cmd.exe expands %VAR% even inside double quotes, so a literal | ||
| // %USERNAME% directory would be rewritten before graphify ever saw it. On | ||
| // POSIX a percent sign is an ordinary filename character, so the path is | ||
| // still safe there, but it has no form that is right in both. | ||
| const { root, output } = await reminderFor("%USERNAME%"); | ||
| expect(root).toContain("%USERNAME%"); | ||
| expect(graphArg(output)).toBeUndefined(); | ||
| expect(output).not.toContain("--graph"); | ||
| expect(output).toContain("no runnable scoped example"); | ||
| }); | ||
|
|
||
| test("offers no runnable example for a bang, which two shells expand", async () => { | ||
| // cmd.exe expands !VAR! inside double quotes when delayed expansion is on, | ||
| // and interactive bash performs history expansion on !. Neither is POSIX | ||
| // script behaviour, but the reminder is aimed at whatever shell a human | ||
| // typed, so a path containing one has no form that is right in all of them. | ||
| const { root, output } = await reminderFor("!USERNAME!"); | ||
| expect(root).toContain("!USERNAME!"); | ||
| expect(graphArg(output)).toBeUndefined(); | ||
| expect(output).not.toContain("--graph"); | ||
| expect(output).toContain("no runnable scoped example"); | ||
| }); | ||
|
|
||
| test("offers no runnable example for a path no shell quotes the same way", async () => { | ||
| // A dollar sign, double quote, backtick or backslash has no portable form: | ||
| // it needs POSIX '\'' or PowerShell '' for an apostrophe, and cmd.exe cannot | ||
| // group it at all. Emitting a command that is wrong in some shell is worse | ||
| // than saying so, so the scoped example is dropped. | ||
| const { root, output } = await reminderFor("cash$"); | ||
| expect(root).toContain("$"); | ||
| expect(graphArg(output)).toBeUndefined(); | ||
| expect(output).not.toContain("--graph"); | ||
| expect(output).toContain("no runnable scoped example"); | ||
| expect(output).toContain("GRAPH_REPORT.md"); | ||
| }); | ||
|
|
||
| test("does not re-add the reminder for a second bash call in the same session", async () => { | ||
| const root = fs.mkdtempSync(path.join(os.tmpdir(), "graphify-once-")); | ||
| fs.mkdirSync(path.join(root, "graphify-out")); | ||
| fs.writeFileSync(path.join(root, "graphify-out", "graph.json"), "{}"); | ||
|
|
||
| const plugin = await GraphifyPlugin({ directory: root, worktree: root }); | ||
| const first = { output: "one" }; | ||
| await plugin["tool.execute.after"]( | ||
| { tool: "bash", sessionID: "same" }, | ||
| first, | ||
| ); | ||
| const second = { output: "two" }; | ||
| await plugin["tool.execute.after"]( | ||
| { tool: "bash", sessionID: "same" }, | ||
| second, | ||
| ); | ||
|
|
||
| expect(first.output).toContain("graphify"); | ||
| expect(second.output).toBe("two"); | ||
| }); | ||
|
|
||
| test("stays silent when the repository has no graph", async () => { | ||
| const root = fs.mkdtempSync(path.join(os.tmpdir(), "graphify-none-")); | ||
| const plugin = await GraphifyPlugin({ directory: root, worktree: root }); | ||
| const output = { output: "bash done" }; | ||
| await plugin["tool.execute.after"]( | ||
| { tool: "bash", sessionID: "none" }, | ||
| output, | ||
| ); | ||
| expect(output.output).toBe("bash done"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| // graphify OpenCode plugin, adapted from `graphify opencode install`. | ||
| // | ||
| // Appends a one-time knowledge-graph reminder to the output of the first bash call in each | ||
| // session. The command itself is never modified, so the plugin behaves the same under any shell | ||
| // OpenCode uses (bash, zsh, Windows PowerShell 5.1, PowerShell 7) and cannot change what runs. | ||
| // Rerunning `graphify opencode install` restores upstream's command-prefixing version; keep this one. | ||
| import { existsSync } from "fs"; | ||
| import { join } from "path"; | ||
|
|
||
| // Quote the path as one literal shell argument, or return null when no quoting | ||
| // is correct everywhere. The plugin API reports only directory and worktree and | ||
| // the repository pins no shell, so nothing here may assume one: a Windows host | ||
| // can run Bash, a POSIX host can run PowerShell, and cmd.exe does not treat | ||
| // single quotes as quoting at all. | ||
| // | ||
| // 1. On Windows a backslash is a path separator and forward slashes are | ||
| // accepted by every Windows shell, so normalise first. On POSIX a backslash | ||
| // is a legal filename character and must be left alone. | ||
| // 2. A path with no shell metacharacter then needs no quoting at all. | ||
| // 3. Double quotes group in POSIX shells, PowerShell and cmd.exe alike, and | ||
| // inside them POSIX shells and PowerShell treat every character literally. | ||
| // The other two shells do not: cmd.exe still expands %VAR% and, with | ||
| // delayed expansion enabled, !VAR! inside double quotes, and interactive | ||
| // bash performs history expansion on !. A path free of " $ ` \ % and ! is | ||
| // therefore safe to double-quote, which covers spaces and apostrophes. | ||
| // 4. A path containing one of those has no portable form: it needs POSIX | ||
| // '\'' or PowerShell '' for an apostrophe, and cmd.exe cannot group it | ||
| // and would expand the variables anyway. Rather than emit a command that is | ||
| // wrong in some shell, the caller drops the runnable example. | ||
| const SHELL_SAFE_PATH = /^[A-Za-z0-9_@+=:,./-]+$/; | ||
| const DOUBLE_QUOTE_UNSAFE = /["$`\\%!]/; | ||
|
|
||
| const shellQuote = (path) => { | ||
| const normalised = | ||
| process.platform === "win32" ? path.replace(/\\/g, "/") : path; | ||
| if (SHELL_SAFE_PATH.test(normalised)) return normalised; | ||
| if (!DOUBLE_QUOTE_UNSAFE.test(normalised)) return `"${normalised}"`; | ||
| return null; | ||
| }; | ||
|
|
||
| const reminder = (graph) => { | ||
| const quoted = shellQuote(graph); | ||
| if (!quoted) { | ||
| return ( | ||
| `[graphify] knowledge graph at ${graph}. Its path contains a character no ` + | ||
| "shell quotes the same way, so no runnable scoped example is offered here; " + | ||
| "read GRAPH_REPORT.md instead of grepping raw files." | ||
| ); | ||
| } | ||
| return ( | ||
| `[graphify] knowledge graph at ${graph}. For focused questions, run ` + | ||
| `graphify query "<question>" --graph ${quoted} (scoped subgraph, usually much smaller than ` + | ||
| "GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md next to it only for " + | ||
| "broad architecture context." | ||
| ); | ||
| }; | ||
|
|
||
| export const GraphifyPlugin = async ({ directory, worktree }) => { | ||
| const reminded = new Set(); | ||
| // Sessions can start below the repository root, so prefer the worktree root's graph, and give | ||
| // the agent its absolute path so the suggested command works from any working directory. | ||
| const findGraph = () => | ||
| [worktree, directory] | ||
| .filter(Boolean) | ||
| .map((d) => join(d, "graphify-out", "graph.json")) | ||
| .find((p) => existsSync(p)); | ||
|
|
||
| return { | ||
| "tool.execute.after": async (input, output) => { | ||
| if (input.tool !== "bash" || reminded.has(input.sessionID)) return; | ||
| const graph = findGraph(); | ||
| if (!graph) return; | ||
| reminded.add(input.sessionID); | ||
| output.output = `${output.output ?? ""}\n\n${reminder(graph)}`; | ||
| }, | ||
| event: async ({ event }) => { | ||
| if (event.type === "session.deleted") reminded.delete(event.properties?.info?.id); | ||
| }, | ||
| }; | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| { | ||
| "$schema": "https://opencode.ai/config.json", | ||
| "mcp": { | ||
| "graft": { | ||
| "type": "local", | ||
| "command": [ | ||
| "graft", | ||
| "mcp" | ||
| ], | ||
| "enabled": true | ||
| }, | ||
| "graphify": { | ||
| "type": "local", | ||
| "command": [ | ||
| "graphify-mcp", | ||
| "graphify-out/graph.json" | ||
| ], | ||
| "enabled": true, | ||
| "timeout": 60000 | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.