Skip to content
Merged
Show file tree
Hide file tree
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 Sep 25, 2026
6df2ab0
Clarify that code graphs are built per checkout
eleshar Sep 25, 2026
8a95beb
Address review findings on code graph wiring
eleshar Sep 25, 2026
7b73034
Make code graph tooling optional for all agents
eleshar Sep 25, 2026
b8d327d
Fix documentation lint on code graph wiring
eleshar Sep 25, 2026
b007543
Apply Qodo review findings to code graph wiring
eleshar Sep 25, 2026
373443b
Apply second Qodo review to code graph wiring
eleshar Sep 25, 2026
6845398
Quote the graph path in the graphify reminder
eleshar Sep 25, 2026
3a75fe3
Single-quote the graph path for both POSIX shells and PowerShell
eleshar Sep 25, 2026
5c2f08c
Merge branch 'develop' into chore/opencode-graph-tooling
mergify[bot] Sep 25, 2026
ae6d90e
Merge branch 'develop' into chore/opencode-graph-tooling
mergify[bot] Sep 25, 2026
27c4e5c
Merge branch 'develop' into chore/opencode-graph-tooling
mergify[bot] Sep 25, 2026
d8a2ea6
Merge branch 'develop' into chore/opencode-graph-tooling
mergify[bot] Sep 26, 2026
7d51e51
Merge branch 'develop' into chore/opencode-graph-tooling
mergify[bot] Sep 26, 2026
3ffa3d7
Merge branch 'develop' into chore/opencode-graph-tooling
mergify[bot] Sep 26, 2026
bf8074e
fix: stop guessing the shell for the graphify graph path (#3567)
eleshar Sep 26, 2026
4df0179
fix: close the apostrophe case, make the graphify test portable, dedu…
eleshar Sep 26, 2026
c9f35a4
chore: adopt develop .gitattributes
eleshar Sep 26, 2026
4b90f95
Merge remote-tracking branch 'origin/develop' into HEAD
eleshar Sep 26, 2026
13becd3
fix: normalise Windows separators before choosing graph path quoting
eleshar Sep 26, 2026
7e6047f
fix: stop guessing the graph path shell entirely, covering cmd.exe
eleshar Sep 26, 2026
4893ba5
fix: treat a percent sign as hostile when quoting the graph path
eleshar Sep 26, 2026
7dd9f45
fix: treat a bang as hostile when quoting the graph path
eleshar Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,5 @@ node_modules/
# Prevent local Claude Code settings from being committed
.claude/settings*.json
.claude/.settings*.json
/graft/
/graphify-out/
6 changes: 6 additions & 0 deletions .ignore
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/
163 changes: 163 additions & 0 deletions .opencode/plugins/__tests__/graphify.test.js
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");
});
});
80 changes: 80 additions & 0 deletions .opencode/plugins/graphify.js
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);
},
};
};
88 changes: 88 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,3 +401,91 @@ All contributors, agents, and AI assistants must comply with these standards.*

