Files
msd-core/tests/opencode-plugin-adapter.test.cjs
Tom Boucher ff6b5cb024 feat(#1914): OpenCode native plugin integration (Option 1 file-copy) (#1923)
* feat(#1914): OpenCode native plugin integration (Option 1 file-copy)

Ship a native OpenCode plugin (.opencode/plugins/gsd-core.js) plus the
installer step that delivers it, so GSD's lifecycle hooks run on OpenCode.
OpenCode declares hooksSurface:'none', so GSD's hook scripts already ship to
<configDir>/hooks/ but nothing invokes them; the plugin bridges OpenCode's
event bus onto those scripts as subprocesses (prompt/read/worktree/workflow
guards, injection scanner, context monitor).

Distribution is Option 1 (file copy) per the #1914 triage decision: no
scripts.build rename, no prepare/prepack removal. package.json gains
main + .opencode in files[] for discovery.

Corrected against OpenCode's docs + loader source (not the reference branch):
- Auto-discovery globs {plugin,plugins}/*.{ts,js} — .cjs is never matched, so
  the installed adapter must be .js (config dir carries {"type":"commonjs"}).
- No opencode.json plugin-array patch — that array is npm-only; local files
  are auto-discovered.
- REPO_ROOT is resolved by walking up to the dir holding hooks/ + gsd-core/,
  correct for package tree, global install, and local install.
- Config-hook registration is gated (IS_PACKAGE_TREE) so it never
  double-registers commands/agents/skills already delivered by native copy.

Also fixes an incidental .gitignore drift: 8 ADR-1239 .cts-generated .cjs
artifacts were untracked-and-not-ignored (leak risk) — now ignored.

Tests: tests/opencode-plugin-adapter.test.cjs (14, pure helpers + real
subprocess bridge against stub hooks); golden-install-parity regenerated.
Green on Mac + Linux (gsd-test): 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1914): harden OpenCode plugin export shape + advisory accumulation (adversarial findings)

Address Codex adversarial-review findings:
- HIGH: export `{ id, server }` could trip OpenCode's loader
  (`for (entry of Object.values(mod)) getServerPlugin(entry)` throws on a
  non-extractable value). Make `id` NON-ENUMERABLE and assign module.exports
  from a variable (not a literal) so no string `id` is ever iterated — verified
  loader-safe under real import(pathToFileURL) (default + module.exports alias,
  both objects with .server; no bare id string).
- MEDIUM: sequential advisory hooks clobbered output.metadata._gsdAdvisory;
  now accumulate into an array.
- LOW: resolveRepoRoot fallback returned ".." while the comment said "../.." —
  aligned to "../.." (package-tree depth).

Tests: added a faithful loader-loop emulation (raw CJS + ESM namespace views),
advisory-accumulation, and a real bin/install.js copy→manifest→uninstall
integration test. golden regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#1914): add changeset fragment for OpenCode plugin integration (PR #1923)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1914): Windows — assert rewritten path with string include, not path-regex

The Read content-rewrite test built a RegExp from `path.join(root,'gsd-core')`.
On Windows the backslashes in the path are interpreted as regex escapes, so the
assertion never matched and `test (windows-latest, 24)` failed — even though the
adapter rewrote the path correctly. Replace the RegExp with a separator-agnostic
`String.includes` check (the repo's no-path-literal-in-assert concern).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:01:51 -04:00

343 lines
14 KiB
JavaScript

'use strict';
/**
* opencode-plugin-adapter.test.cjs — unit + integration coverage for the
* OpenCode native plugin adapter (.opencode/plugins/gsd-core.js, issue #1914).
*
* The adapter bridges OpenCode's plugin event bus onto GSD's existing hook
* scripts by spawning them as subprocesses. These tests exercise it WITHOUT a
* live OpenCode runtime by:
* 1. Unit-testing the pure translation helpers exposed on `_internals`.
* 2. Building a temp "install" layout (hooks/ with deterministic STUB hooks +
* gsd-core/ + plugins/gsd-core.js) and driving the plugin's returned
* handlers directly, asserting the real spawn bridge maps block/advisory/
* allow correctly and that REPO_ROOT resolves to the payload dir.
*
* Cross-platform note: filesystem-failure paths are not exercised here; the
* adapter's own error handling swallows spawn failures by design (a broken hook
* must never break a tool call), which the "missing hook" case covers.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('os');
const { cleanup } = require('./helpers.cjs');
const ADAPTER_SRC = path.join(__dirname, '..', '.opencode', 'plugins', 'gsd-core.js');
// ---------------------------------------------------------------------------
// Pure-helper unit tests (no filesystem / no spawn)
// ---------------------------------------------------------------------------
// Test-only helpers hang off the exported `server` function (see adapter export
// note) so they never appear as top-level exports the OpenCode loader iterates.
const _internals = require(ADAPTER_SRC).server._internals;
// Faithful emulation of OpenCode's loader: `getServerPlugin` accepts a bare
// function OR an object with a `.server` function, else the loader THROWS.
function getServerPlugin(entry) {
if (typeof entry === 'function') return entry;
if (entry && typeof entry === 'object' && typeof entry.server === 'function') return entry.server;
return null;
}
// Emulate the loader loop: `for (const entry of Object.values(mod)) { … throw if null }`.
function loaderExtract(mod) {
const servers = [];
for (const entry of Object.values(mod)) {
const s = getServerPlugin(entry);
if (!s) throw new TypeError('Plugin export is not a function');
servers.push(s);
}
return servers;
}
test('export survives the loader loop as raw CommonJS (require)', () => {
const mod = require(ADAPTER_SRC);
// `id` must be readable for identity/dedup...
assert.equal(mod.id, 'gsd-core');
// ...but NON-ENUMERABLE so it never lands in Object.values (would throw).
assert.ok(!Object.keys(mod).includes('id'), 'id must be non-enumerable');
const servers = loaderExtract(mod); // must not throw
assert.equal(servers.length, 1);
assert.equal(typeof servers[0], 'function');
// Internals hang off the server fn, never as a sibling top-level export.
assert.equal(mod._internals, undefined);
assert.equal(typeof mod.server._internals, 'object');
});
test('export survives the loader loop as an ESM/Bun namespace (default + synthesized)', () => {
const raw = require(ADAPTER_SRC);
// Worst-case ESM interop: default plus any lexer-synthesized named exports.
// Because module.exports is assigned from a variable, only `default` is
// realistically synthesized — but assert robustness even if `server` leaks.
for (const ns of [{ default: raw }, { default: raw, server: raw.server }]) {
assert.doesNotThrow(() => loaderExtract(ns), `loader threw on namespace ${Object.keys(ns)}`);
}
});
test('mapToolName maps OpenCode tool names to Claude names', () => {
assert.equal(_internals.mapToolName('read'), 'Read');
assert.equal(_internals.mapToolName('write'), 'Write');
assert.equal(_internals.mapToolName('edit'), 'Edit');
assert.equal(_internals.mapToolName('bash'), 'Bash');
assert.equal(_internals.mapToolName('apply_patch'), 'MultiEdit');
assert.equal(_internals.mapToolName('webfetch'), 'WebFetch');
// Unknown tools pass through unchanged; empty is empty.
assert.equal(_internals.mapToolName('mystery'), 'mystery');
assert.equal(_internals.mapToolName(''), '');
});
test('mapToolInput normalizes camelCase + snake_case arg keys', () => {
const out = _internals.mapToolInput({
filePath: '/a/b.txt',
oldString: 'x',
newString: 'y',
command: 'ls',
url: 'http://e',
});
assert.deepEqual(out, {
file_path: '/a/b.txt',
old_string: 'x',
new_string: 'y',
command: 'ls',
url: 'http://e',
});
// path/file_path aliases also resolve to file_path.
assert.equal(_internals.mapToolInput({ path: '/p' }).file_path, '/p');
assert.deepEqual(_internals.mapToolInput(null), {});
});
test('parseFrontmatter splits frontmatter and body', () => {
const { frontmatter, body } = _internals.parseFrontmatter(
'---\ndescription: A command\nmode: primary\n---\nHello body\n',
);
assert.equal(frontmatter.description, 'A command');
assert.equal(frontmatter.mode, 'primary');
assert.equal(body, 'Hello body\n');
// No frontmatter → whole content is body.
const plain = _internals.parseFrontmatter('just text');
assert.deepEqual(plain.frontmatter, {});
assert.equal(plain.body, 'just text');
});
test('handleHookResult: block decision throws with the hook reason', () => {
assert.throws(
() => _internals.handleHookResult(
{ stdout: JSON.stringify({ decision: 'block', reason: 'blocked!' }), exitCode: 0 },
),
/blocked!/,
);
});
test('handleHookResult: exit code 2 is a hard block even without JSON', () => {
assert.throws(
() => _internals.handleHookResult({ stdout: '', exitCode: 2 }),
/Blocked by GSD hook/,
);
});
test('handleHookResult: advisory sets metadata + does not throw', () => {
const output = {};
assert.doesNotThrow(() =>
_internals.handleHookResult(
{ stdout: JSON.stringify({ hookSpecificOutput: { additionalContext: 'heads up' } }), exitCode: 0 },
output,
),
);
assert.deepEqual(output.metadata._gsdAdvisory, ['heads up']);
});
test('handleHookResult: multiple advisories accumulate (no clobber)', () => {
// A single tool call runs several advisory hooks in sequence; each must be
// preserved, not overwritten by the next.
const output = {};
const advise = (ctx) =>
_internals.handleHookResult(
{ stdout: JSON.stringify({ hookSpecificOutput: { additionalContext: ctx } }), exitCode: 0 },
output,
);
advise('prompt-guard note');
advise('read-guard note');
advise('workflow-guard note');
assert.deepEqual(output.metadata._gsdAdvisory, [
'prompt-guard note',
'read-guard note',
'workflow-guard note',
]);
});
test('handleHookResult: silent allow is a no-op', () => {
const output = {};
assert.doesNotThrow(() => _internals.handleHookResult({ stdout: '', exitCode: 0 }, output));
assert.deepEqual(output, {});
});
// ---------------------------------------------------------------------------
// Integration: drive the plugin against a temp install layout with STUB hooks
// ---------------------------------------------------------------------------
// Build a self-contained payload dir: <root>/hooks/<stub>.js, <root>/gsd-core/,
// and <root>/plugins/gsd-core.js (a copy of the adapter). Returns the loaded
// plugin module for that layout. Each stub hook echoes a fixed JSON verdict.
function buildInstalledLayout(t, stubHooks) {
// realpath so `root` matches Node's realpath-resolved __dirname inside the
// copied plugin (macOS /var → /private/var symlink would otherwise diverge).
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-oc-plugin-')));
t.after(() => cleanup(root));
fs.mkdirSync(path.join(root, 'hooks'), { recursive: true });
fs.mkdirSync(path.join(root, 'gsd-core', 'workflows'), { recursive: true });
fs.mkdirSync(path.join(root, 'plugins'), { recursive: true });
for (const [name, jsBody] of Object.entries(stubHooks)) {
fs.writeFileSync(path.join(root, 'hooks', name), jsBody);
}
// Copy the real adapter into the payload's plugins/ dir so REPO_ROOT resolves
// to `root` via the walk-up probe (root has both hooks/ and gsd-core/).
const dest = path.join(root, 'plugins', 'gsd-core.js');
fs.copyFileSync(ADAPTER_SRC, dest);
// Fresh module instance (bypass require cache — each layout is distinct).
delete require.cache[require.resolve(dest)];
const mod = require(dest);
return { root, mod };
}
// A stub hook that reads stdin (ignored) and prints the given verdict JSON.
function stubHook(verdictJson, exitCode = 0) {
return `
let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{
process.stdout.write(${JSON.stringify(verdictJson)});
process.exit(${exitCode});
});
process.stdin.on('error',()=>process.exit(${exitCode}));
if(process.stdin.isTTY){process.stdout.write(${JSON.stringify(verdictJson)});process.exit(${exitCode});}
`;
}
test('REPO_ROOT resolves to the payload dir in an installed layout', (t) => {
const { root, mod } = buildInstalledLayout(t, {});
assert.equal(mod.server._internals.REPO_ROOT, fs.realpathSync(root));
// No source commands/gsd/ present → treated as installed (not package) tree.
assert.equal(mod.server._internals.IS_PACKAGE_TREE, false);
});
test('tool.execute.before: a blocking hook aborts the tool call (throws)', async (t) => {
const { mod } = buildInstalledLayout(t, {
'gsd-prompt-guard.js': stubHook(JSON.stringify({ decision: 'block', reason: 'injection detected' })),
});
const handlers = await mod.server({ directory: process.cwd() });
await assert.rejects(
() => handlers['tool.execute.before'](
{ tool: 'write' },
{ args: { filePath: '/proj/.planning/x.md', content: 'evil' } },
),
/injection detected/,
);
});
test('tool.execute.before: a silent hook allows the tool call (no throw)', async (t) => {
const { mod } = buildInstalledLayout(t, {
'gsd-prompt-guard.js': stubHook(''),
'gsd-read-guard.js': stubHook(''),
'gsd-worktree-path-guard.js': stubHook(''),
'gsd-workflow-guard.js': stubHook(''),
});
const handlers = await mod.server({ directory: process.cwd() });
await assert.doesNotReject(() =>
handlers['tool.execute.before'](
{ tool: 'write' },
{ args: { filePath: '/proj/notes.md', content: 'ok' } },
),
);
});
test('tool.execute.after: Read content rewriting maps ~/.claude/gsd-core paths', async (t) => {
const { root, mod } = buildInstalledLayout(t, {
'gsd-read-injection-scanner.js': stubHook(''),
});
const handlers = await mod.server({ directory: process.cwd() });
// A file under the payload's gsd-core/workflows is a GSD-managed file, so its
// Read output is rewritten (canonical ~/.claude/gsd-core/ → real payload path).
const managed = path.join(root, 'gsd-core', 'workflows', 'x.md');
const output = { output: 'see ~/.claude/gsd-core/references/foo.md for details' };
await handlers['tool.execute.after']({ tool: 'read', args: { filePath: managed } }, output);
// The adapter rewrites `~/.claude/gsd-core/` → `${GSD_CORE}/`, where GSD_CORE
// is `path.join(root, 'gsd-core')` (OS-native separators). Assert with a plain
// string include, NOT a RegExp built from a path — on Windows the backslashes
// in the path would be interpreted as regex escapes and never match.
const expected = path.join(root, 'gsd-core') + '/references/foo.md';
assert.ok(
output.output.includes(expected),
`expected rewritten path "${expected}" in output: ${output.output}`,
);
assert.ok(
!output.output.includes('~/.claude/gsd-core/'),
'canonical ~/.claude/gsd-core/ prefix must be rewritten away',
);
});
test('missing hook script is a silent allow (never breaks the tool call)', async (t) => {
// No hook stubs written at all → every runHook finds no file → silent allow.
const { mod } = buildInstalledLayout(t, {});
const handlers = await mod.server({ directory: process.cwd() });
await assert.doesNotReject(() =>
handlers['tool.execute.before'](
{ tool: 'edit' },
{ args: { filePath: '/proj/a.md', old_string: 'a', new_string: 'b' } },
),
);
});
test('config hook is a no-op in installed (non-package) layout', async (t) => {
const { mod } = buildInstalledLayout(t, {});
const handlers = await mod.server({ directory: process.cwd() });
const config = {};
await handlers.config(config);
// No commands/agents/skills registered — native file copy owns that surface.
assert.deepEqual(config, {});
});
// ---------------------------------------------------------------------------
// Installer integration: copy → manifest → uninstall (real bin/install.js)
// ---------------------------------------------------------------------------
test('installer copies plugin as .js, records it in the manifest, and removes it on uninstall', (t) => {
const { spawnSync } = require('node:child_process');
const installer = path.join(__dirname, '..', 'bin', 'install.js');
const cfg = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-oc-install-')));
t.after(() => cleanup(cfg));
const run = (args) =>
spawnSync(process.execPath, [installer, '--opencode', '--global', '--config-dir', cfg, ...args], {
encoding: 'utf8',
});
// Install
const install = run([]);
assert.equal(install.status, 0, `install failed: ${install.stderr}`);
const pluginPath = path.join(cfg, 'plugins', 'gsd-core.js');
assert.ok(fs.existsSync(pluginPath), 'plugin must land at plugins/gsd-core.js (matches OpenCode {plugin,plugins}/*.{ts,js} glob)');
assert.ok(!fs.existsSync(path.join(cfg, 'plugins', 'gsd-core.cjs')), 'must NOT ship a .cjs (never auto-discovered)');
// Manifest records the plugin for drift/uninstall accounting.
const manifest = JSON.parse(fs.readFileSync(path.join(cfg, 'gsd-file-manifest.json'), 'utf8'));
assert.ok(manifest.files['plugins/gsd-core.js'], 'manifest must track plugins/gsd-core.js');
// The installed plugin loads and resolves REPO_ROOT to the config dir.
delete require.cache[require.resolve(pluginPath)];
const installed = require(pluginPath);
assert.equal(installed.id, 'gsd-core');
assert.equal(installed.server._internals.REPO_ROOT, cfg);
assert.equal(installed.server._internals.IS_PACKAGE_TREE, false);
// Uninstall removes the plugin and prunes the (now empty) plugins/ dir.
const uninstall = run(['--uninstall']);
assert.equal(uninstall.status, 0, `uninstall failed: ${uninstall.stderr}`);
assert.ok(!fs.existsSync(pluginPath), 'plugin must be removed on uninstall');
assert.ok(!fs.existsSync(path.join(cfg, 'plugins')), 'empty plugins/ dir must be pruned');
});