diff --git a/.opencode/plugins/ecc-hooks.ts b/.opencode/plugins/ecc-hooks.ts index fa96b805..ff8628b5 100644 --- a/.opencode/plugins/ecc-hooks.ts +++ b/.opencode/plugins/ecc-hooks.ts @@ -43,6 +43,14 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({ return path.join(worktreePath, p) } + function hasProjectFile(relativePath: string): boolean { + try { + return fs.existsSync(resolvePath(relativePath)) + } catch { + return false + } + } + const pendingToolChanges = new Map() let writeCounter = 0 @@ -275,13 +283,8 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({ log("info", `[ECC] Session started - profile=${currentProfile}`) // Check for project-specific context files - try { - const hasClaudeMd = await $`test -f ${worktree}/CLAUDE.md && echo "yes"`.text() - if (hasClaudeMd.trim() === "yes") { - log("info", "[ECC] Found CLAUDE.md - loading project context") - } - } catch { - // No CLAUDE.md found + if (hasProjectFile("CLAUDE.md")) { + log("info", "[ECC] Found CLAUDE.md - loading project context") } }, @@ -400,7 +403,7 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({ ECC_PLUGIN: "true", ECC_HOOK_PROFILE: currentProfile, ECC_DISABLED_HOOKS: process.env.ECC_DISABLED_HOOKS || "", - PROJECT_ROOT: worktree || directory, + PROJECT_ROOT: worktreePath, } // Detect package manager @@ -411,12 +414,9 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({ "package-lock.json": "npm", } for (const [lockfile, pm] of Object.entries(lockfiles)) { - try { - await $`test -f ${worktree}/${lockfile}` + if (hasProjectFile(lockfile)) { env.PACKAGE_MANAGER = pm break - } catch { - // Not found, try next } } @@ -430,11 +430,8 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({ } const detected: string[] = [] for (const [file, lang] of Object.entries(langDetectors)) { - try { - await $`test -f ${worktree}/${file}` + if (hasProjectFile(file)) { detected.push(lang) - } catch { - // Not found } } if (detected.length > 0) { diff --git a/tests/opencode-plugin-hooks.test.js b/tests/opencode-plugin-hooks.test.js new file mode 100644 index 00000000..8b23cee8 --- /dev/null +++ b/tests/opencode-plugin-hooks.test.js @@ -0,0 +1,137 @@ +/** + * Tests for the published OpenCode hook plugin surface. + */ + +const assert = require("node:assert") +const fs = require("node:fs") +const os = require("node:os") +const path = require("node:path") +const { spawnSync } = require("node:child_process") +const { pathToFileURL } = require("node:url") + +function runTest(name, fn) { + return Promise.resolve() + .then(fn) + .then(() => { + console.log(` ✓ ${name}`) + return { passed: 1, failed: 0 } + }) + .catch((error) => { + console.log(` ✗ ${name}`) + console.error(` ${error.stack || error.message}`) + return { passed: 0, failed: 1 } + }) +} + +async function loadPlugin() { + const repoRoot = path.join(__dirname, "..") + const buildResult = spawnSync("node", [path.join(repoRoot, "scripts", "build-opencode.js")], { + cwd: repoRoot, + encoding: "utf8", + }) + assert.strictEqual(buildResult.status, 0, buildResult.stderr || buildResult.stdout) + const pluginUrl = pathToFileURL( + path.join(repoRoot, ".opencode", "dist", "plugins", "ecc-hooks.js") + ).href + return import(pluginUrl) +} + +function createClient() { + const logs = [] + return { + logs, + app: { + log: ({ body }) => { + logs.push(body) + return Promise.resolve() + }, + }, + } +} + +function createFailingShell() { + const calls = [] + const shell = (strings, ...values) => { + calls.push(String.raw({ raw: strings }, ...values)) + const error = new Error("OpenCode plugin file probes must not use shell commands") + return { + then: (_resolve, reject) => reject(error), + text: async () => { + throw error + }, + } + } + shell.calls = calls + return shell +} + +async function withTempProject(files, fn) { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ecc-opencode-plugin-")) + try { + for (const file of files) { + const filePath = path.join(projectDir, file) + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, "") + } + return await fn(projectDir) + } finally { + fs.rmSync(projectDir, { recursive: true, force: true }) + } +} + +async function main() { + console.log("\n=== Testing OpenCode plugin hooks ===\n") + + const { ECCHooksPlugin } = await loadPlugin() + const tests = [ + [ + "shell.env detects project markers without shelling out to test -f", + async () => withTempProject( + ["pnpm-lock.yaml", "tsconfig.json", "pyproject.toml"], + async (projectDir) => { + const client = createClient() + const $ = createFailingShell() + const hooks = await ECCHooksPlugin({ client, $, directory: projectDir }) + + const env = await hooks["shell.env"]() + + assert.deepStrictEqual($.calls, [], `Unexpected shell probes: ${$.calls.join(", ")}`) + assert.strictEqual(env.PROJECT_ROOT, projectDir) + assert.strictEqual(env.PACKAGE_MANAGER, "pnpm") + assert.strictEqual(env.DETECTED_LANGUAGES, "typescript,python") + assert.strictEqual(env.PRIMARY_LANGUAGE, "typescript") + } + ), + ], + [ + "session.created checks CLAUDE.md through fs instead of shell test", + async () => withTempProject(["CLAUDE.md"], async (projectDir) => { + const client = createClient() + const $ = createFailingShell() + const hooks = await ECCHooksPlugin({ client, $, directory: projectDir }) + + await hooks["session.created"]() + + assert.deepStrictEqual($.calls, [], `Unexpected shell probes: ${$.calls.join(", ")}`) + assert.ok( + client.logs.some((entry) => entry.message === "[ECC] Found CLAUDE.md - loading project context"), + "Expected CLAUDE.md detection log" + ) + }), + ], + ] + + let passed = 0 + let failed = 0 + for (const [name, fn] of tests) { + const result = await runTest(name, fn) + passed += result.passed + failed += result.failed + } + + console.log(`\nPassed: ${passed}`) + console.log(`Failed: ${failed}`) + process.exit(failed > 0 ? 1 : 0) +} + +main()