*This page brought to you by the 🦄 Magic Automation Unicorns of LightSpeedWP.*
[Automation Docs](https://github.com/lightspeedwp/.github/tree/main/instructions)

## Code graphs (optional)

graft and graphify build local code graphs that help agents locate code. Both are
optional. Nothing in this repo depends on them, and without them agents should
search the code as usual.

- **Install** (once per machine): `npm install -g @nanonets/graft` and
`uv tool install "graphifyy[mcp]"`. With npm 12 or later, rerun the graft
install with the `--allow-scripts=...` list npm prints, or its native parsers
do not build and `graft` fails to start.
- **Build** (once per clone or worktree, then after large changes): `graft build`
and `graphify update .`. Both are deterministic and need no API key or model.
`graft/` and `graphify-out/` are gitignored, so each checkout builds its own.
graphify suggests committing `graphify-out/`, but its `graph.json` can exceed
GitHub's 100 MB file limit.
- **OpenCode**: `opencode.json` registers both MCP servers, and
`.opencode/plugins/graphify.js` adds a one-time reminder, per session, to
query the graph.
Without the tools or a built graph, the servers fail to start and OpenCode
carries on without them.
- **Other agents** (Claude Code, Codex, Cursor, Copilot) ignore those OpenCode
files. Follow the graft and graphify sections below only when `graft` or
`graphify` is installed and `graft/` or `graphify-out/graph.json` exists.
- The two sections below and `.opencode/plugins/graphify.js` come from
`graft init` and `graphify opencode install`, adjusted for this repo. Rerunning
either tool restores its upstream text, so reapply the adjustments: `--fixed`
on graft's literal `graft grep` example, the `graft/INDEX.md` entry-point
wording, the blank line after graphify's `Rules:` line (markdownlint), and
this repo's plugin, which appends its reminder to command output instead of
prefixing the command (the upstream prefix breaks under Windows PowerShell).

<!-- graft:start -->
## Graft — repo context graph

This repo is indexed in `graft/`: small linked markdown nodes that explain each
system and carry exact file:line spans, kept in sync with the code through git.

For ANY task here — understanding how something works, finding where code lives,
or scoping a change — get context from the graph before grepping or opening
source files. Re-ask freely (it's cheap) and reuse literal identifiers you
already have (symbol, error string, file name) as the query. New to this repo?
Run `graft map` first — a token-budgeted orientation (dir clusters, hubs,
hotspots), no LLM, no key.

- Run `graft ask "<your question>" --source` → ranked nodes with the relevant
code spans inlined (each hit's ≤8-line crux by default; `--full` for whole
definitions when the crux isn't enough). Match the tool to the task shape:
for understanding or editing, the top node IS the answer — cite its
`covers:` file:line spans and edit straight from `--source`. For
exhaustive tasks ("every occurrence / every caller of this pattern"), ranked
results are top-N, not complete — run `graft grep --fixed "<literal>"` instead
(exhaustive over indexed files, grouped by enclosing symbol), falling back
to raw `grep -rn` only for unindexed files.
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
- `graft skeleton <file>` → every definition's signature + span, ~10× cheaper
than reading the file; use it to skim an API surface.
- `graft callers <symbol>` gives precomputed, exact edges — who calls this.
Add `--direction out` for what it calls, or `--depth N` to walk
transitively for the full blast radius. For structural questions, skip
ranking and use this directly.
- Or browse: `graft/INDEX.md` is the entry point; follow its links. It is not
a complete list of nodes; for exhaustive searches use `graft grep --fixed`.
- Monorepos and folders of multiple repos rank fairly across sub-projects —
hits carry `[scope/]` labels naming which one they're from. Narrow with
`graft ask "<task>" --in <scope>/` once you know where you're working.

If a returned span is truncated ("+N more lines"), open the file at that exact
range before finalizing. Only open source files when a node genuinely lacks a
needed detail, and then at the exact file:line the node points to — never
re-read whole files.

After big code changes, refresh the graph with `graft build` (deterministic,
no API key, $0).
<!-- graft:end -->

## graphify

This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.

When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else.

Rules:

- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Code Graphs for OpenCode** — OpenCode can use locally built graft and graphify code graphs to locate code before searching files. The graphs are not committed. (#3569)
- **Shared Review and Phase Workflows** — Other repositories can now reuse the review-feedback check (warnings only by default) and the automatic phase-label workflow, which now really applies labels. (#3480)
- **Test Check on Every Pull Request** — Non-documentation pull requests run the full suite and fail only on new failures; eligible documentation-only pull requests report success without installing dependencies or running Jest. (#3479)
- **Workflow Reachability Guards** — A test now fails if a composite action has no caller, a local `uses:` does not resolve, or a workflow-shaped file sits outside `.github/workflows/`. (#3570)
Expand All @@ -52,6 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Graph Path No Longer Escaped for a Guessed Shell** — A plain graph path is passed through unquoted, so the suggested command is correct in any shell. ([#3567](https://github.com/lightspeedwp/.github/pull/3567))
- **Changelog Merges No Longer Conflict** — The changelog file is union-merged, so a git merge of two branches that each add an entry keeps both instead of conflicting. (#3574)
- **Issue Types Matched by Signal Strength** — A title naming a subject now classifies by that subject, so a test or compatibility update is no longer filed as a feature. ([#3577](https://github.com/lightspeedwp/.github/issues/3577))
- **Malformed Title Prefixes Repaired** — A title that already carries a recognised prefix but no space after the colon now has the space added, instead of gaining a second prefix. ([#3578](https://github.com/lightspeedwp/.github/issues/3578))
Expand Down
22 changes: 22 additions & 0 deletions opencode.json
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
}
}
}
Loading