opencode-plugin-zooplankton
Advanced tools
| /** | ||
| * Shared test fixtures and helpers used across per-module test files. | ||
| * | ||
| * Zero new dependencies — uses Node.js built-ins only. | ||
| */ | ||
| import fs from "fs"; | ||
| import os from "os"; | ||
| import path from "path"; | ||
| /** Create a unique temporary directory and return its path. */ | ||
| export const makeTmpDir = () => | ||
| fs.mkdtempSync(path.join(os.tmpdir(), "plugin-test-")); | ||
| /** Write a file inside a temp dir, creating intermediate directories as needed. */ | ||
| export const writeFixture = (dir, relativePath, content) => { | ||
| const full = path.join(dir, relativePath); | ||
| fs.mkdirSync(path.dirname(full), { recursive: true }); | ||
| fs.writeFileSync(full, content, "utf8"); | ||
| return full; | ||
| }; |
| /** | ||
| * Unit tests for .opencode/plugins/lib/agent-scaffolding.js (getAgentPermissions, generateAgentMd). | ||
| * | ||
| * Uses Node.js built-in test runner (node:test + node:assert). Zero new dependencies. | ||
| */ | ||
| import { describe, it } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import { | ||
| getAgentPermissions, | ||
| generateAgentMd, | ||
| getGuidePath, | ||
| } from "../../.opencode/plugins/lib.js"; | ||
| describe("getAgentPermissions", () => { | ||
| it("returns coder permissions for coreCoder", () => { | ||
| const perms = getAgentPermissions("coreCoder"); | ||
| assert.equal(perms.edit, "allow"); | ||
| assert.equal(perms.webfetch, "allow"); | ||
| assert.equal(perms.bash["*"], "allow"); | ||
| }); | ||
| it("returns coder permissions for juniorCoder", () => { | ||
| const perms = getAgentPermissions("juniorCoder"); | ||
| assert.equal(perms.edit, "allow"); | ||
| assert.equal(perms.webfetch, "allow"); | ||
| assert.equal(perms.bash["*"], "allow"); | ||
| }); | ||
| it("returns coder permissions for frontendCoder", () => { | ||
| const perms = getAgentPermissions("frontendCoder"); | ||
| assert.equal(perms.edit, "allow"); | ||
| assert.equal(perms.webfetch, "allow"); | ||
| }); | ||
| it("returns core reviewer permissions for coreReviewer", () => { | ||
| const perms = getAgentPermissions("coreReviewer"); | ||
| assert.equal(perms.edit, "deny"); | ||
| assert.equal(perms.webfetch, "deny"); | ||
| assert.equal(perms.bash["*"], "allow"); | ||
| }); | ||
| it("returns core reviewer permissions for frontendReviewer", () => { | ||
| const perms = getAgentPermissions("frontendReviewer"); | ||
| assert.equal(perms.edit, "deny"); | ||
| assert.equal(perms.bash["*"], "allow"); | ||
| }); | ||
| it("returns restricted reviewer permissions for reviewer", () => { | ||
| const perms = getAgentPermissions("reviewer"); | ||
| assert.equal(perms.edit, "deny"); | ||
| assert.equal(perms.webfetch, "deny"); | ||
| assert.equal(perms.bash["*"], "deny"); | ||
| assert.ok(!("gh api *" in perms.bash)); | ||
| assert.equal(perms.bash["gh pr diff *"], "allow"); | ||
| assert.equal(perms.bash["gh pr view *"], "allow"); | ||
| assert.equal(perms.bash["git diff *"], "allow"); | ||
| assert.equal(perms.bash["git log *"], "allow"); | ||
| assert.equal(perms.bash["git fetch *"], "allow"); | ||
| }); | ||
| it("returns restricted reviewer permissions for securityReviewer", () => { | ||
| const perms = getAgentPermissions("securityReviewer"); | ||
| assert.equal(perms.edit, "deny"); | ||
| assert.equal(perms.read, "allow"); | ||
| assert.equal(perms.webfetch, "allow"); | ||
| assert.equal(perms.bash["*"], "deny"); | ||
| assert.ok(!("gh api *" in perms.bash)); | ||
| assert.equal(perms.bash["gh pr diff *"], "allow"); | ||
| assert.equal(perms.bash["gh pr view *"], "allow"); | ||
| assert.equal(perms.bash["git diff *"], "allow"); | ||
| assert.equal(perms.bash["git log *"], "allow"); | ||
| assert.equal(perms.bash["git fetch *"], "allow"); | ||
| }); | ||
| it("returns explorer permissions for explorer", () => { | ||
| const perms = getAgentPermissions("explorer"); | ||
| assert.equal(perms.edit, "deny"); | ||
| assert.equal(perms.read, "allow"); | ||
| assert.equal(perms.webfetch, "allow"); | ||
| assert.equal(perms.bash["*"], "deny"); | ||
| // Explorer must NOT have any bash allowlist entries | ||
| assert.ok(!("gh api *" in perms.bash)); | ||
| assert.ok(!("git diff *" in perms.bash)); | ||
| }); | ||
| it("returns null for unknown role", () => { | ||
| const perms = getAgentPermissions("unknownRole"); | ||
| assert.equal(perms, null); | ||
| }); | ||
| it("reviewer permissions do not include gh pr checks *", () => { | ||
| // v2 removed gh pr checks * from reviewer permissions | ||
| const perms = getAgentPermissions("reviewer"); | ||
| assert.ok(!("gh pr checks *" in perms.bash)); | ||
| }); | ||
| it("all roles return bash permissions as an object", () => { | ||
| for (const role of ["coreCoder", "juniorCoder", "coreReviewer", "reviewer", "securityReviewer", "frontendCoder", "frontendReviewer", "explorer"]) { | ||
| const perms = getAgentPermissions(role); | ||
| assert.equal(typeof perms.bash, "object", `${role} should have bash as an object`); | ||
| assert.ok(perms.bash !== null, `${role} bash should not be null`); | ||
| } | ||
| }); | ||
| it("all roles include read: allow", () => { | ||
| for (const role of ["coreCoder", "juniorCoder", "coreReviewer", "reviewer", "securityReviewer", "frontendCoder", "frontendReviewer", "explorer"]) { | ||
| const perms = getAgentPermissions(role); | ||
| assert.equal(perms.read, "allow", `${role} should have read: allow`); | ||
| } | ||
| }); | ||
| // reviewer and securityReviewer bash allowlists contain only read-only inspection | ||
| // commands. gh api * was removed in PR-5b — the orchestrator owns all posting. | ||
| // Agents choose which subset to use based on project.platform. | ||
| it("reviewer and securityReviewer bash allowlist is the read-only subset (no gh api posting)", () => { | ||
| for (const role of ["reviewer", "securityReviewer"]) { | ||
| const perms = getAgentPermissions(role); | ||
| assert.equal(perms.bash["*"], "deny", `${role}: default must deny`); | ||
| assert.equal(perms.bash["gh pr diff *"], "allow", `${role}: gh pr diff * must be allow`); | ||
| assert.equal(perms.bash["gh pr view *"], "allow", `${role}: gh pr view * must be allow`); | ||
| assert.equal(perms.bash["git diff *"], "allow", `${role}: git diff * must be allow`); | ||
| assert.equal(perms.bash["git log *"], "allow", `${role}: git log * must be allow`); | ||
| assert.equal(perms.bash["git fetch *"], "allow", `${role}: git fetch * must be allow`); | ||
| assert.ok(!("gh api *" in perms.bash), `${role}: gh api * key must be absent`); | ||
| } | ||
| }); | ||
| }); | ||
| // SUITE: getGuidePath | ||
| describe("generateAgentMd", () => { | ||
| it("generates frontmatter with description", () => { | ||
| const md = generateAgentMd({ role: "coreCoder", description: "Test agent" }); | ||
| assert.ok(md.includes('description: "Test agent"')); | ||
| }); | ||
| it("includes model when provided", () => { | ||
| const md = generateAgentMd({ role: "coreCoder", model: "test-model", description: "Test" }); | ||
| assert.ok(md.includes('model: "test-model"')); | ||
| }); | ||
| it("omits model when not provided", () => { | ||
| const md = generateAgentMd({ role: "coreCoder", description: "Test" }); | ||
| assert.ok(!md.includes("model:")); | ||
| }); | ||
| it("includes steps when provided", () => { | ||
| const md = generateAgentMd({ role: "reviewer", steps: 80, description: "Test" }); | ||
| assert.ok(md.includes("steps: 80")); | ||
| }); | ||
| it("omits steps when not provided", () => { | ||
| const md = generateAgentMd({ role: "reviewer", description: "Test" }); | ||
| assert.ok(!md.includes("steps:")); | ||
| }); | ||
| it("includes correct permissions for the role", () => { | ||
| const md = generateAgentMd({ role: "reviewer", description: "Test" }); | ||
| assert.ok(md.includes("edit: deny")); | ||
| assert.ok(md.includes("read: allow")); | ||
| assert.ok(md.includes('"*": deny')); | ||
| assert.ok(!md.includes('"gh api *"'), 'reviewer agent MD must not contain gh api * allow'); | ||
| }); | ||
| it("emits a guide reference line with an absolute path to the platform-agnostic stub", () => { | ||
| const md = generateAgentMd({ role: "coreCoder", description: "Test" }); | ||
| const expectedPath = getGuidePath("coreCoder"); | ||
| assert.ok( | ||
| md.includes(`Read your full operating instructions from: ${expectedPath}`), | ||
| "body must contain the guide reference line with absolute path", | ||
| ); | ||
| assert.ok(!md.includes("-guide-github.md"), "must not reference a github-baked guide"); | ||
| assert.ok(!md.includes("-guide-local.md"), "must not reference a local-baked guide"); | ||
| }); | ||
| it("emits reference line for every role including juniorCoder", () => { | ||
| const roles = [ | ||
| "coreCoder", | ||
| "juniorCoder", | ||
| "coreReviewer", | ||
| "reviewer", | ||
| "securityReviewer", | ||
| "frontendCoder", | ||
| "frontendReviewer", | ||
| "explorer", | ||
| ]; | ||
| for (const role of roles) { | ||
| const md = generateAgentMd({ role, description: "T" }); | ||
| const expectedPath = getGuidePath(role); | ||
| assert.ok( | ||
| md.includes(`Read your full operating instructions from: ${expectedPath}`), | ||
| `${role}: body must reference the platform-agnostic guide stub`, | ||
| ); | ||
| } | ||
| }); | ||
| it("back-compat: non-empty guideContent overrides the default reference line", () => { | ||
| const md = generateAgentMd({ role: "coreCoder", description: "Test", guideContent: "# My Guide\nContent here" }); | ||
| assert.ok(md.includes("---\n\n# My Guide\nContent here")); | ||
| assert.ok(!md.includes("Read your full operating instructions from:")); | ||
| }); | ||
| it("empty guideContent falls through to the default reference line", () => { | ||
| const md = generateAgentMd({ role: "coreCoder", description: "Test", guideContent: "" }); | ||
| assert.ok(md.includes("Read your full operating instructions from:")); | ||
| }); | ||
| it("generates valid frontmatter delimiters", () => { | ||
| const md = generateAgentMd({ role: "coreCoder", description: "Test" }); | ||
| assert.ok(md.startsWith("---\n")); | ||
| assert.ok(md.includes("\n---\n")); | ||
| }); | ||
| it("throws for unknown role", () => { | ||
| assert.throws( | ||
| () => generateAgentMd({ role: "unknownRole", description: "Test" }), | ||
| /Unknown agent role: unknownRole/ | ||
| ); | ||
| }); | ||
| it("accepts valid numeric steps and writes integer to frontmatter", () => { | ||
| const md = generateAgentMd({ role: "coreCoder", description: "T", steps: 10 }); | ||
| assert.ok(md.includes("\nsteps: 10\n"), "steps should appear as integer"); | ||
| }); | ||
| it("accepts string-numeric steps and coerces to integer", () => { | ||
| const md = generateAgentMd({ role: "coreCoder", description: "T", steps: "5" }); | ||
| assert.ok(md.includes("\nsteps: 5\n"), "string steps should be coerced to integer"); | ||
| }); | ||
| it("throws for non-numeric steps (YAML injection guard)", () => { | ||
| assert.throws( | ||
| () => generateAgentMd({ role: "coreCoder", description: "T", steps: "10\npermission: allow" }), | ||
| /Invalid steps value/ | ||
| ); | ||
| }); | ||
| it("throws for zero or negative steps", () => { | ||
| assert.throws( | ||
| () => generateAgentMd({ role: "coreCoder", description: "T", steps: 0 }), | ||
| /Invalid steps value/ | ||
| ); | ||
| }); | ||
| it("throws for boolean steps (type guard)", () => { | ||
| assert.throws( | ||
| () => generateAgentMd({ role: "coreCoder", description: "T", steps: true }), | ||
| /Invalid steps value/ | ||
| ); | ||
| }); | ||
| it("omits steps from frontmatter when undefined", () => { | ||
| const md = generateAgentMd({ role: "coreCoder", description: "T" }); | ||
| assert.ok(!md.includes("steps:"), "steps should be absent when undefined"); | ||
| }); | ||
| }); | ||
| /** | ||
| * Unit tests for .opencode/plugins/lib/clustering.js (CLUSTER_ID_REGEX, normalizeSummary, dedupeFindings, fuzzy-cluster pipeline, assignClusterIds, computeFingerprint). | ||
| * | ||
| * Uses Node.js built-in test runner (node:test + node:assert). Zero new dependencies. | ||
| */ | ||
| import { describe, it, before, after } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| import { makeTmpDir, writeFixture } from "../helpers/fixtures.js"; | ||
| import { | ||
| CLUSTER_ID_REGEX, | ||
| normalizeSummary, | ||
| dedupeFindings, | ||
| assignClusterIds, | ||
| computeFingerprint, | ||
| } from "../../.opencode/plugins/lib.js"; | ||
| describe("CLUSTER_ID_REGEX", () => { | ||
| it("accepts R<n>-C<nn> (code)", () => { | ||
| assert.ok(CLUSTER_ID_REGEX.test("R1-C01")); | ||
| assert.ok(CLUSTER_ID_REGEX.test("R2-C15")); | ||
| assert.ok(CLUSTER_ID_REGEX.test("R9-C99")); | ||
| assert.ok(CLUSTER_ID_REGEX.test("R10-C123")); // 3-digit nn supported | ||
| }); | ||
| it("accepts S<n>-C<nn> (security)", () => { | ||
| assert.ok(CLUSTER_ID_REGEX.test("S1-C01")); | ||
| assert.ok(CLUSTER_ID_REGEX.test("S1-C42")); | ||
| }); | ||
| it("accepts P<n>-C<nn> (plan)", () => { | ||
| assert.ok(CLUSTER_ID_REGEX.test("P1-C01")); | ||
| assert.ok(CLUSTER_ID_REGEX.test("P3-C07")); | ||
| }); | ||
| it("rejects the legacy SR-C<nn> format (B3)", () => { | ||
| assert.ok(!CLUSTER_ID_REGEX.test("SR-C01")); | ||
| assert.ok(!CLUSTER_ID_REGEX.test("SR-C99")); | ||
| }); | ||
| it("rejects other malformed IDs", () => { | ||
| assert.ok(!CLUSTER_ID_REGEX.test("X1-C01")); // bad prefix | ||
| assert.ok(!CLUSTER_ID_REGEX.test("R0-C01")); // round starts at 1 | ||
| assert.ok(!CLUSTER_ID_REGEX.test("R1-C1")); // nn must be >=2 digits | ||
| assert.ok(!CLUSTER_ID_REGEX.test("R1C01")); // missing hyphen | ||
| assert.ok(!CLUSTER_ID_REGEX.test("R1-C0a")); // non-digit | ||
| assert.ok(!CLUSTER_ID_REGEX.test("")); | ||
| }); | ||
| }); | ||
| describe("normalizeSummary", () => { | ||
| it("lowercases, collapses whitespace, strips trailing period", () => { | ||
| assert.equal(normalizeSummary(" Hello World. "), "hello world"); | ||
| assert.equal(normalizeSummary("Missing\tnull\ncheck."), "missing null check"); | ||
| }); | ||
| it("handles non-string input gracefully", () => { | ||
| assert.equal(normalizeSummary(null), ""); | ||
| assert.equal(normalizeSummary(undefined), ""); | ||
| assert.equal(normalizeSummary(42), ""); | ||
| }); | ||
| }); | ||
| describe("dedupeFindings", () => { | ||
| const mk = (overrides) => ({ | ||
| path: "src/a.js", | ||
| line: 10, | ||
| summary: "Null check missing", | ||
| severity: "blocking", | ||
| anchor_scope: "line", | ||
| area: "error handling", | ||
| reviewer_name: "reviewer-glm", | ||
| reviewer_model: "glm-4", | ||
| reviewer_role: "reviewer", | ||
| ...overrides, | ||
| }); | ||
| it("merges findings sharing (path, line, normalized summary, severity)", () => { | ||
| const findings = [ | ||
| mk({ reviewer_name: "reviewer-glm" }), | ||
| mk({ reviewer_name: "reviewer-deepseek", summary: " NULL CHECK missing." }), // case + whitespace + period differ | ||
| ]; | ||
| const result = dedupeFindings(findings); | ||
| assert.equal(result.length, 1); | ||
| assert.equal(result[0].sources.length, 2); | ||
| assert.equal(result[0].sources[0].reviewer_name, "reviewer-glm"); | ||
| assert.equal(result[0].sources[1].reviewer_name, "reviewer-deepseek"); | ||
| }); | ||
| it("keeps the first occurrence's summary verbatim", () => { | ||
| const findings = [ | ||
| mk({ summary: "Null check missing" }), | ||
| mk({ summary: "null check MISSING." }), | ||
| ]; | ||
| const result = dedupeFindings(findings); | ||
| assert.equal(result[0].summary, "Null check missing"); | ||
| }); | ||
| it("does NOT merge when severity differs", () => { | ||
| const findings = [ | ||
| mk({ severity: "blocking" }), | ||
| mk({ severity: "advisory" }), | ||
| ]; | ||
| assert.equal(dedupeFindings(findings).length, 2); | ||
| }); | ||
| it("does NOT merge when path or line differs", () => { | ||
| const findings = [ | ||
| mk({ path: "src/a.js", line: 10 }), | ||
| mk({ path: "src/a.js", line: 11 }), | ||
| mk({ path: "src/b.js", line: 10 }), | ||
| ]; | ||
| assert.equal(dedupeFindings(findings).length, 3); | ||
| }); | ||
| it("strips reviewer_name/model/role from merged body; keeps them in sources[]", () => { | ||
| const result = dedupeFindings([mk()]); | ||
| assert.ok(!("reviewer_name" in result[0])); | ||
| assert.ok(!("reviewer_model" in result[0])); | ||
| assert.ok(!("reviewer_role" in result[0])); | ||
| assert.equal(result[0].sources[0].reviewer_name, "reviewer-glm"); | ||
| }); | ||
| it("returns [] for non-array input", () => { | ||
| assert.deepEqual(dedupeFindings(null), []); | ||
| assert.deepEqual(dedupeFindings(undefined), []); | ||
| assert.deepEqual(dedupeFindings("nope"), []); | ||
| }); | ||
| // B2 (round 2): null-line findings about the same file but different `area` | ||
| // must NOT merge. Previously the dedup key was (path, line, summary, severity) | ||
| // which over-merged file-scope findings whose only distinguishing field is | ||
| // `area`. | ||
| it("B2: two null-line findings on same file with different areas → two clusters", () => { | ||
| const findings = [ | ||
| mk({ | ||
| line: null, | ||
| anchor_scope: "file", | ||
| area: "logic", | ||
| summary: "Module has unclear responsibility", | ||
| reviewer_name: "reviewer-glm", | ||
| }), | ||
| mk({ | ||
| line: null, | ||
| anchor_scope: "file", | ||
| area: "tests", | ||
| summary: "Module has unclear responsibility", | ||
| reviewer_name: "reviewer-deepseek", | ||
| }), | ||
| ]; | ||
| const result = dedupeFindings(findings); | ||
| assert.equal(result.length, 2); | ||
| assert.equal(result[0].area, "logic"); | ||
| assert.equal(result[1].area, "tests"); | ||
| }); | ||
| it("B2: two null-line findings on same file with SAME area → still merge", () => { | ||
| const findings = [ | ||
| mk({ line: null, anchor_scope: "file", area: "logic", reviewer_name: "r1" }), | ||
| mk({ line: null, anchor_scope: "file", area: "logic", reviewer_name: "r2" }), | ||
| ]; | ||
| const result = dedupeFindings(findings); | ||
| assert.equal(result.length, 1); | ||
| assert.equal(result[0].sources.length, 2); | ||
| }); | ||
| it("B2: two branch-scope findings with different areas → two clusters", () => { | ||
| const findings = [ | ||
| mk({ path: null, line: null, anchor_scope: "branch", area: "docs", summary: "Readme stale" }), | ||
| mk({ path: null, line: null, anchor_scope: "branch", area: "architecture", summary: "Readme stale" }), | ||
| ]; | ||
| assert.equal(dedupeFindings(findings).length, 2); | ||
| }); | ||
| it("B2: line-specific findings still ignore `area` for dedup (line is precise enough)", () => { | ||
| // Regression guard: line-anchored findings at (path, line) with different | ||
| // `area` but same (summary, severity) SHOULD still merge, because a single | ||
| // line in a single file is specific enough that two reviewers flagging it | ||
| // with different area labels are reporting the same underlying issue. | ||
| const findings = [ | ||
| mk({ path: "src/a.js", line: 10, area: "logic", reviewer_name: "r1" }), | ||
| mk({ path: "src/a.js", line: 10, area: "types", reviewer_name: "r2" }), | ||
| ]; | ||
| const result = dedupeFindings(findings); | ||
| assert.equal(result.length, 1); | ||
| assert.equal(result[0].sources.length, 2); | ||
| }); | ||
| }); | ||
| // SUITE: fuzzy-cluster pipeline | ||
| // | ||
| // PR-5c adds a fuzzy pre-merge step (see review-protocol.md → Finding Consolidation) where the | ||
| // orchestrator normalizes near-duplicate summaries to a canonical form before | ||
| // the exact-string dedup step runs. The "paraphrase pair" tests below | ||
| // simulate that normalization step by applying `normalizeSummary` to each | ||
| // finding before `dedupeFindings`, exercising the end-to-end pipeline that | ||
| // the orchestrator is expected to perform: trivially-different summaries | ||
| // (case, whitespace, trailing period) should collapse into one cluster after | ||
| // normalization. The "distinct issue" tests then verify that genuinely | ||
| // different findings (different paths, areas, or severity) stay as separate | ||
| // clusters. | ||
| // | ||
| // Note: the SKILL.md draft of step 2 originally allowed a ±3-line tolerance | ||
| // on `line` for off-by-one diff context drift, but that clause was dropped | ||
| // in PR-5c review (R1-C03) because the downstream exact-dedup key requires | ||
| // an exact `(path, line)` match — a ±3 fuzz at the orchestrator level cannot | ||
| // merge the two findings without also rewriting `line`, which the | ||
| // fuzzy step is explicitly forbidden from doing. Same-`line` (or both | ||
| // `null`) is the only semantically-correct rule, so no ±3 test is needed. | ||
| describe("fuzzy-cluster pipeline", () => { | ||
| // Helper: simulate orchestrator step 2 (normalize summaries to canonical | ||
| // form) followed by step 3 (exact-dedup) and step 5 (cluster ID assign). | ||
| const runPipeline = (findings) => { | ||
| const normalized = findings.map((f) => ({ ...f, summary: normalizeSummary(f.summary) })); | ||
| const deduped = dedupeFindings(normalized); | ||
| return assignClusterIds(deduped, { round: 1 }); | ||
| }; | ||
| it("paraphrase pair 1: case + whitespace + trailing-period variants collapse after normalization", () => { | ||
| const base = { anchor_scope: "line", path: "src/foo.js", line: 10, severity: "blocking", area: "logic" }; | ||
| const findings = [ | ||
| { ...base, summary: "null dereference in bar()", reviewer_name: "r1", reviewer_model: "m1", reviewer_role: "reviewer" }, | ||
| { ...base, summary: " Null Dereference In Bar(). ", reviewer_name: "r2", reviewer_model: "m2", reviewer_role: "reviewer" }, | ||
| ]; | ||
| const result = runPipeline(findings); | ||
| assert.equal(result.length, 1, "trivially-different summaries must collapse to one cluster after normalization"); | ||
| assert.equal(result[0].sources.length, 2, "merged cluster must have two sources"); | ||
| assert.ok(CLUSTER_ID_REGEX.test(result[0].cluster_id), "cluster ID must match pattern"); | ||
| }); | ||
| it("paraphrase pair 2: branch-scope findings with capitalization + trailing-period diff collapse", () => { | ||
| const base = { anchor_scope: "branch", path: null, line: null, severity: "blocking", area: "security" }; | ||
| const findings = [ | ||
| { ...base, summary: "Missing input validation on user-controlled data", reviewer_name: "r1", reviewer_model: "m1", reviewer_role: "security" }, | ||
| { ...base, summary: "missing input validation on user-controlled data.", reviewer_name: "r2", reviewer_model: "m2", reviewer_role: "security" }, | ||
| ]; | ||
| const result = runPipeline(findings); | ||
| assert.equal(result.length, 1, "branch-scope paraphrase pair must collapse"); | ||
| assert.equal(result[0].sources.length, 2); | ||
| }); | ||
| it("paraphrase pair 3: line-anchored findings differing only in casing collapse", () => { | ||
| const base = { anchor_scope: "line", path: "src/loop.js", line: 42, severity: "advisory", area: "logic" }; | ||
| const findings = [ | ||
| { ...base, summary: "Off-by-one error in loop", reviewer_name: "r1", reviewer_model: "m1", reviewer_role: "reviewer" }, | ||
| { ...base, summary: "off-by-one error in loop", reviewer_name: "r2", reviewer_model: "m2", reviewer_role: "reviewer" }, | ||
| ]; | ||
| const result = runPipeline(findings); | ||
| assert.equal(result.length, 1); | ||
| assert.equal(result[0].sources.length, 2); | ||
| }); | ||
| it("distinct issue 1: different paths stay separate even with identical summary", () => { | ||
| const base = { anchor_scope: "line", line: 5, severity: "blocking", area: "logic", summary: "off-by-one error" }; | ||
| const findings = [ | ||
| { ...base, path: "src/a.js", reviewer_name: "r1", reviewer_model: "m1", reviewer_role: "reviewer" }, | ||
| { ...base, path: "src/b.js", reviewer_name: "r2", reviewer_model: "m2", reviewer_role: "reviewer" }, | ||
| ]; | ||
| const result = runPipeline(findings); | ||
| assert.equal(result.length, 2, "different paths must NOT collapse"); | ||
| }); | ||
| it("distinct issue 2: different areas on branch-scope finding stay separate", () => { | ||
| const base = { anchor_scope: "branch", path: null, line: null, severity: "advisory", summary: "missing test coverage" }; | ||
| const findings = [ | ||
| { ...base, area: "tests", reviewer_name: "r1", reviewer_model: "m1", reviewer_role: "reviewer" }, | ||
| { ...base, area: "logic", reviewer_name: "r2", reviewer_model: "m2", reviewer_role: "reviewer" }, | ||
| ]; | ||
| const result = runPipeline(findings); | ||
| assert.equal(result.length, 2, "different areas on branch-scope findings must NOT collapse"); | ||
| }); | ||
| it("distinct issue 3: different severity stays separate", () => { | ||
| const base = { anchor_scope: "line", path: "src/foo.js", line: 20, area: "tests", summary: "missing null check" }; | ||
| const findings = [ | ||
| { ...base, severity: "blocking", reviewer_name: "r1", reviewer_model: "m1", reviewer_role: "reviewer" }, | ||
| { ...base, severity: "advisory", reviewer_name: "r2", reviewer_model: "m2", reviewer_role: "reviewer" }, | ||
| ]; | ||
| const result = runPipeline(findings); | ||
| assert.equal(result.length, 2, "different severity on same path/line must NOT collapse"); | ||
| }); | ||
| it("fast-path: single finding passes through unchanged", () => { | ||
| const f = { anchor_scope: "line", path: "src/x.js", line: 1, severity: "blocking", area: "logic", summary: "only finding" }; | ||
| const result = runPipeline([f]); | ||
| assert.equal(result.length, 1); | ||
| assert.ok(CLUSTER_ID_REGEX.test(result[0].cluster_id)); | ||
| }); | ||
| it("cluster IDs are stable — same input produces same IDs on second run", () => { | ||
| const base = { anchor_scope: "line", path: "src/foo.js", line: 10, severity: "blocking", area: "logic" }; | ||
| const findings = [ | ||
| { ...base, summary: "Null dereference in bar()", reviewer_name: "r1", reviewer_model: "m1", reviewer_role: "reviewer" }, | ||
| { ...base, path: "src/bar.js", summary: "unrelated issue", reviewer_name: "r1", reviewer_model: "m1", reviewer_role: "reviewer" }, | ||
| ]; | ||
| const run1 = runPipeline([...findings]); | ||
| const run2 = runPipeline([...findings]); | ||
| assert.deepEqual( | ||
| run1.map((f) => f.cluster_id), | ||
| run2.map((f) => f.cluster_id), | ||
| "cluster IDs must be stable across runs", | ||
| ); | ||
| }); | ||
| }); | ||
| describe("assignClusterIds", () => { | ||
| const mk = (overrides) => ({ | ||
| path: "src/a.js", | ||
| line: 10, | ||
| summary: "x", | ||
| severity: "blocking", | ||
| anchor_scope: "line", | ||
| ...overrides, | ||
| }); | ||
| it("assigns R<round>-C<nn> for code phase", () => { | ||
| const out = assignClusterIds([mk(), mk({ line: 20 })], { round: 1, phase: "code" }); | ||
| assert.ok(/^R1-C01$/.test(out[0].cluster_id)); | ||
| assert.ok(/^R1-C02$/.test(out[1].cluster_id)); | ||
| }); | ||
| it("assigns S<round>-C<nn> for security phase (B3)", () => { | ||
| const out = assignClusterIds([mk(), mk({ line: 20 })], { round: 1, phase: "security" }); | ||
| assert.equal(out[0].cluster_id, "S1-C01"); | ||
| assert.equal(out[1].cluster_id, "S1-C02"); | ||
| }); | ||
| it("assigns P<round>-C<nn> for plan phase", () => { | ||
| const out = assignClusterIds([mk()], { round: 2, phase: "plan" }); | ||
| assert.equal(out[0].cluster_id, "P2-C01"); | ||
| }); | ||
| it("sorts deterministically by (path, line, severity, normalized summary)", () => { | ||
| const findings = [ | ||
| mk({ path: "src/b.js", line: 5, summary: "Z" }), | ||
| mk({ path: "src/a.js", line: 20, summary: "A" }), | ||
| mk({ path: "src/a.js", line: 10, summary: "B" }), | ||
| ]; | ||
| const out = assignClusterIds(findings, { round: 1 }); | ||
| assert.equal(out[0].path, "src/a.js"); assert.equal(out[0].line, 10); | ||
| assert.equal(out[1].path, "src/a.js"); assert.equal(out[1].line, 20); | ||
| assert.equal(out[2].path, "src/b.js"); assert.equal(out[2].line, 5); | ||
| }); | ||
| it("produces identical IDs regardless of input order (determinism)", () => { | ||
| const a = [mk({ path: "x", line: 1 }), mk({ path: "y", line: 2 })]; | ||
| const b = [mk({ path: "y", line: 2 }), mk({ path: "x", line: 1 })]; | ||
| const outA = assignClusterIds(a, { round: 1 }); | ||
| const outB = assignClusterIds(b, { round: 1 }); | ||
| assert.deepEqual(outA.map((f) => [f.path, f.cluster_id]), outB.map((f) => [f.path, f.cluster_id])); | ||
| }); | ||
| it("zero-pads to 2 digits minimum", () => { | ||
| const findings = Array.from({ length: 9 }, (_, i) => mk({ line: i + 1 })); | ||
| const out = assignClusterIds(findings, { round: 1 }); | ||
| assert.equal(out[0].cluster_id, "R1-C01"); | ||
| assert.equal(out[8].cluster_id, "R1-C09"); | ||
| }); | ||
| it("generated IDs all match CLUSTER_ID_REGEX", () => { | ||
| const out = assignClusterIds([mk(), mk({ line: 20 })], { round: 3, phase: "security" }); | ||
| for (const f of out) { | ||
| assert.ok(CLUSTER_ID_REGEX.test(f.cluster_id), `bad id: ${f.cluster_id}`); | ||
| } | ||
| }); | ||
| it("throws on invalid round or phase", () => { | ||
| assert.throws(() => assignClusterIds([], { round: 0 }), /positive integer/); | ||
| assert.throws(() => assignClusterIds([], { round: 1, phase: "nope" }), /code\|security\|plan/); | ||
| }); | ||
| it("returns [] for non-array input", () => { | ||
| assert.deepEqual(assignClusterIds(null, { round: 1 }), []); | ||
| }); | ||
| }); | ||
| describe("computeFingerprint", () => { | ||
| const mk = (overrides) => ({ | ||
| path: "src/a.js", | ||
| line: 10, | ||
| summary: "Null check missing", | ||
| severity: "blocking", | ||
| anchor_scope: "line", | ||
| area: "error handling", | ||
| ...overrides, | ||
| }); | ||
| it("is deterministic for identical inputs", () => { | ||
| const fp1 = computeFingerprint(1, 1, [mk()]); | ||
| const fp2 = computeFingerprint(1, 1, [mk()]); | ||
| assert.equal(fp1, fp2); | ||
| }); | ||
| it("is input-order-independent (stable after reordering)", () => { | ||
| const fpA = computeFingerprint(1, 1, [mk({ line: 10 }), mk({ line: 20 })]); | ||
| const fpB = computeFingerprint(1, 1, [mk({ line: 20 }), mk({ line: 10 })]); | ||
| assert.equal(fpA, fpB); | ||
| }); | ||
| it("ignores cluster_id, ephemeral_id, sources, detail (retries produce same fp)", () => { | ||
| const base = mk(); | ||
| const enriched = { | ||
| ...base, | ||
| cluster_id: "R1-C01", | ||
| ephemeral_id: "e-1", | ||
| sources: [{ reviewer_name: "x" }], | ||
| detail: "long explanation...", | ||
| }; | ||
| assert.equal(computeFingerprint(1, 1, [base]), computeFingerprint(1, 1, [enriched])); | ||
| }); | ||
| it("normalizes summary (case, whitespace, trailing period)", () => { | ||
| const a = mk({ summary: "Null check missing" }); | ||
| const b = mk({ summary: " null\tcheck MISSING. " }); | ||
| assert.equal(computeFingerprint(1, 1, [a]), computeFingerprint(1, 1, [b])); | ||
| }); | ||
| it("changes when findings change", () => { | ||
| const fp1 = computeFingerprint(1, 1, [mk()]); | ||
| const fp2 = computeFingerprint(1, 1, [mk({ summary: "Different issue" })]); | ||
| assert.notEqual(fp1, fp2); | ||
| }); | ||
| it("changes when pr or round changes", () => { | ||
| const fp1 = computeFingerprint(1, 1, [mk()]); | ||
| const fp2 = computeFingerprint(2, 1, [mk()]); | ||
| const fp3 = computeFingerprint(1, 2, [mk()]); | ||
| assert.notEqual(fp1, fp2); | ||
| assert.notEqual(fp1, fp3); | ||
| }); | ||
| it("returns a 64-char hex sha256 digest", () => { | ||
| const fp = computeFingerprint(1, 1, [mk()]); | ||
| assert.ok(/^[0-9a-f]{64}$/.test(fp)); | ||
| }); | ||
| it("handles empty findings list", () => { | ||
| const fp = computeFingerprint(1, 1, []); | ||
| assert.ok(/^[0-9a-f]{64}$/.test(fp)); | ||
| }); | ||
| it("throws on invalid pr or round", () => { | ||
| assert.throws(() => computeFingerprint(0, 1, []), /pr must be a positive integer/); | ||
| assert.throws(() => computeFingerprint(1, 0, []), /round must be a positive integer/); | ||
| }); | ||
| }); | ||
| /** | ||
| * Unit tests for .opencode/plugins/lib/file-readers.js (loadCommands, readGuide). | ||
| * | ||
| * Uses Node.js built-in test runner (node:test + node:assert). Zero new dependencies. | ||
| */ | ||
| import { describe, it, before, after } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| import { makeTmpDir, writeFixture } from "../helpers/fixtures.js"; | ||
| import { | ||
| loadCommands, | ||
| readGuide, | ||
| extractFrontmatter, | ||
| } from "../../.opencode/plugins/lib.js"; | ||
| describe("readGuide", () => { | ||
| it("returns empty string for missing guide file", () => { | ||
| assert.equal(readGuide("nonexistent-guide.md"), ""); | ||
| }); | ||
| it("returns trimmed content for existing guide files", () => { | ||
| const result = readGuide("core-coder-guide.md"); | ||
| assert.ok(typeof result === "string"); | ||
| assert.ok(result.length > 0); | ||
| assert.equal(result, result.trim()); | ||
| }); | ||
| }); | ||
| // SUITE: detectPlatform | ||
| describe("loadCommands", () => { | ||
| it("returns an object with command names as keys", () => { | ||
| const commands = loadCommands(); | ||
| assert.ok(typeof commands === "object" && commands !== null); | ||
| const keys = Object.keys(commands); | ||
| assert.ok(keys.includes("zooplankton-init")); | ||
| assert.ok(keys.includes("zooplankton-update")); | ||
| }); | ||
| it("each command has description and template fields", () => { | ||
| const commands = loadCommands(); | ||
| for (const [name, cmd] of Object.entries(commands)) { | ||
| assert.ok(typeof cmd.description === "string", `${name} missing description`); | ||
| assert.ok(typeof cmd.template === "string", `${name} missing template`); | ||
| assert.ok(cmd.template.length > 0, `${name} has empty template`); | ||
| } | ||
| }); | ||
| it("commands without agent/model frontmatter omit those fields", () => { | ||
| const commands = loadCommands(); | ||
| for (const [name, cmd] of Object.entries(commands)) { | ||
| assert.ok(!("agent" in cmd), `${name} should not have agent`); | ||
| assert.ok(!("model" in cmd), `${name} should not have model`); | ||
| } | ||
| }); | ||
| it("uses filename-without-extension as key", () => { | ||
| const commands = loadCommands(); | ||
| for (const key of Object.keys(commands)) { | ||
| assert.ok(!key.endsWith(".md"), `key ${key} should not have .md suffix`); | ||
| } | ||
| }); | ||
| it("extractFrontmatter correctly parses agent/model fields for command loading", () => { | ||
| const content = | ||
| '---\ndescription: Test command\nagent: "my-agent"\nmodel: "my-model"\n---\nTemplate body'; | ||
| const { meta, body } = extractFrontmatter(content); | ||
| assert.equal(meta.description, "Test command"); | ||
| assert.equal(meta.agent, "my-agent"); | ||
| assert.equal(meta.model, "my-model"); | ||
| assert.equal(body, "Template body"); | ||
| const cmd = { description: meta.description, template: body.trim() }; | ||
| if (meta.agent) cmd.agent = meta.agent; | ||
| if (meta.model) cmd.model = meta.model; | ||
| assert.equal(cmd.agent, "my-agent"); | ||
| assert.equal(cmd.model, "my-model"); | ||
| }); | ||
| }); | ||
| // SUITE: readZooplanktonJson |
| /** | ||
| * Unit tests for .opencode/plugins/lib/frontmatter.js (extractFrontmatter, parsePlanScope). | ||
| * | ||
| * Uses Node.js built-in test runner (node:test + node:assert). Zero new dependencies. | ||
| */ | ||
| import { describe, it } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import { extractFrontmatter, parsePlanScope } from "../../.opencode/plugins/lib.js"; | ||
| describe("extractFrontmatter", () => { | ||
| it("returns original content when no frontmatter present", () => { | ||
| const input = "Hello world\nNo frontmatter here."; | ||
| const { meta, body } = extractFrontmatter(input); | ||
| assert.deepStrictEqual(meta, {}); | ||
| assert.equal(body, input); | ||
| }); | ||
| it("parses valid frontmatter into meta keys and body", () => { | ||
| const input = "---\ntitle: My Title\nauthor: Jane\n---\nBody content here."; | ||
| const { meta, body } = extractFrontmatter(input); | ||
| assert.equal(meta.title, "My Title"); | ||
| assert.equal(meta.author, "Jane"); | ||
| assert.equal(body, "Body content here."); | ||
| }); | ||
| it("parses frontmatter with CRLF line endings correctly", () => { | ||
| const input = "---\r\ntitle: My Title\r\nauthor: Jane\r\n---\r\nBody content here."; | ||
| const { meta, body } = extractFrontmatter(input); | ||
| assert.equal(meta.title, "My Title"); | ||
| assert.equal(meta.author, "Jane"); | ||
| assert.equal(body, "Body content here."); | ||
| }); | ||
| it("strips surrounding quotes from values", () => { | ||
| const input = '---\nname: "quoted value"\nother: \'single quoted\'\n---\nBody'; | ||
| const { meta } = extractFrontmatter(input); | ||
| assert.equal(meta.name, "quoted value"); | ||
| assert.equal(meta.other, "single quoted"); | ||
| }); | ||
| it("splits only on first colon (colon in value)", () => { | ||
| const input = "---\ndescription: key: value pair\n---\nBody"; | ||
| const { meta } = extractFrontmatter(input); | ||
| assert.equal(meta.description, "key: value pair"); | ||
| }); | ||
| it("returns empty body when nothing follows frontmatter", () => { | ||
| const input = "---\nkey: val\n---\n"; | ||
| const { meta, body } = extractFrontmatter(input); | ||
| assert.equal(meta.key, "val"); | ||
| assert.equal(body, ""); | ||
| }); | ||
| it("returns empty meta for empty frontmatter block", () => { | ||
| const input = "---\n\n---\nSome body"; | ||
| const { meta, body } = extractFrontmatter(input); | ||
| assert.deepStrictEqual(meta, {}); | ||
| assert.equal(body, "Some body"); | ||
| }); | ||
| }); | ||
| // SUITE: parsePlanScope | ||
| // | ||
| // Validates `parsePlanScope` semantics introduced in PR-4 of the | ||
| // unified-orchestrate-flow plan: a plan file's `plan_scope:` frontmatter | ||
| // field must be one of `simple|medium|large`. The legacy `scope:` alias was | ||
| // removed in PR-5a — `scope:` is now treated as an unknown field. | ||
| describe("parsePlanScope", () => { | ||
| it("returns the value when plan_scope is a valid scope (no warning)", () => { | ||
| for (const value of ["simple", "medium", "large"]) { | ||
| const result = parsePlanScope(`---\nplan_scope: ${value}\n---\nbody`); | ||
| assert.equal(result.scope, value); | ||
| assert.ok(!("warning" in result), `valid value "${value}" should not emit warning`); | ||
| } | ||
| }); | ||
| it("defaults to 'medium' (no warning) when frontmatter is absent", () => { | ||
| const result = parsePlanScope("just a plan body, no frontmatter"); | ||
| assert.equal(result.scope, "medium"); | ||
| assert.ok(!("warning" in result)); | ||
| }); | ||
| it("defaults to 'medium' with a warning when plan_scope value is invalid", () => { | ||
| const result = parsePlanScope("---\nplan_scope: huge\n---\nbody"); | ||
| assert.equal(result.scope, "medium"); | ||
| assert.ok(typeof result.warning === "string" && result.warning.length > 0); | ||
| assert.match(result.warning, /huge/); | ||
| assert.match(result.warning, /simple\|medium\|large/); | ||
| }); | ||
| it("legacy 'scope:' field is treated as unknown — defaults to 'medium' with missing-plan_scope warning (not a deprecation warning)", () => { | ||
| const result = parsePlanScope("---\nscope: large\n---\nbody"); | ||
| assert.equal(result.scope, "medium"); | ||
| assert.ok(typeof result.warning === "string"); | ||
| assert.match(result.warning, /missing or invalid `plan_scope:`/); | ||
| }); | ||
| it("plan_scope wins when both plan_scope and legacy scope are present (no warning)", () => { | ||
| const result = parsePlanScope( | ||
| "---\nplan_scope: simple\nscope: large\n---\nbody" | ||
| ); | ||
| assert.equal(result.scope, "simple"); | ||
| assert.ok(!("warning" in result)); | ||
| }); | ||
| it("defaults to 'medium' with a warning when input is null", () => { | ||
| const result = parsePlanScope(null); | ||
| assert.equal(result.scope, "medium"); | ||
| assert.ok(typeof result.warning === "string" && result.warning.length > 0); | ||
| assert.match(result.warning, /null\/undefined/); | ||
| }); | ||
| it("defaults to 'medium' with a warning when input is undefined", () => { | ||
| const result = parsePlanScope(undefined); | ||
| assert.equal(result.scope, "medium"); | ||
| assert.ok(typeof result.warning === "string" && result.warning.length > 0); | ||
| assert.match(result.warning, /null\/undefined/); | ||
| }); | ||
| }); | ||
| // SUITE: readGuide |
| /** | ||
| * Unit tests for .opencode/plugins/lib/github-posting.js (GitHub posting helpers). | ||
| * | ||
| * Uses Node.js built-in test runner (node:test + node:assert). Zero new dependencies. | ||
| */ | ||
| import { describe, it } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import { | ||
| createReviewMarker, | ||
| hasReviewMarker, | ||
| makePostingStateEntry, | ||
| upsertPostingState, | ||
| decidePostingAction, | ||
| renderConsolidatedReviewBody, | ||
| buildInlineCommentAnchors, | ||
| planReviewPostRetry, | ||
| } from "../../.opencode/plugins/lib.js"; | ||
| describe("GitHub posting helpers", () => { | ||
| const fpA = "a".repeat(64); | ||
| const fpB = "b".repeat(64); | ||
| const findings = [ | ||
| { | ||
| cluster_id: "R1-C01", | ||
| severity: "blocking", | ||
| anchor_scope: "line", | ||
| path: "src/a.js", | ||
| line: 10, | ||
| area: "logic", | ||
| summary: "Null check missing", | ||
| detail: "Value may be null.", | ||
| reviewer_model: "model-a", | ||
| }, | ||
| { | ||
| cluster_id: "R1-C02", | ||
| severity: "advisory", | ||
| anchor_scope: "branch", | ||
| path: null, | ||
| line: null, | ||
| area: "tests", | ||
| summary: "Add regression test", | ||
| detail: null, | ||
| reviewer_model: "model-b", | ||
| }, | ||
| ]; | ||
| it("creates code and security markers", () => { | ||
| assert.equal( | ||
| createReviewMarker({ pr: 38, round: 2, phase: "code", fingerprint: fpA }), | ||
| `<!-- zooplankton-orchestrate: pr=38 round=2 fingerprint=${fpA} -->`, | ||
| ); | ||
| assert.equal( | ||
| createReviewMarker({ pr: 38, phase: "security", fingerprint: fpA }), | ||
| `<!-- zooplankton-orchestrate: pr=38 phase=security fingerprint=${fpA} -->`, | ||
| ); | ||
| }); | ||
| it("rejects invalid review marker inputs", () => { | ||
| assert.throws(() => createReviewMarker({ pr: 0, round: 1, fingerprint: fpA }), /positive integer/); | ||
| assert.throws(() => createReviewMarker({ pr: 1, round: 1, fingerprint: "short" }), /64-character lowercase hex/); | ||
| assert.throws(() => createReviewMarker({ pr: 1, round: 1, fingerprint: "A".repeat(64) }), /64-character lowercase hex/); | ||
| assert.throws(() => createReviewMarker({ pr: 1, round: 0, fingerprint: fpA }), /positive integer/); | ||
| assert.throws(() => createReviewMarker({ pr: 1, round: 1, phase: "deploy", fingerprint: fpA }), /code\|security/); | ||
| }); | ||
| it("detects markers in existing PR bodies", () => { | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| assert.equal(hasReviewMarker([{ body: `body\n${marker}`, author: "zoe-bot" }], marker, ["zoe-bot"]), true); | ||
| assert.equal(hasReviewMarker([{ body: marker, author: { login: "zoe-bot" } }], marker, ["zoe-bot"]), true); | ||
| assert.equal(hasReviewMarker([{ body: marker, user: { login: "zoe-bot" } }], marker, ["zoe-bot"]), true); | ||
| assert.equal(hasReviewMarker([{ body: marker, author: "attacker" }], marker, ["zoe-bot"]), false); | ||
| assert.equal(hasReviewMarker([{ body: `quoted ${marker}`, author: "zoe-bot" }], marker, ["zoe-bot"]), false); | ||
| assert.equal(hasReviewMarker(["hello"], marker, ["zoe-bot"]), false); | ||
| }); | ||
| it("upserts posting state by pr/round/phase", () => { | ||
| const first = makePostingStateEntry({ pr: 1, round: 1, phase: "code", fingerprint: fpA, status: "IN_PROGRESS", timestamp: "t1" }); | ||
| const second = makePostingStateEntry({ pr: 1, round: 1, phase: "code", fingerprint: fpA, status: "COMPLETE", timestamp: "t2" }); | ||
| const state = upsertPostingState([first], second); | ||
| assert.equal(state.length, 1); | ||
| assert.equal(state[0].status, "COMPLETE"); | ||
| assert.equal(state[0].timestamp, "t2"); | ||
| }); | ||
| it("skips posting when exact marker already exists even without local state", () => { | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| const decision = decidePostingAction({ state: [], existingBodies: [{ body: marker, author: "zoe-bot" }], trustedAuthors: ["zoe-bot"], pr: 1, round: 1, fingerprint: fpA }); | ||
| assert.equal(decision.action, "skip"); | ||
| assert.equal(decision.reason, "marker-exists"); | ||
| assert.equal(decision.nextState[0].status, "COMPLETE"); | ||
| }); | ||
| it("posts instead of trusting markers from untrusted authors", () => { | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| const decision = decidePostingAction({ state: [], existingBodies: [{ body: marker, author: "attacker" }], trustedAuthors: ["zoe-bot"], pr: 1, round: 1, fingerprint: fpA }); | ||
| assert.equal(decision.action, "post"); | ||
| assert.equal(decision.reason, "new-post"); | ||
| }); | ||
| it("throws when existing bodies are provided without trusted authors", () => { | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| assert.throws( | ||
| () => decidePostingAction({ state: [], existingBodies: [{ body: marker, author: "zoe-bot" }], pr: 1, round: 1, fingerprint: fpA }), | ||
| /trustedAuthors must be a non-empty array/, | ||
| ); | ||
| }); | ||
| it("retries crashed post only when local IN_PROGRESS exists and PR marker is absent", () => { | ||
| const state = [makePostingStateEntry({ pr: 1, round: 1, phase: "code", fingerprint: fpA, status: "IN_PROGRESS", timestamp: "t1" })]; | ||
| const decision = decidePostingAction({ state, existingBodies: [], pr: 1, round: 1, fingerprint: fpA }); | ||
| assert.equal(decision.action, "post"); | ||
| assert.equal(decision.reason, "retry-crashed-post"); | ||
| }); | ||
| it("posts with supersedes when completed fingerprint changes", () => { | ||
| const state = [makePostingStateEntry({ pr: 1, round: 1, phase: "code", fingerprint: fpA, status: "COMPLETE", timestamp: "t1" })]; | ||
| const decision = decidePostingAction({ state, existingBodies: [], pr: 1, round: 1, fingerprint: fpB }); | ||
| assert.equal(decision.action, "post"); | ||
| assert.equal(decision.reason, "changed-fingerprint"); | ||
| assert.equal(decision.supersedes, fpA); | ||
| }); | ||
| it("normalizes security round values to the single security state slot", () => { | ||
| const decision = decidePostingAction({ state: [], existingBodies: [], pr: 1, round: 2, phase: "security", fingerprint: fpA }); | ||
| assert.equal(decision.action, "post"); | ||
| assert.equal(decision.nextState[0].round, 1); | ||
| }); | ||
| it("throws when trusted authors contain no non-empty strings", () => { | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| assert.throws( | ||
| () => decidePostingAction({ state: [], existingBodies: [{ body: marker, author: "zoe-bot" }], trustedAuthors: [""], pr: 1, round: 1, fingerprint: fpA }), | ||
| /trustedAuthors must be a non-empty array/, | ||
| ); | ||
| }); | ||
| const reviewers = [ | ||
| { name: "core-reviewer", model: "claude-sonnet-4.6" }, | ||
| { name: "reviewer", model: "glm-4.6" }, | ||
| ]; | ||
| it("body starts with marker and contains total/severity counts, reviewer attribution, and pointer", () => { | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| const body = renderConsolidatedReviewBody(findings, reviewers, { marker }); | ||
| // Marker is the first line. | ||
| assert.equal(body.split("\n")[0], marker); | ||
| // Total + severity rows. | ||
| assert.match(body, /^Total findings: 2$/m); | ||
| assert.match(body, /^- blocking: 1$/m); | ||
| assert.match(body, /^- advisory: 1$/m); | ||
| // Reviewer attribution. | ||
| assert.match(body, /^Reviewers: core-reviewer \(claude-sonnet-4\.6\), reviewer \(glm-4\.6\)$/m); | ||
| // Pointer line. | ||
| assert.match(body, /See inline comments below for line-by-line details\. File-\/branch-scope findings are listed in the next section\./); | ||
| }); | ||
| it("renders supersedes line and skips zero-severity rows", () => { | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| const onlyAdvisory = [findings[1]]; | ||
| const body = renderConsolidatedReviewBody(onlyAdvisory, reviewers, { marker, supersedes: fpB }); | ||
| assert.match(body, /Supersedes earlier review \(fingerprint bbbbbbbb\); see comment timestamps for ordering\./); | ||
| assert.match(body, /^- advisory: 1$/m); | ||
| assert.doesNotMatch(body, /^- blocking:/m); | ||
| }); | ||
| it("body excludes line-anchored prose by default but renders file/branch-scope findings", () => { | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| const body = renderConsolidatedReviewBody(findings, reviewers, { marker }); | ||
| // Line-anchored detail is NOT in the body. | ||
| assert.doesNotMatch(body, /Value may be null/); | ||
| // The line-anchored finding's *summary* + cluster ID are also absent | ||
| // from the body (they live in inline comments only). | ||
| assert.doesNotMatch(body, /R1-C01/); | ||
| // File/branch-scope section is present with the advisory finding. | ||
| assert.match(body, /### File-\/branch-scope findings/); | ||
| assert.match(body, /\[R1-C02\] \[advisory\] Add regression test — branch-scope/); | ||
| }); | ||
| it("Tier 3 fallback (includeLineAnchored: true) appends line-anchored findings in full prose", () => { | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| const body = renderConsolidatedReviewBody(findings, reviewers, { marker, includeLineAnchored: true }); | ||
| assert.match(body, /### Line-anchored findings \(body-only fallback\)/); | ||
| assert.match(body, /\[R1-C01\] \[blocking\] Null check missing — src\/a\.js:10/); | ||
| // Detail prose is included for Tier 3. | ||
| assert.match(body, /Value may be null\./); | ||
| // File/branch section is also still present. | ||
| assert.match(body, /\[R1-C02\] \[advisory\] Add regression test — branch-scope/); | ||
| }); | ||
| it("renders a compact body with no findings (no severity rows, no sections, no pointer)", () => { | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| const body = renderConsolidatedReviewBody([], [], { marker }); | ||
| assert.match(body, /^Total findings: 0$/m); | ||
| assert.doesNotMatch(body, /^- blocking:/m); | ||
| assert.doesNotMatch(body, /^- advisory:/m); | ||
| assert.doesNotMatch(body, /Reviewers:/); | ||
| assert.doesNotMatch(body, /### File-\/branch-scope findings/); | ||
| assert.doesNotMatch(body, /Blocking issues span/); | ||
| assert.doesNotMatch(body, /See inline comments below/); | ||
| }); | ||
| it("falls back to empty arrays when reviewers is null/undefined (no Reviewers line emitted)", () => { | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| const bodyNull = renderConsolidatedReviewBody([], null, { marker }); | ||
| const bodyUndef = renderConsolidatedReviewBody([], undefined, { marker }); | ||
| assert.doesNotMatch(bodyNull, /Reviewers:/); | ||
| assert.doesNotMatch(bodyUndef, /Reviewers:/); | ||
| }); | ||
| it("throws TypeError when marker is missing/invalid", () => { | ||
| assert.throws(() => renderConsolidatedReviewBody([], [], {}), /marker.*required/); | ||
| assert.throws(() => renderConsolidatedReviewBody([], [], { marker: "" }), /marker.*required/); | ||
| assert.throws(() => renderConsolidatedReviewBody([], [], { marker: 123 }), /marker.*required/); | ||
| assert.throws(() => renderConsolidatedReviewBody([], []), /marker.*required/); | ||
| }); | ||
| it("throws TypeError when marker is non-empty but malformed (R2-C09)", () => { | ||
| // Hand-rolled markers that do not match the createReviewMarker shape are | ||
| // rejected so idempotency (line-exact match in hasReviewMarker) cannot be | ||
| // silently broken by typos or partial markers. | ||
| const badMarkers = [ | ||
| "not a marker", | ||
| "<!-- zooplankton-orchestrate: pr=1 -->", // missing round/fingerprint | ||
| "<!-- zooplankton-orchestrate: pr=1 round=1 fingerprint=short -->", // bad fingerprint | ||
| `<!-- zooplankton-orchestrate: pr=1 round=1 fingerprint=${"A".repeat(64)} -->`, // upper-case hex | ||
| `<!-- zooplankton-orchestrate: pr=abc round=1 fingerprint=${fpA} -->`, // non-numeric pr | ||
| `<!-- zooplankton-orchestrate: pr=1 round=1 phase=security fingerprint=${fpA} -->`, // mixed code+security | ||
| ` <!-- zooplankton-orchestrate: pr=1 round=1 fingerprint=${fpA} -->`, // leading whitespace | ||
| ]; | ||
| for (const marker of badMarkers) { | ||
| assert.throws( | ||
| () => renderConsolidatedReviewBody([], [], { marker }), | ||
| /does not match the expected format/, | ||
| `expected reject for marker: ${marker}`, | ||
| ); | ||
| } | ||
| }); | ||
| it("treats findings as empty when given a primitive (string/number/boolean) (R2-C07)", () => { | ||
| // Array.isArray(findings) guard is the only contract — primitive inputs | ||
| // collapse to zero findings, render a valid (empty) body, and never throw. | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| for (const primitive of ["", "garbage", 42, true, false, null, undefined]) { | ||
| const body = renderConsolidatedReviewBody(primitive, [], { marker }); | ||
| assert.equal(body.split("\n")[0], marker); | ||
| assert.match(body, /^Total findings: 0$/m); | ||
| assert.doesNotMatch(body, /^- blocking:/m); | ||
| assert.doesNotMatch(body, /### File-\/branch-scope findings/); | ||
| } | ||
| }); | ||
| it("tolerates malformed reviewers entries (null, empty-string name, non-object) (R2-C03)", () => { | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| const malformed = [ | ||
| null, // null entry | ||
| undefined, // undefined entry | ||
| "raw-string", // non-object | ||
| 42, // number | ||
| { name: "", model: "m" }, // empty-string name | ||
| { name: "valid", model: "" }, // empty model (no parenthesis) | ||
| { name: "ok", model: "claude-sonnet-4.6" }, // sentinel valid entry | ||
| ]; | ||
| const body = renderConsolidatedReviewBody([], malformed, { marker }); | ||
| // The line is emitted because reviewerList.length > 0; missing/empty names | ||
| // fall back to the literal "reviewer" placeholder. | ||
| assert.match(body, /^Reviewers: .+$/m); | ||
| const reviewersLine = body.split("\n").find((l) => l.startsWith("Reviewers:")); | ||
| assert.ok(reviewersLine.includes("reviewer"), "fallback name appears for malformed entries"); | ||
| assert.ok(reviewersLine.includes("ok (claude-sonnet-4.6)"), "valid entry rendered with model"); | ||
| assert.ok(reviewersLine.includes("valid"), "entry with empty model rendered as bare name"); | ||
| assert.ok(!reviewersLine.includes("()"), "no empty parens for blank model"); | ||
| }); | ||
| it("computeWhyItMatters: empty-string area falls back to path; absent-area dedupes by path (R2-C02)", () => { | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| // (a) area: "" (empty string) is falsy → falls back to path. | ||
| const emptyArea = [{ | ||
| cluster_id: "R1-C30", severity: "blocking", anchor_scope: "file", | ||
| path: "src/empty.js", line: null, area: "", summary: "x", detail: null, reviewer_model: "m", | ||
| }]; | ||
| const bodyEmpty = renderConsolidatedReviewBody(emptyArea, reviewers, { marker }); | ||
| assert.match(bodyEmpty, /^Blocking issues span: src\/empty\.js\. See inline comments for details\.$/m); | ||
| // (b) absent-area + same path on multiple findings → dedup by path, | ||
| // emitting the path key only once. | ||
| const absentAreaSamePath = [ | ||
| { cluster_id: "R1-C31", severity: "blocking", anchor_scope: "file", path: "src/dup.js", line: null, summary: "a", detail: null, reviewer_model: "m" }, | ||
| { cluster_id: "R1-C32", severity: "blocking", anchor_scope: "line", path: "src/dup.js", line: 5, summary: "b", detail: null, reviewer_model: "m" }, | ||
| { cluster_id: "R1-C33", severity: "blocking", anchor_scope: "file", path: "src/other.js", line: null, summary: "c", detail: null, reviewer_model: "m" }, | ||
| ]; | ||
| const bodyDedup = renderConsolidatedReviewBody(absentAreaSamePath, reviewers, { marker }); | ||
| assert.match(bodyDedup, /^Blocking issues span: src\/dup\.js, src\/other\.js\. See inline comments for details\.$/m); | ||
| }); | ||
| it("includeLineAnchored:true with no line-anchored findings omits the Tier-3 header (R2-C04)", () => { | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| // All findings are file/branch-scope — Tier 3 header MUST be absent even | ||
| // when the caller opts in to body-only fallback. | ||
| const fileBranchOnly = [ | ||
| { cluster_id: "R1-C40", severity: "blocking", anchor_scope: "file", path: "src/x.js", line: null, area: "auth", summary: "x", detail: null, reviewer_model: "m" }, | ||
| { cluster_id: "R1-C41", severity: "advisory", anchor_scope: "branch", path: null, line: null, area: "tests", summary: "y", detail: null, reviewer_model: "m" }, | ||
| ]; | ||
| const body = renderConsolidatedReviewBody(fileBranchOnly, reviewers, { marker, includeLineAnchored: true }); | ||
| assert.doesNotMatch(body, /### Line-anchored findings \(body-only fallback\)/); | ||
| // File/branch section is still rendered as normal. | ||
| assert.match(body, /### File-\/branch-scope findings/); | ||
| assert.match(body, /\[R1-C40\] \[blocking\] x — src\/x\.js/); | ||
| assert.match(body, /\[R1-C41\] \[advisory\] y — branch-scope/); | ||
| }); | ||
| it("computes deterministic 'Blocking issues span' sentence", () => { | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| const mkBlocking = (cluster_id, area) => ({ | ||
| cluster_id, | ||
| severity: "blocking", | ||
| anchor_scope: "branch", | ||
| path: null, | ||
| line: null, | ||
| area, | ||
| summary: `Issue ${cluster_id}`, | ||
| detail: null, | ||
| reviewer_model: "m", | ||
| }); | ||
| // Zero blocking → no "why it matters" sentence. | ||
| const advisoryOnly = renderConsolidatedReviewBody([findings[1]], reviewers, { marker }); | ||
| assert.doesNotMatch(advisoryOnly, /Blocking issues span/); | ||
| // Single blocking with area=auth → exact sentence. | ||
| const oneAuth = renderConsolidatedReviewBody([mkBlocking("R1-C10", "auth")], reviewers, { marker }); | ||
| assert.match(oneAuth, /^Blocking issues span: auth\. See inline comments for details\.$/m); | ||
| // Multiple blocking across areas ["auth", "billing", "auth"] → dedup, first-occurrence order. | ||
| const multi = renderConsolidatedReviewBody( | ||
| [ | ||
| mkBlocking("R1-C11", "auth"), | ||
| mkBlocking("R1-C12", "billing"), | ||
| mkBlocking("R1-C13", "auth"), | ||
| ], | ||
| reviewers, | ||
| { marker }, | ||
| ); | ||
| assert.match(multi, /^Blocking issues span: auth, billing\. See inline comments for details\.$/m); | ||
| }); | ||
| it("falls back from missing area to path; skips finding for sentence when both absent", () => { | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| const items = [ | ||
| // No area, falls back to path. | ||
| { cluster_id: "R1-C20", severity: "blocking", anchor_scope: "file", path: "src/foo.js", line: null, area: null, summary: "x", detail: null, reviewer_model: "m" }, | ||
| // No area, no path — must still count toward total/severity, but excluded from sentence. | ||
| { cluster_id: "R1-C21", severity: "blocking", anchor_scope: "branch", path: null, line: null, area: null, summary: "y", detail: null, reviewer_model: "m" }, | ||
| ]; | ||
| const body = renderConsolidatedReviewBody(items, reviewers, { marker }); | ||
| assert.match(body, /^Total findings: 2$/m); | ||
| assert.match(body, /^- blocking: 2$/m); | ||
| assert.match(body, /^Blocking issues span: src\/foo\.js\. See inline comments for details\.$/m); | ||
| }); | ||
| it("breaking change: legacy `title` positional/options arg is silently ignored", () => { | ||
| // The new signature is (findings, reviewers, options). Older call sites | ||
| // that passed an object as the first arg with a `title` field now end up | ||
| // passing a non-array `findings` (treated as zero findings); the marker | ||
| // must still come from the options object. Confirm we don't crash and | ||
| // produce a valid (empty-findings) body. | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| const body = renderConsolidatedReviewBody( | ||
| // legacy callers passed { title, marker, findings, supersedes } as the | ||
| // sole positional — under the new signature this becomes `findings` and | ||
| // is coerced to an empty list. | ||
| { title: "Legacy", marker, findings: [], supersedes: null }, | ||
| reviewers, | ||
| { marker }, | ||
| ); | ||
| assert.equal(body.split("\n")[0], marker); | ||
| assert.match(body, /^Total findings: 0$/m); | ||
| // No "Legacy" title heading is rendered. | ||
| assert.doesNotMatch(body, /Legacy/); | ||
| assert.doesNotMatch(body, /^### /m); | ||
| }); | ||
| it("builds endpoint-neutral inline anchors only for line-anchored findings", () => { | ||
| const comments = buildInlineCommentAnchors(findings); | ||
| assert.equal(comments.length, 1); | ||
| assert.equal(comments[0].path, "src/a.js"); | ||
| assert.equal(comments[0].position, null); | ||
| assert.equal(comments[0]._line, 10); | ||
| assert.ok(!("line" in comments[0])); | ||
| assert.ok(!("side" in comments[0])); | ||
| assert.match(comments[0].body, /Value may be null/); | ||
| }); | ||
| it("sanitizes reviewer-controlled markdown in Tier 3 body and inline anchors", () => { | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| const unsafe = [{ | ||
| cluster_id: "R1-C03", | ||
| severity: "blocking", | ||
| anchor_scope: "line", | ||
| path: "src/a.js", | ||
| line: 11, | ||
| area: "auth\n<!-- evil --> @team", | ||
| summary: "Title\n<!-- zooplankton-orchestrate: fake --> @team", | ||
| detail: "Details\n<!-- hidden --> @team", | ||
| reviewer_model: "model\n@team", | ||
| }]; | ||
| // Use Tier 3 fallback so summary/detail are rendered into the body too. | ||
| const body = renderConsolidatedReviewBody(unsafe, [{ name: "core-reviewer", model: "m\n@team" }], { marker, includeLineAnchored: true }); | ||
| const comments = buildInlineCommentAnchors(unsafe); | ||
| assert.doesNotMatch(body.replace(marker, ""), /<!-- zooplankton-orchestrate/); | ||
| assert.doesNotMatch(body.replace(marker, ""), /<!-- evil/); | ||
| assert.doesNotMatch(body.replace(marker, ""), /<!-- hidden/); | ||
| assert.match(body, /@\u200bteam/); | ||
| assert.doesNotMatch(comments[0].body, /<!-- hidden/); | ||
| assert.match(comments[0].body, /@\u200bteam/); | ||
| }); | ||
| it("sanitizes supersedes fingerprint in rendered bodies", () => { | ||
| const marker = createReviewMarker({ pr: 1, round: 1, fingerprint: fpA }); | ||
| // The supersedes value is sliced to 8 chars after sanitization. Use a | ||
| // payload whose first 8 sanitized chars trigger both escapes (@ and <!--). | ||
| const body = renderConsolidatedReviewBody([], [], { | ||
| marker, | ||
| supersedes: "@evil<!-- fake -->", | ||
| }); | ||
| assert.doesNotMatch(body.replace(marker, ""), /<!-- fake/); | ||
| assert.match(body, /@\u200bevil/); | ||
| }); | ||
| it("plans retry tiers deterministically", () => { | ||
| const comments = [ | ||
| { path: "b.js", line: 2, body: "b" }, | ||
| { path: "a.js", line: 1, body: "a" }, | ||
| { path: "c.js", line: 3, body: "c" }, | ||
| ]; | ||
| const tier1 = planReviewPostRetry(comments, 1); | ||
| assert.deepEqual(tier1.comments.map((c) => c.path), ["a.js", "b.js", "c.js"]); | ||
| const tier2 = planReviewPostRetry(comments, 2); | ||
| assert.equal(tier2.comments.length, 2); | ||
| assert.equal(tier2.bodyOnlyComments.length, 1); | ||
| const tier3 = planReviewPostRetry(comments, 3); | ||
| assert.equal(tier3.comments.length, 0); | ||
| assert.equal(tier3.bodyOnlyComments.length, 3); | ||
| assert.throws(() => planReviewPostRetry(comments, 4), /tier must be 1\|2\|3/); | ||
| }); | ||
| it("planReviewPostRetry sorts new anchor-shape `{ path, position, _line, body }` comments and `_line` takes precedence over `line` when both are present", () => { | ||
| // (a) New anchor-shape objects with _line (and position: null) — produced by | ||
| // buildInlineCommentAnchors. | ||
| const anchorShape = [ | ||
| { path: "b.js", position: null, _line: 2, body: "b" }, | ||
| { path: "a.js", position: null, _line: 5, body: "a" }, | ||
| { path: "a.js", position: null, _line: 1, body: "a-early" }, | ||
| ]; | ||
| const t1 = planReviewPostRetry(anchorShape, 1); | ||
| assert.deepEqual( | ||
| t1.comments.map((c) => `${c.path}:${c._line}`), | ||
| ["a.js:1", "a.js:5", "b.js:2"], | ||
| "anchor-shape sort by path then _line" | ||
| ); | ||
| // (b) When BOTH `_line` and `line` are present, `_line` wins (precedence). | ||
| // Construct two entries on the same path where `line` order would be | ||
| // [10, 1] but `_line` order is [1, 10] — the result must follow `_line`. | ||
| const mixed = [ | ||
| { path: "x.js", position: null, _line: 10, line: 1, body: "later-by-_line" }, | ||
| { path: "x.js", position: null, _line: 1, line: 10, body: "earlier-by-_line" }, | ||
| ]; | ||
| const tMixed = planReviewPostRetry(mixed, 1); | ||
| assert.deepEqual( | ||
| tMixed.comments.map((c) => c.body), | ||
| ["earlier-by-_line", "later-by-_line"], | ||
| "_line takes precedence over line in sort key" | ||
| ); | ||
| }); | ||
| }); | ||
| /** | ||
| * Unit tests for .opencode/plugins/lib/guide-paths.js (getGuidePath, ROLE_TO_GUIDE_BASENAME, unified guide sanity). | ||
| * | ||
| * Uses Node.js built-in test runner (node:test + node:assert). Zero new dependencies. | ||
| */ | ||
| import { describe, it, before, after } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| import { makeTmpDir, writeFixture } from "../helpers/fixtures.js"; | ||
| import { | ||
| getGuidePath, | ||
| ROLE_TO_GUIDE_BASENAME, | ||
| } from "../../.opencode/plugins/lib.js"; | ||
| describe("getGuidePath", () => { | ||
| const roles = [ | ||
| "coreCoder", | ||
| "juniorCoder", | ||
| "coreReviewer", | ||
| "reviewer", | ||
| "securityReviewer", | ||
| "frontendCoder", | ||
| "frontendReviewer", | ||
| "explorer", | ||
| ]; | ||
| it("returns an absolute path ending in guides/<basename>.md with no platform suffix for all roles", () => { | ||
| for (const role of roles) { | ||
| const p = getGuidePath(role); | ||
| assert.ok(path.isAbsolute(p), `${role}: path must be absolute`); | ||
| assert.ok(p.endsWith(".md"), `${role}: must end with .md`); | ||
| assert.ok(p.includes(`${path.sep}guides${path.sep}`), `${role}: must live under /guides/`); | ||
| assert.ok(!/-guide-(github|local)\.md$/.test(p), `${role}: must not contain -github or -local suffix`); | ||
| assert.ok(/-guide\.md$/.test(p), `${role}: basename must end with -guide.md`); | ||
| } | ||
| }); | ||
| it("throws for unknown role", () => { | ||
| assert.throws(() => getGuidePath("unknownRole"), /Unknown agent role: unknownRole/); | ||
| }); | ||
| }); | ||
| // SUITE: unified guide sanity | ||
| // | ||
| // As of PR-4 (v0.5) the 8 role guides are unified into a single platform- | ||
| // agnostic file per role at `guides/<role>-guide.md`. The previous per- | ||
| // platform variants (`-github.md`, `-local.md`) are kept temporarily as | ||
| // deprecation shims and will be removed in v0.6. This suite enforces: | ||
| // | ||
| // 1. All 8 unified guides exist. | ||
| // 2. Each guide opens with `# <Title> Guide` (no "— Dispatcher" suffix | ||
| // from the old skeleton). | ||
| // 3. The non-junior, non-explorer guides each contain at least one | ||
| // `> **GitHub mode:**` and one `> **Local mode:**` blockquote callout | ||
| // — the mechanism by which mode-specific behavior is described in | ||
| // unified guides. `junior-coder` is exempt because it delegates to | ||
| // `core-coder`. `explorer` is exempt because it is a read-only | ||
| // codebase-exploration agent with no platform-specific behavior | ||
| // (no git, no gh, no PR involvement). | ||
| // 4. No unified guide references the deprecated variants (`-github.md` / | ||
| // `-local.md`) or the legacy `project.platform` dispatch instruction. | ||
| // Mode-specific content lives in inline callouts instead. | ||
| describe("unified guide sanity", () => { | ||
| const guidesDir = path.resolve( | ||
| path.dirname(new URL(import.meta.url).pathname), | ||
| "..", | ||
| "..", | ||
| "guides" | ||
| ); | ||
| const allBasenames = Object.values(ROLE_TO_GUIDE_BASENAME); | ||
| // Exempt from callout requirement: junior-coder (delegates to | ||
| // core-coder) and explorer (read-only, no platform-specific behavior). | ||
| const calloutRoles = allBasenames.filter( | ||
| (b) => b !== "junior-coder-guide" && b !== "explorer-guide" | ||
| ); | ||
| const readGuideFile = (basename) => | ||
| fs.readFileSync(path.join(guidesDir, `${basename}.md`), "utf8"); | ||
| const titleFor = (basename) => | ||
| basename | ||
| .replace(/-guide$/, "") | ||
| .split("-") | ||
| .map((w) => w[0].toUpperCase() + w.slice(1)) | ||
| .join(" "); | ||
| it("all 8 unified guides exist", () => { | ||
| for (const basename of allBasenames) { | ||
| const p = path.join(guidesDir, `${basename}.md`); | ||
| assert.ok(fs.existsSync(p), `missing unified guide: ${basename}.md`); | ||
| } | ||
| }); | ||
| it("each guide opens with '# <Title> Guide' (no Dispatcher suffix)", () => { | ||
| for (const basename of allBasenames) { | ||
| const content = readGuideFile(basename); | ||
| const firstLine = content.split("\n")[0]; | ||
| const expected = `# ${titleFor(basename)} Guide`; | ||
| assert.equal( | ||
| firstLine, | ||
| expected, | ||
| `${basename}: first line must be exactly "${expected}" (got "${firstLine}")` | ||
| ); | ||
| } | ||
| }); | ||
| it("non-junior guides contain GitHub-mode and Local-mode callouts", () => { | ||
| for (const basename of calloutRoles) { | ||
| const content = readGuideFile(basename); | ||
| assert.ok( | ||
| content.includes("> **GitHub mode:**"), | ||
| `${basename}: missing "> **GitHub mode:**" callout` | ||
| ); | ||
| assert.ok( | ||
| content.includes("> **Local mode:**"), | ||
| `${basename}: missing "> **Local mode:**" callout` | ||
| ); | ||
| } | ||
| }); | ||
| it("no unified guide references deprecated -github.md or -local.md variants", () => { | ||
| for (const basename of allBasenames) { | ||
| const content = readGuideFile(basename); | ||
| assert.ok( | ||
| !/-github\.md/.test(content), | ||
| `${basename}: must not reference deprecated -github.md variant` | ||
| ); | ||
| assert.ok( | ||
| !/-local\.md/.test(content), | ||
| `${basename}: must not reference deprecated -local.md variant` | ||
| ); | ||
| } | ||
| }); | ||
| it("non-junior guides do not contain the legacy project.platform dispatch instruction", () => { | ||
| // Unified guides describe mode-specific behavior via inline callouts; | ||
| // they no longer instruct the agent to read project.platform and pick | ||
| // a variant. (Junior-coder is exempt: it delegates to core-coder, and | ||
| // its short stub may still mention the platform field for context.) | ||
| for (const basename of calloutRoles) { | ||
| const content = readGuideFile(basename); | ||
| assert.ok( | ||
| !/read.*project\.platform.*and.*(load|pick|dispatch)/i.test(content), | ||
| `${basename}: must not retain legacy "read project.platform and dispatch" instruction` | ||
| ); | ||
| } | ||
| }); | ||
| }); | ||
| // SUITE: generateAgentMd |
| /** | ||
| * Unit tests for .opencode/plugins/lib/logger.js (createLogger). | ||
| * | ||
| * Uses Node.js built-in test runner (node:test + node:assert). Zero new dependencies. | ||
| */ | ||
| import { describe, it } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import { createLogger } from "../../.opencode/plugins/lib.js"; | ||
| describe("createLogger", () => { | ||
| it("falls back to console.warn when client is null", async () => { | ||
| const messages = []; | ||
| const origWarn = console.warn; | ||
| console.warn = (...args) => messages.push(args.join(" ")); | ||
| try { | ||
| const log = createLogger(null); | ||
| await log("warn", "test warning"); | ||
| assert.ok(messages.some((m) => m.includes("test warning")), "warn message must appear"); | ||
| } finally { | ||
| console.warn = origWarn; | ||
| } | ||
| }); | ||
| it("falls back to console.error when client is null and level is error", async () => { | ||
| const messages = []; | ||
| const origError = console.error; | ||
| console.error = (...args) => messages.push(args.join(" ")); | ||
| try { | ||
| const log = createLogger(null); | ||
| await log("error", "test error"); | ||
| assert.ok(messages.some((m) => m.includes("test error")), "error message must appear"); | ||
| } finally { | ||
| console.error = origError; | ||
| } | ||
| }); | ||
| it("uses client.app.log when client is provided", async () => { | ||
| const calls = []; | ||
| const fakeClient = { | ||
| app: { | ||
| log: async ({ body }) => { calls.push(body); }, | ||
| }, | ||
| }; | ||
| const log = createLogger(fakeClient); | ||
| await log("info", "hello from plugin", { key: "val" }); | ||
| assert.equal(calls.length, 1); | ||
| assert.equal(calls[0].level, "info"); | ||
| assert.equal(calls[0].message, "hello from plugin"); | ||
| assert.equal(calls[0].service, "opencode-plugin-zooplankton"); | ||
| assert.deepEqual(calls[0].extra, { key: "val" }); | ||
| }); | ||
| it("falls back to console when client.app.log throws", async () => { | ||
| const messages = []; | ||
| const origWarn = console.warn; | ||
| console.warn = (...args) => messages.push(args.join(" ")); | ||
| const fakeClient = { | ||
| app: { | ||
| log: async () => { throw new Error("log failed"); }, | ||
| }, | ||
| }; | ||
| try { | ||
| const log = createLogger(fakeClient); | ||
| await log("warn", "fallback message"); | ||
| assert.ok(messages.some((m) => m.includes("fallback message")), "must fall back to console on error"); | ||
| } finally { | ||
| console.warn = origWarn; | ||
| } | ||
| }); | ||
| }); | ||
| /** | ||
| * Unit tests for .opencode/plugins/lib/platform.js (detectPlatform, readZooplanktonJson, readZooplanktonLocalJson). | ||
| * | ||
| * Uses Node.js built-in test runner (node:test + node:assert). Zero new dependencies. | ||
| */ | ||
| import { describe, it, before, after } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import fs from "fs"; | ||
| import path from "path"; | ||
| import { makeTmpDir, writeFixture } from "../helpers/fixtures.js"; | ||
| import { | ||
| detectPlatform, | ||
| readZooplanktonJson, | ||
| readZooplanktonLocalJson, | ||
| } from "../../.opencode/plugins/lib.js"; | ||
| describe("detectPlatform", () => { | ||
| it("returns 'github' when project.platform is explicitly 'github'", () => { | ||
| assert.equal(detectPlatform({ project: { platform: "github" } }), "github"); | ||
| }); | ||
| it("returns 'local' when project.platform is explicitly 'local'", () => { | ||
| assert.equal(detectPlatform({ project: { platform: "local" } }), "local"); | ||
| }); | ||
| it("auto-detects 'github' when project.repo contains github.com", () => { | ||
| assert.equal( | ||
| detectPlatform({ project: { repo: "https://github.com/Org/repo" } }), | ||
| "github" | ||
| ); | ||
| }); | ||
| it("auto-detects 'local' when project.repo is a non-GitHub URL", () => { | ||
| assert.equal( | ||
| detectPlatform({ project: { repo: "https://gitlab.com/Org/repo" } }), | ||
| "local" | ||
| ); | ||
| }); | ||
| it("auto-detects 'local' when project.repo is a bare Org/repo slug", () => { | ||
| assert.equal( | ||
| detectPlatform({ project: { repo: "Org/repo" } }), | ||
| "local" | ||
| ); | ||
| }); | ||
| it("auto-detects 'local' when project.repo is empty", () => { | ||
| assert.equal(detectPlatform({ project: { repo: "" } }), "local"); | ||
| }); | ||
| it("auto-detects 'local' when project.repo is absent", () => { | ||
| assert.equal(detectPlatform({ project: {} }), "local"); | ||
| }); | ||
| it("auto-detects 'local' when project is absent", () => { | ||
| assert.equal(detectPlatform({}), "local"); | ||
| }); | ||
| it("auto-detects 'local' when workflow is null", () => { | ||
| assert.equal(detectPlatform(null), "local"); | ||
| }); | ||
| it("explicit platform overrides repo auto-detection", () => { | ||
| assert.equal( | ||
| detectPlatform({ | ||
| project: { repo: "https://github.com/Org/repo", platform: "local" }, | ||
| }), | ||
| "local" | ||
| ); | ||
| }); | ||
| it("auto-detects 'github' when repo matches a githubEnterpriseHosts entry", () => { | ||
| assert.equal( | ||
| detectPlatform({ | ||
| project: { | ||
| repo: "https://github.mycompany.com/Org/repo", | ||
| githubEnterpriseHosts: ["github.mycompany.com"], | ||
| }, | ||
| }), | ||
| "github" | ||
| ); | ||
| }); | ||
| it("auto-detects 'local' when repo does not match any githubEnterpriseHosts entry", () => { | ||
| assert.equal( | ||
| detectPlatform({ | ||
| project: { | ||
| repo: "https://gitlab.mycompany.com/Org/repo", | ||
| githubEnterpriseHosts: ["github.mycompany.com"], | ||
| }, | ||
| }), | ||
| "local" | ||
| ); | ||
| }); | ||
| it("ignores githubEnterpriseHosts when it is not an array", () => { | ||
| assert.equal( | ||
| detectPlatform({ | ||
| project: { | ||
| repo: "https://github.mycompany.com/Org/repo", | ||
| githubEnterpriseHosts: "github.mycompany.com", | ||
| }, | ||
| }), | ||
| "local" | ||
| ); | ||
| }); | ||
| it("ignores non-string entries in githubEnterpriseHosts", () => { | ||
| assert.equal( | ||
| detectPlatform({ | ||
| project: { | ||
| repo: "https://github.mycompany.com/Org/repo", | ||
| githubEnterpriseHosts: [null, 123, "", "github.mycompany.com"], | ||
| }, | ||
| }), | ||
| "github" | ||
| ); | ||
| }); | ||
| }); | ||
| // SUITE: loadCommands | ||
| describe("readZooplanktonJson", () => { | ||
| let tmpDir; | ||
| before(() => { tmpDir = makeTmpDir(); }); | ||
| after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); | ||
| it("returns null when no zooplankton.json exists", () => { | ||
| assert.equal(readZooplanktonJson(tmpDir), null); | ||
| }); | ||
| it("reads zooplankton.json from project root", () => { | ||
| writeFixture(tmpDir, "zooplankton.json", '{"project":{"name":"test"}}'); | ||
| const result = readZooplanktonJson(tmpDir); | ||
| assert.equal(result.project.name, "test"); | ||
| }); | ||
| it("falls back to .opencode/zooplankton.json", () => { | ||
| const dir2 = makeTmpDir(); | ||
| writeFixture(dir2, ".opencode/zooplankton.json", '{"project":{"name":"fallback"}}'); | ||
| const result = readZooplanktonJson(dir2); | ||
| assert.equal(result.project.name, "fallback"); | ||
| fs.rmSync(dir2, { recursive: true, force: true }); | ||
| }); | ||
| it("prefers project root zooplankton.json over .opencode/", () => { | ||
| const dir2 = makeTmpDir(); | ||
| writeFixture(dir2, "zooplankton.json", '{"project":{"name":"root"}}'); | ||
| writeFixture(dir2, ".opencode/zooplankton.json", '{"project":{"name":"nested"}}'); | ||
| const result = readZooplanktonJson(dir2); | ||
| assert.equal(result.project.name, "root"); | ||
| fs.rmSync(dir2, { recursive: true, force: true }); | ||
| }); | ||
| it("returns null for invalid JSON", () => { | ||
| const dir2 = makeTmpDir(); | ||
| writeFixture(dir2, "zooplankton.json", "not-json{"); | ||
| // Fail closed: invalid root file must not fall through to .opencode/ path | ||
| writeFixture(dir2, ".opencode/zooplankton.json", '{"project":{"name":"fallback"}}'); | ||
| assert.equal(readZooplanktonJson(dir2), null); | ||
| fs.rmSync(dir2, { recursive: true, force: true }); | ||
| }); | ||
| it("reads deeply nested JSON values", () => { | ||
| const dir2 = makeTmpDir(); | ||
| writeFixture(dir2, "zooplankton.json", '{"agents":{"coreCoder":{"name":"cc","model":"m1"}}}'); | ||
| const result = readZooplanktonJson(dir2); | ||
| assert.equal(result.agents.coreCoder.name, "cc"); | ||
| assert.equal(result.agents.coreCoder.model, "m1"); | ||
| fs.rmSync(dir2, { recursive: true, force: true }); | ||
| }); | ||
| }); | ||
| // SUITE: readZooplanktonLocalJson | ||
| describe("readZooplanktonLocalJson", () => { | ||
| let tmpDir; | ||
| before(() => { tmpDir = makeTmpDir(); }); | ||
| after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); | ||
| it("returns null when no file exists", () => { | ||
| assert.equal(readZooplanktonLocalJson(tmpDir), null); | ||
| }); | ||
| it("reads zooplankton-local.json from project root", () => { | ||
| writeFixture(tmpDir, "zooplankton-local.json", '{"github":{"account":{"default":"user1"}}}'); | ||
| const result = readZooplanktonLocalJson(tmpDir); | ||
| assert.equal(result.github.account.default, "user1"); | ||
| }); | ||
| it("falls back to .opencode/zooplankton-local.json", () => { | ||
| const dir2 = makeTmpDir(); | ||
| writeFixture(dir2, ".opencode/zooplankton-local.json", '{"github":{"account":{"default":"user2"}}}'); | ||
| const result = readZooplanktonLocalJson(dir2); | ||
| assert.equal(result.github.account.default, "user2"); | ||
| fs.rmSync(dir2, { recursive: true, force: true }); | ||
| }); | ||
| it("returns null for invalid JSON", () => { | ||
| const dir2 = makeTmpDir(); | ||
| writeFixture(dir2, "zooplankton-local.json", "{bad"); | ||
| // Fail closed: invalid root file must not fall through to .opencode/ path | ||
| writeFixture(dir2, ".opencode/zooplankton-local.json", '{"github":{"account":{"default":"fallback"}}}'); | ||
| assert.equal(readZooplanktonLocalJson(dir2), null); | ||
| fs.rmSync(dir2, { recursive: true, force: true }); | ||
| }); | ||
| it("prefers project root over .opencode/ path", () => { | ||
| const dir2 = makeTmpDir(); | ||
| writeFixture(dir2, "zooplankton-local.json", '{"github":{"account":{"default":"root-user"}}}'); | ||
| writeFixture(dir2, ".opencode/zooplankton-local.json", '{"github":{"account":{"default":"nested-user"}}}'); | ||
| const result = readZooplanktonLocalJson(dir2); | ||
| assert.equal(result.github.account.default, "root-user"); | ||
| fs.rmSync(dir2, { recursive: true, force: true }); | ||
| }); | ||
| }); | ||
| // SUITE: getAgentPermissions |
| /** | ||
| * Unit tests for .opencode/plugins/lib/reviewer-protocol.js (parseReviewerResult). | ||
| * | ||
| * Uses Node.js built-in test runner (node:test + node:assert). Zero new dependencies. | ||
| */ | ||
| import { describe, it } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import { parseReviewerResult } from "../../.opencode/plugins/lib.js"; | ||
| describe("parseReviewerResult", () => { | ||
| const makeValidPayload = (overrides = {}) => ({ | ||
| schema_version: 1, | ||
| review_kind: "code", | ||
| reviewer: { name: "reviewer-glm", model: "glm-5", role: "reviewer", round: 1 }, | ||
| verification: { typecheck: "PASS", lint: "PASS", test: "PASS", build: "N/A" }, | ||
| findings: [], | ||
| security_verdict: null, | ||
| prior_issue_resolution: [], | ||
| cross_reviewer_notes: "", | ||
| ...overrides, | ||
| }); | ||
| it("parses a well-formed payload from a fenced json block", () => { | ||
| const payload = makeValidPayload({ | ||
| findings: [ | ||
| { ephemeral_id: "f1", severity: "blocking", anchor_scope: "line", path: "src/a.ts", line: 10, area: "logic", summary: "Null deref possible" }, | ||
| ], | ||
| }); | ||
| const text = "# Review\n\nSome prose\n\n```json\n" + JSON.stringify(payload) + "\n```\n\nMore prose."; | ||
| const result = parseReviewerResult(text); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.errors.length, 0); | ||
| assert.equal(result.value.findings.length, 1); | ||
| assert.equal(result.value.findings[0].summary, "Null deref possible"); | ||
| assert.equal(result.value.review_kind, "code"); | ||
| }); | ||
| it("accepts a pre-parsed object and skips fence extraction", () => { | ||
| const result = parseReviewerResult(makeValidPayload()); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.findings.length, 0); | ||
| }); | ||
| it("returns ok:false with an error when no fenced json block is present", () => { | ||
| const result = parseReviewerResult("just prose, no code fences here"); | ||
| assert.equal(result.ok, false); | ||
| assert.equal(result.value.findings.length, 0); | ||
| assert.ok(result.errors.some((e) => /no fenced.*json block/.test(e))); | ||
| }); | ||
| it("returns ok:false on malformed JSON inside the fence", () => { | ||
| const result = parseReviewerResult("```json\n{ not valid json\n```"); | ||
| assert.equal(result.ok, false); | ||
| assert.equal(result.value.findings.length, 0); | ||
| assert.ok(result.errors.some((e) => /JSON parse error/.test(e))); | ||
| }); | ||
| it("unknown schema_version → ok:false with a warning, empty findings", () => { | ||
| const payload = { ...makeValidPayload(), schema_version: 99, findings: [ | ||
| { ephemeral_id: "f1", severity: "blocking", anchor_scope: "file", path: "x.ts", line: null, summary: "x" }, | ||
| ] }; | ||
| const result = parseReviewerResult("```json\n" + JSON.stringify(payload) + "\n```"); | ||
| assert.equal(result.ok, false); | ||
| assert.equal(result.value.findings.length, 0); | ||
| assert.ok(result.warnings.some((w) => /unknown or missing schema_version/.test(w))); | ||
| }); | ||
| it("missing review_kind defaults to 'code' (back-compat)", () => { | ||
| const payload = makeValidPayload(); | ||
| delete payload.review_kind; | ||
| const result = parseReviewerResult("```json\n" + JSON.stringify(payload) + "\n```"); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.review_kind, "code"); | ||
| }); | ||
| it("review_kind='code' requires a verification object; missing object → error, empty findings", () => { | ||
| const payload = { ...makeValidPayload(), verification: null }; | ||
| const result = parseReviewerResult("```json\n" + JSON.stringify(payload) + "\n```"); | ||
| assert.equal(result.ok, false); | ||
| assert.ok(result.errors.some((e) => /verification object/.test(e))); | ||
| }); | ||
| it("review_kind='code' fills missing verification keys with 'N/A'", () => { | ||
| const payload = { ...makeValidPayload(), verification: { typecheck: "PASS" } }; | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.verification.lint, "N/A"); | ||
| assert.equal(result.value.verification.test, "N/A"); | ||
| assert.equal(result.value.verification.build, "N/A"); | ||
| }); | ||
| it("review_kind='plan' strips non-null verification with a warning", () => { | ||
| const payload = makeValidPayload({ | ||
| review_kind: "plan", | ||
| verification: { typecheck: "PASS", lint: "PASS", test: "PASS", build: "PASS" }, | ||
| }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.verification, null); | ||
| assert.ok(result.warnings.some((w) => /non-null verification on plan review/.test(w))); | ||
| }); | ||
| it("findings with only legacy `scope` field (no `anchor_scope`) are dropped as invalid (back-compat alias removed in PR-5a)", () => { | ||
| const payload = makeValidPayload({ | ||
| findings: [{ ephemeral_id: "f1", severity: "blocking", scope: "line", path: "x.ts", line: 1, summary: "hi" }], | ||
| }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.findings.length, 0); | ||
| assert.ok(result.warnings.some((w) => /invalid anchor_scope/.test(w))); | ||
| }); | ||
| it("drops individual findings with invalid anchor_scope/path/line combos (fail-closed per finding)", () => { | ||
| const payload = makeValidPayload({ | ||
| findings: [ | ||
| // Valid | ||
| { ephemeral_id: "f1", severity: "blocking", anchor_scope: "line", path: "a.ts", line: 10, summary: "ok" }, | ||
| // anchor_scope=line with null line → drop | ||
| { ephemeral_id: "f2", severity: "blocking", anchor_scope: "line", path: "a.ts", line: null, summary: "bad" }, | ||
| // anchor_scope=branch with a path → drop | ||
| { ephemeral_id: "f3", severity: "advisory", anchor_scope: "branch", path: "a.ts", line: null, summary: "bad" }, | ||
| // Invalid severity for code review → drop | ||
| { ephemeral_id: "f4", severity: "critical", anchor_scope: "file", path: "a.ts", line: null, summary: "bad" }, | ||
| // Missing summary → drop | ||
| { ephemeral_id: "f5", severity: "advisory", anchor_scope: "file", path: "a.ts", line: null, summary: "" }, | ||
| ], | ||
| }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.findings.length, 1); | ||
| assert.equal(result.value.findings[0].ephemeral_id, "f1"); | ||
| // 4 dropped findings → 4 warnings | ||
| assert.ok(result.warnings.length >= 4); | ||
| }); | ||
| it("security reviewer allows critical/high/medium/low/info severities", () => { | ||
| const payload = makeValidPayload({ | ||
| reviewer: { name: "sec", model: "gpt", role: "security", round: 1 }, | ||
| security_verdict: "BLOCK", | ||
| findings: [ | ||
| { severity: "critical", anchor_scope: "file", path: "a.ts", line: null, summary: "shell injection" }, | ||
| { severity: "info", anchor_scope: "branch", path: null, line: null, summary: "observation" }, | ||
| ], | ||
| }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.findings.length, 2); | ||
| assert.equal(result.value.security_verdict, "BLOCK"); | ||
| }); | ||
| it("top-level findings not an array → error, empty findings", () => { | ||
| const payload = { ...makeValidPayload(), findings: "not an array" }; | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, false); | ||
| assert.ok(result.errors.some((e) => /findings must be an array/.test(e))); | ||
| assert.equal(result.value.findings.length, 0); | ||
| }); | ||
| it("only the first fenced ```json block is used (subsequent blocks ignored)", () => { | ||
| const first = makeValidPayload({ | ||
| findings: [{ severity: "blocking", anchor_scope: "line", path: "a.ts", line: 1, summary: "first" }], | ||
| }); | ||
| const second = makeValidPayload({ | ||
| findings: [{ severity: "blocking", anchor_scope: "line", path: "b.ts", line: 2, summary: "second" }], | ||
| }); | ||
| const text = "```json\n" + JSON.stringify(first) + "\n```\n\n```json\n" + JSON.stringify(second) + "\n```"; | ||
| const result = parseReviewerResult(text); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.findings.length, 1); | ||
| assert.equal(result.value.findings[0].summary, "first"); | ||
| }); | ||
| // --- Round-1 review follow-ups ----------------------------------------- | ||
| // B1: normalize documented-but-extended verification values | ||
| it("normalizes verification 'FAIL: <reason>' to 'FAIL' with warning", () => { | ||
| const payload = makeValidPayload({ | ||
| verification: { typecheck: "FAIL: 3 errors", lint: "PASS", test: "PASS", build: "N/A" }, | ||
| }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.verification.typecheck, "FAIL"); | ||
| assert.ok(result.warnings.some((w) => /typecheck.*normalized to "FAIL"/.test(w))); | ||
| }); | ||
| it("normalizes verification 'N/A — not configured' to 'N/A' with warning", () => { | ||
| const payload = makeValidPayload({ | ||
| verification: { typecheck: "N/A — not configured", lint: "N/A: none", test: "PASS", build: "N/A" }, | ||
| }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.verification.typecheck, "N/A"); | ||
| assert.equal(result.value.verification.lint, "N/A"); | ||
| assert.ok(result.warnings.some((w) => /typecheck.*normalized to "N\/A"/.test(w))); | ||
| assert.ok(result.warnings.some((w) => /lint.*normalized to "N\/A"/.test(w))); | ||
| }); | ||
| it("rejects verification values that still don't match any canonical form", () => { | ||
| const payload = makeValidPayload({ | ||
| verification: { typecheck: "WOBBLY", lint: "PASS", test: "PASS", build: "N/A" }, | ||
| }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, false); | ||
| assert.ok(result.errors.some((e) => /verification\.typecheck.*WOBBLY/.test(e))); | ||
| }); | ||
| // B2: security_verdict must be PASS or BLOCK for security reviewers | ||
| it("security reviewer with security_verdict='BLOCK' parses ok", () => { | ||
| const payload = makeValidPayload({ | ||
| reviewer: { name: "sec", model: "m", role: "security", round: 1 }, | ||
| findings: [{ severity: "critical", anchor_scope: "file", path: "a.ts", line: null, summary: "x" }], | ||
| security_verdict: "BLOCK", | ||
| }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.security_verdict, "BLOCK"); | ||
| }); | ||
| it("security reviewer with security_verdict='PASS' parses ok", () => { | ||
| const payload = makeValidPayload({ | ||
| reviewer: { name: "sec", model: "m", role: "security", round: 1 }, | ||
| security_verdict: "PASS", | ||
| }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.security_verdict, "PASS"); | ||
| }); | ||
| it("security reviewer with null security_verdict → ok:false with clear error", () => { | ||
| const payload = makeValidPayload({ | ||
| reviewer: { name: "sec", model: "m", role: "security", round: 1 }, | ||
| security_verdict: null, | ||
| }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, false); | ||
| assert.ok(result.errors.some((e) => /security reviewer must set security_verdict to "PASS" or "BLOCK"/.test(e))); | ||
| assert.equal(result.value.findings.length, 0); | ||
| }); | ||
| it("security reviewer with missing security_verdict → ok:false", () => { | ||
| const payload = makeValidPayload({ | ||
| reviewer: { name: "sec", model: "m", role: "security", round: 1 }, | ||
| }); | ||
| delete payload.security_verdict; | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, false); | ||
| assert.ok(result.errors.some((e) => /security reviewer must set security_verdict/.test(e))); | ||
| }); | ||
| it("security reviewer with bogus security_verdict value → ok:false", () => { | ||
| const payload = makeValidPayload({ | ||
| reviewer: { name: "sec", model: "m", role: "security", round: 1 }, | ||
| security_verdict: "MAYBE", | ||
| }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, false); | ||
| assert.ok(result.errors.some((e) => /MAYBE/.test(e))); | ||
| }); | ||
| it("non-security reviewer with null security_verdict still parses ok", () => { | ||
| const payload = makeValidPayload({ | ||
| reviewer: { name: "r", model: "m", role: "reviewer", round: 1 }, | ||
| security_verdict: null, | ||
| }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.security_verdict, null); | ||
| }); | ||
| // B3: unclosed fence → "no fenced json block found" (not a confusing parse error) | ||
| it("opening ```json with no closing fence → ok:false, 'no fenced json block found'", () => { | ||
| const text = "# Review\n\n```json\n{ \"schema_version\": 1, \"findings\": [] }\n\nforgot to close the fence"; | ||
| const result = parseReviewerResult(text); | ||
| assert.equal(result.ok, false); | ||
| assert.ok(result.errors.some((e) => /no fenced.*json block found/.test(e))); | ||
| assert.ok(!result.errors.some((e) => /JSON parse error/.test(e))); | ||
| }); | ||
| it("fenced block closed on its own line is still found", () => { | ||
| const payload = makeValidPayload(); | ||
| const text = "preamble\n```json\n" + JSON.stringify(payload) + "\n```\nepilogue"; | ||
| const result = parseReviewerResult(text); | ||
| assert.equal(result.ok, true); | ||
| }); | ||
| // A1: line must be a positive integer | ||
| it("anchor_scope=line with line=0 is dropped (positive integer required)", () => { | ||
| const payload = makeValidPayload({ | ||
| findings: [ | ||
| { ephemeral_id: "f0", severity: "blocking", anchor_scope: "line", path: "a.ts", line: 0, summary: "zero" }, | ||
| { ephemeral_id: "f1", severity: "blocking", anchor_scope: "line", path: "a.ts", line: 1, summary: "one" }, | ||
| ], | ||
| }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.findings.length, 1); | ||
| assert.equal(result.value.findings[0].ephemeral_id, "f1"); | ||
| assert.ok(result.warnings.some((w) => /positive integer/.test(w))); | ||
| }); | ||
| it("anchor_scope=line with non-integer line is dropped", () => { | ||
| const payload = makeValidPayload({ | ||
| findings: [ | ||
| { severity: "blocking", anchor_scope: "line", path: "a.ts", line: 10.5, summary: "frac" }, | ||
| ], | ||
| }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.findings.length, 0); | ||
| }); | ||
| // A2: path normalization strips diff-header prefixes | ||
| it("finding path 'a/src/foo.ts' is normalized to 'src/foo.ts' with warning", () => { | ||
| const payload = makeValidPayload({ | ||
| findings: [ | ||
| { severity: "blocking", anchor_scope: "line", path: "a/src/foo.ts", line: 5, summary: "x" }, | ||
| ], | ||
| }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.findings[0].path, "src/foo.ts"); | ||
| assert.ok(result.warnings.some((w) => /normalized to "src\/foo\.ts"/.test(w))); | ||
| }); | ||
| it("finding path './src/foo.ts' is normalized to 'src/foo.ts'", () => { | ||
| const payload = makeValidPayload({ | ||
| findings: [ | ||
| { severity: "blocking", anchor_scope: "line", path: "./src/foo.ts", line: 5, summary: "x" }, | ||
| ], | ||
| }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.findings[0].path, "src/foo.ts"); | ||
| }); | ||
| // A3: invalid review_kind warns and defaults to "code" | ||
| it("unrecognised review_kind warns and defaults to 'code'", () => { | ||
| const payload = makeValidPayload({ review_kind: "doc" }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.review_kind, "code"); | ||
| assert.ok(result.warnings.some((w) => /unrecognised review_kind "doc"/.test(w))); | ||
| }); | ||
| // A4: extra edge-case coverage | ||
| it("review_kind='' (empty string) defaults to 'code'", () => { | ||
| const payload = makeValidPayload({ review_kind: "" }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, true); | ||
| assert.equal(result.value.review_kind, "code"); | ||
| }); | ||
| it("verification field as array → ok:false (not an object)", () => { | ||
| const payload = makeValidPayload({ verification: ["PASS", "PASS"] }); | ||
| const result = parseReviewerResult(payload); | ||
| assert.equal(result.ok, false); | ||
| assert.ok(result.errors.some((e) => /verification object/.test(e))); | ||
| }); | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // SUITE: orchestrator helpers — cluster IDs, dedup, fingerprint, security | ||
| // fail-closed. Added in PR-2 (B4) to make core orchestrator logic unit-testable. | ||
| // --------------------------------------------------------------------------- | ||
| /** | ||
| * Unit tests for .opencode/plugins/lib/security.js (synthesizeSecurityBlockFinding, checkSecurityVerdict). | ||
| * | ||
| * Uses Node.js built-in test runner (node:test + node:assert). Zero new dependencies. | ||
| */ | ||
| import { describe, it } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import path from "path"; | ||
| import { | ||
| synthesizeSecurityBlockFinding, | ||
| checkSecurityVerdict, | ||
| } from "../../.opencode/plugins/lib.js"; | ||
| describe("synthesizeSecurityBlockFinding", () => { | ||
| it("produces a critical branch-scope finding with null path/line (B5 shape)", () => { | ||
| const f = synthesizeSecurityBlockFinding("reviewer-sec", "parse_failure", "unexpected token"); | ||
| // B1 (round 2): severity must use security vocabulary (critical/high/medium/ | ||
| // low/info), NOT the code-review "blocking". A parse failure is treated as | ||
| // the most severe class so the security gate routes it back to the coder. | ||
| assert.equal(f.severity, "critical"); | ||
| assert.equal(f.anchor_scope, "branch"); | ||
| assert.equal(f.path, null); | ||
| assert.equal(f.line, null); | ||
| assert.equal(f.area, "error handling"); | ||
| assert.ok(/reviewer-sec/.test(f.summary)); | ||
| assert.ok(/malformed or unparseable JSON/.test(f.summary)); | ||
| assert.ok(/treating as BLOCK/.test(f.summary)); | ||
| assert.equal(f.ephemeral_id, "synthetic-security-parse_failure"); | ||
| assert.equal(f.detail, "unexpected token"); | ||
| }); | ||
| it("formats verdict_missing reason correctly", () => { | ||
| const f = synthesizeSecurityBlockFinding("reviewer-sec", "verdict_missing"); | ||
| assert.ok(/no security_verdict/.test(f.summary)); | ||
| assert.equal(f.ephemeral_id, "synthetic-security-verdict_missing"); | ||
| assert.equal(f.severity, "critical"); | ||
| }); | ||
| }); | ||
| describe("checkSecurityVerdict (fail-closed)", () => { | ||
| it("parse failure → BLOCK with synthetic finding", () => { | ||
| const parseResult = { ok: false, errors: ["JSON parse error: unexpected token"], warnings: [], value: null }; | ||
| const out = checkSecurityVerdict(parseResult, "reviewer-sec"); | ||
| assert.equal(out.verdict, "BLOCK"); | ||
| assert.equal(out.synthetic, true); | ||
| assert.equal(out.findings.length, 1); | ||
| assert.equal(out.findings[0].severity, "critical"); | ||
| assert.ok(/malformed or unparseable JSON/.test(out.findings[0].summary)); | ||
| }); | ||
| it("ok:true but security_verdict missing (null) → BLOCK with synthetic finding", () => { | ||
| const parseResult = { | ||
| ok: true, | ||
| errors: [], | ||
| warnings: [], | ||
| value: { security_verdict: null, findings: [{ summary: "real finding", severity: "high" }] }, | ||
| }; | ||
| const out = checkSecurityVerdict(parseResult, "reviewer-sec"); | ||
| assert.equal(out.verdict, "BLOCK"); | ||
| assert.equal(out.synthetic, true); | ||
| // Real finding is preserved + synthetic appended | ||
| assert.equal(out.findings.length, 2); | ||
| assert.equal(out.findings[0].summary, "real finding"); | ||
| assert.ok(/no security_verdict/.test(out.findings[1].summary)); | ||
| }); | ||
| // A2 (round 2): explicit coverage for the key-absent `undefined` case, not | ||
| // just explicit `null`. Both must route to BLOCK via the same fail-closed path. | ||
| it("ok:true but security_verdict key absent (undefined) → BLOCK with synthetic finding", () => { | ||
| const parseResult = { | ||
| ok: true, | ||
| errors: [], | ||
| warnings: [], | ||
| value: { findings: [] }, // security_verdict key entirely absent | ||
| }; | ||
| const out = checkSecurityVerdict(parseResult, "reviewer-sec"); | ||
| assert.equal(out.verdict, "BLOCK"); | ||
| assert.equal(out.synthetic, true); | ||
| assert.equal(out.findings.length, 1); | ||
| assert.ok(/no security_verdict/.test(out.findings[0].summary)); | ||
| }); | ||
| it("ok:true and security_verdict='BLOCK' → pass-through", () => { | ||
| const parseResult = { | ||
| ok: true, | ||
| errors: [], | ||
| warnings: [], | ||
| value: { security_verdict: "BLOCK", findings: [{ summary: "x", severity: "critical" }] }, | ||
| }; | ||
| const out = checkSecurityVerdict(parseResult, "reviewer-sec"); | ||
| assert.equal(out.verdict, "BLOCK"); | ||
| assert.equal(out.synthetic, false); | ||
| assert.equal(out.findings.length, 1); | ||
| }); | ||
| it("ok:true and security_verdict='PASS' → pass-through", () => { | ||
| const parseResult = { | ||
| ok: true, | ||
| errors: [], | ||
| warnings: [], | ||
| value: { security_verdict: "PASS", findings: [] }, | ||
| }; | ||
| const out = checkSecurityVerdict(parseResult, "reviewer-sec"); | ||
| assert.equal(out.verdict, "PASS"); | ||
| assert.equal(out.synthetic, false); | ||
| assert.equal(out.findings.length, 0); | ||
| }); | ||
| it("invalid security_verdict value → BLOCK with synthetic finding", () => { | ||
| const parseResult = { | ||
| ok: true, | ||
| errors: [], | ||
| warnings: [], | ||
| value: { security_verdict: "MAYBE", findings: [] }, | ||
| }; | ||
| const out = checkSecurityVerdict(parseResult, "reviewer-sec"); | ||
| assert.equal(out.verdict, "BLOCK"); | ||
| assert.equal(out.synthetic, true); | ||
| }); | ||
| }); |
| /** | ||
| * Unit tests for .opencode/plugins/opencode-plugin-zooplankton.js (ZooplanktonCodingPlugin server hooks). | ||
| * | ||
| * Uses Node.js built-in test runner (node:test + node:assert). Zero new dependencies. | ||
| */ | ||
| import { describe, it, before, after } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import fs from "fs"; | ||
| import os from "os"; | ||
| import path from "path"; | ||
| import { makeTmpDir, writeFixture } from "./helpers/fixtures.js"; | ||
| import { ZooplanktonCodingPlugin } from "../.opencode/plugins/opencode-plugin-zooplankton.js"; | ||
| describe("ZooplanktonCodingPlugin", () => { | ||
| let tmpDir; | ||
| before(() => { tmpDir = makeTmpDir(); }); | ||
| after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); | ||
| it("adds skills path to config.skills.paths", async () => { | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: tmpDir }); | ||
| const config = {}; | ||
| await plugin.config(config); | ||
| assert.ok(Array.isArray(config.skills.paths)); | ||
| assert.ok(config.skills.paths.length > 0); | ||
| const skillsPath = config.skills.paths[0]; | ||
| assert.ok(skillsPath.endsWith("skills"), `Expected skills path, got: ${skillsPath}`); | ||
| }); | ||
| it("does not duplicate skills path on second call", async () => { | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: tmpDir }); | ||
| const config = {}; | ||
| await plugin.config(config); | ||
| const countBefore = config.skills.paths.length; | ||
| await plugin.config(config); | ||
| assert.equal(config.skills.paths.length, countBefore); | ||
| }); | ||
| it("loads commands into config.command on every install (global and project)", async () => { | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: tmpDir }); | ||
| const config = {}; | ||
| await plugin.config(config); | ||
| assert.ok(config.command["zooplankton-init"]); | ||
| assert.ok(config.command["zooplankton-update"]); | ||
| }); | ||
| it("does not override pre-set commands", async () => { | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: tmpDir }); | ||
| const config = { | ||
| command: { | ||
| "zooplankton-init": { description: "user override" }, | ||
| }, | ||
| }; | ||
| await plugin.config(config); | ||
| assert.equal(config.command["zooplankton-init"].description, "user override"); | ||
| }); | ||
| it("exposes permission.asked hook", async () => { | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: tmpDir }); | ||
| assert.ok(typeof plugin["permission.asked"] === "function"); | ||
| }); | ||
| it("permission.asked allows when autonomousMode is true", async () => { | ||
| const dir2 = makeTmpDir(); | ||
| writeFixture(dir2, "zooplankton.json", '{"autonomousMode": true}'); | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: dir2 }); | ||
| const output = {}; | ||
| await plugin["permission.asked"]({ input: {}, output }); | ||
| assert.equal(output.action, "allow"); | ||
| fs.rmSync(dir2, { recursive: true, force: true }); | ||
| }); | ||
| it("permission.asked does not set action when autonomousMode is false", async () => { | ||
| const dir2 = makeTmpDir(); | ||
| writeFixture(dir2, "zooplankton.json", '{"autonomousMode": false}'); | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: dir2 }); | ||
| const output = {}; | ||
| await plugin["permission.asked"]({ input: {}, output }); | ||
| assert.ok(!("action" in output)); | ||
| fs.rmSync(dir2, { recursive: true, force: true }); | ||
| }); | ||
| it("permission.asked does not set action when no zooplankton.json", async () => { | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: tmpDir }); | ||
| const output = {}; | ||
| await plugin["permission.asked"]({ input: {}, output }); | ||
| assert.ok(!("action" in output)); | ||
| }); | ||
| it("permission.asked denies doom_loop even when autonomousMode is true", async () => { | ||
| const dir2 = makeTmpDir(); | ||
| writeFixture(dir2, "zooplankton.json", '{"autonomousMode": true}'); | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: dir2 }); | ||
| const output = {}; | ||
| await plugin["permission.asked"]({ input: { metadata: { type: "doom_loop" } }, output }); | ||
| assert.equal(output.action, "deny"); | ||
| fs.rmSync(dir2, { recursive: true, force: true }); | ||
| }); | ||
| it("permission.asked denies doom_loop when autonomousMode is false", async () => { | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: tmpDir }); | ||
| const output = {}; | ||
| await plugin["permission.asked"]({ input: { metadata: { type: "doom_loop" } }, output }); | ||
| assert.equal(output.action, "deny"); | ||
| }); | ||
| it("exposes tool.execute.before hook", async () => { | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: tmpDir }); | ||
| assert.ok(typeof plugin["tool.execute.before"] === "function"); | ||
| }); | ||
| it("tool.execute.before blocks ~/.ssh/ files", async () => { | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: tmpDir }); | ||
| const sshKey = path.join(os.homedir(), ".ssh", "id_rsa"); | ||
| await assert.rejects( | ||
| () => plugin["tool.execute.before"]({ tool: "read" }, { args: { filePath: sshKey } }), | ||
| /Blocked access to sensitive file/, | ||
| ); | ||
| }); | ||
| it("tool.execute.before blocks ~/.ssh directory itself", async () => { | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: tmpDir }); | ||
| const sshDir = path.join(os.homedir(), ".ssh"); | ||
| await assert.rejects( | ||
| () => plugin["tool.execute.before"]({ tool: "read" }, { args: { filePath: sshDir } }), | ||
| /Blocked access to sensitive file/, | ||
| ); | ||
| }); | ||
| it("tool.execute.before blocks ~/.npmrc", async () => { | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: tmpDir }); | ||
| const npmrc = path.join(os.homedir(), ".npmrc"); | ||
| await assert.rejects( | ||
| () => plugin["tool.execute.before"]({ tool: "read" }, { args: { filePath: npmrc } }), | ||
| /Blocked access to sensitive file/, | ||
| ); | ||
| }); | ||
| it("tool.execute.before blocks ~/.netrc", async () => { | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: tmpDir }); | ||
| const netrc = path.join(os.homedir(), ".netrc"); | ||
| await assert.rejects( | ||
| () => plugin["tool.execute.before"]({ tool: "read" }, { args: { filePath: netrc } }), | ||
| /Blocked access to sensitive file/, | ||
| ); | ||
| }); | ||
| it("tool.execute.before allows project-level .npmrc (not in home dir)", async () => { | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: tmpDir }); | ||
| // Should not throw — project .npmrc is not a credential file | ||
| await plugin["tool.execute.before"]({ tool: "read" }, { args: { filePath: path.join(tmpDir, ".npmrc") } }); | ||
| }); | ||
| it("tool.execute.before allows normal files", async () => { | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: tmpDir }); | ||
| // Should not throw | ||
| await plugin["tool.execute.before"]({ tool: "read" }, { args: { filePath: "/tmp/some-file.txt" } }); | ||
| }); | ||
| it("tool.execute.before is a no-op when filePath is absent", async () => { | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: tmpDir }); | ||
| // Should not throw | ||
| await plugin["tool.execute.before"]({ tool: "bash" }, { args: {} }); | ||
| }); | ||
| it("exposes experimental.session.compacting hook", async () => { | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: tmpDir }); | ||
| assert.ok(typeof plugin["experimental.session.compacting"] === "function"); | ||
| }); | ||
| it("experimental.session.compacting is a no-op when no zooplankton.json", async () => { | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: tmpDir }); | ||
| const output = { context: [] }; | ||
| await plugin["experimental.session.compacting"]({}, output); | ||
| assert.equal(output.context.length, 0); | ||
| }); | ||
| it("experimental.session.compacting injects project config into context", async () => { | ||
| const dir2 = makeTmpDir(); | ||
| writeFixture(dir2, "zooplankton.json", JSON.stringify({ | ||
| project: { platform: "github", repo: "Org/repo" }, | ||
| autonomousMode: false, | ||
| })); | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: dir2 }); | ||
| const output = { context: [] }; | ||
| await plugin["experimental.session.compacting"]({}, output); | ||
| assert.equal(output.context.length, 1); | ||
| assert.ok(output.context[0].includes("platform: github")); | ||
| assert.ok(output.context[0].includes("repo: Org/repo")); | ||
| assert.ok(output.context[0].includes("autonomousMode: false")); | ||
| fs.rmSync(dir2, { recursive: true, force: true }); | ||
| }); | ||
| it("experimental.session.compacting omits absent fields", async () => { | ||
| const dir2 = makeTmpDir(); | ||
| writeFixture(dir2, "zooplankton.json", JSON.stringify({ autonomousMode: true })); | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: dir2 }); | ||
| const output = { context: [] }; | ||
| await plugin["experimental.session.compacting"]({}, output); | ||
| assert.equal(output.context.length, 1); | ||
| assert.ok(!output.context[0].includes("platform")); | ||
| assert.ok(!output.context[0].includes("repo:")); | ||
| assert.ok(output.context[0].includes("autonomousMode: true")); | ||
| fs.rmSync(dir2, { recursive: true, force: true }); | ||
| }); | ||
| it("exposes event hook", async () => { | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: tmpDir }); | ||
| assert.ok(typeof plugin.event === "function"); | ||
| }); | ||
| it("event hook is a no-op for non-idle events", async () => { | ||
| const toastCalls = []; | ||
| const mockClient = { tui: { showToast: async (opts) => { toastCalls.push(opts); return true; } } }; | ||
| const dir2 = makeTmpDir(); | ||
| writeFixture(dir2, "zooplankton.json", JSON.stringify({ notifications: true })); | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: dir2, client: mockClient }); | ||
| await plugin.event({ event: { type: "session.created" } }); | ||
| assert.equal(toastCalls.length, 0); | ||
| fs.rmSync(dir2, { recursive: true, force: true }); | ||
| }); | ||
| it("event hook is a no-op when notifications is false", async () => { | ||
| const toastCalls = []; | ||
| const mockClient = { tui: { showToast: async (opts) => { toastCalls.push(opts); return true; } } }; | ||
| const dir2 = makeTmpDir(); | ||
| writeFixture(dir2, "zooplankton.json", JSON.stringify({ notifications: false })); | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: dir2, client: mockClient }); | ||
| await plugin.event({ event: { type: "session.idle" } }); | ||
| assert.equal(toastCalls.length, 0); | ||
| fs.rmSync(dir2, { recursive: true, force: true }); | ||
| }); | ||
| it("event hook shows toast on session.idle when notifications: true", async () => { | ||
| const toastCalls = []; | ||
| const mockClient = { tui: { showToast: async (opts) => { toastCalls.push(opts); return true; } } }; | ||
| const dir2 = makeTmpDir(); | ||
| writeFixture(dir2, "zooplankton.json", JSON.stringify({ notifications: true })); | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: dir2, client: mockClient }); | ||
| await plugin.event({ event: { type: "session.idle" } }); | ||
| assert.equal(toastCalls.length, 1); | ||
| assert.equal(toastCalls[0].body.variant, "info"); | ||
| fs.rmSync(dir2, { recursive: true, force: true }); | ||
| }); | ||
| it("event hook is a no-op when client is absent", async () => { | ||
| const dir2 = makeTmpDir(); | ||
| writeFixture(dir2, "zooplankton.json", JSON.stringify({ notifications: true })); | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: dir2 }); | ||
| // Should not throw | ||
| await plugin.event({ event: { type: "session.idle" } }); | ||
| fs.rmSync(dir2, { recursive: true, force: true }); | ||
| }); | ||
| it("event hook silently ignores toast errors", async () => { | ||
| const mockClient = { tui: { showToast: async () => { throw new Error("TUI not running"); } } }; | ||
| const dir2 = makeTmpDir(); | ||
| writeFixture(dir2, "zooplankton.json", JSON.stringify({ notifications: true })); | ||
| const plugin = await ZooplanktonCodingPlugin({ directory: dir2, client: mockClient }); | ||
| // Should not throw even when showToast fails | ||
| await plugin.event({ event: { type: "session.idle" } }); | ||
| fs.rmSync(dir2, { recursive: true, force: true }); | ||
| }); | ||
| }); | ||
| // SUITE: parseReviewerResult (Reviewer Result Protocol v1) | ||
| // The parser is the orchestrator's trust boundary for reviewer task returns. | ||
| // Semantics follow plan "Structured Reviewer Result Protocol" (PR-1 scope): | ||
| // malformed → fail-closed (ok:false, findings:[], visible errors); | ||
| // unknown schema_version → empty findings + warning; | ||
| // legacy `scope` field aliased to `anchor_scope` with deprecation warning; | ||
| // review_kind absent → defaults to "code" for back-compat. |
| /** | ||
| * Unit tests for .opencode/plugins/opencode-plugin-zooplankton.js | ||
| * (ZooplanktonPlugin back-compat export + _createPlugin: experimental.chat.system.transform hook). | ||
| * | ||
| * Uses Node.js built-in test runner (node:test + node:assert). Zero new dependencies. | ||
| */ | ||
| import { describe, it, beforeEach } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import { ZooplanktonPlugin, _createPlugin } from "../.opencode/plugins/opencode-plugin-zooplankton.js"; | ||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| const repoRoot = path.resolve(__dirname, ".."); | ||
| const expectedPath = path.join(repoRoot, "instructions", "coding-standards.md"); | ||
| const codingStandardsContent = fs.readFileSync(expectedPath, "utf8").trim(); | ||
| describe("ZooplanktonPlugin", () => { | ||
| let plugin; | ||
| beforeEach(async () => { | ||
| plugin = await ZooplanktonPlugin(); | ||
| }); | ||
| it("exports experimental.chat.system.transform as a function (no config hook)", async () => { | ||
| assert.equal(typeof plugin["experimental.chat.system.transform"], "function"); | ||
| assert.equal(plugin.config, undefined, "config hook must not exist to avoid OpenCode crash"); | ||
| }); | ||
| describe("experimental.chat.system.transform hook", () => { | ||
| const hookName = "experimental.chat.system.transform"; | ||
| it("injects coding standards into empty system array", async () => { | ||
| const output = { system: [] }; | ||
| await plugin[hookName]({}, output); | ||
| assert.equal(output.system.length, 1); | ||
| assert.equal(output.system[0], codingStandardsContent); | ||
| }); | ||
| it("appends to existing system entries", async () => { | ||
| const output = { system: ["You are a helpful assistant."] }; | ||
| await plugin[hookName]({}, output); | ||
| assert.equal(output.system.length, 2); | ||
| assert.equal(output.system[0], "You are a helpful assistant."); | ||
| assert.equal(output.system[1], codingStandardsContent); | ||
| }); | ||
| it("skips injection when content is already present", async () => { | ||
| const output = { | ||
| system: ["Instructions from: ...\n" + codingStandardsContent], | ||
| }; | ||
| await plugin[hookName]({}, output); | ||
| assert.equal(output.system.length, 1); | ||
| }); | ||
| it("skips injection when content is embedded in a longer entry", async () => { | ||
| const output = { | ||
| system: [ | ||
| "Some preamble\n\n" + codingStandardsContent + "\n\nMore text", | ||
| ], | ||
| }; | ||
| await plugin[hookName]({}, output); | ||
| assert.equal(output.system.length, 1); | ||
| }); | ||
| it("works with subagent-like input containing sessionID", async () => { | ||
| const input = { sessionID: "ses_abc123", model: { id: "test-model" } }; | ||
| const output = { system: ["Subagent prompt content."] }; | ||
| await plugin[hookName](input, output); | ||
| assert.equal(output.system.length, 2); | ||
| assert.equal(output.system[1], codingStandardsContent); | ||
| }); | ||
| it("does not duplicate on repeated calls", async () => { | ||
| const output = { system: [] }; | ||
| await plugin[hookName]({}, output); | ||
| await plugin[hookName]({}, output); | ||
| // Second call should detect content is already present and skip | ||
| assert.equal(output.system.length, 1); | ||
| }); | ||
| it("handles null output without throwing", async () => { | ||
| await assert.doesNotReject(() => plugin[hookName]({}, null)); | ||
| }); | ||
| it("handles output with missing system array without throwing", async () => { | ||
| await assert.doesNotReject(() => plugin[hookName]({}, {})); | ||
| await assert.doesNotReject(() => plugin[hookName]({}, { system: "str" })); | ||
| }); | ||
| it("handles non-string elements in system array without throwing", async () => { | ||
| const output = { system: [42, null, "__UNRELATED_MARKER__"] }; | ||
| await assert.doesNotReject(() => plugin[hookName]({}, output)); | ||
| // Should still append since no string element contains the coding standards | ||
| assert.equal(output.system.length, 4); | ||
| assert.equal(output.system[3], codingStandardsContent); | ||
| }); | ||
| it("deduplicates correctly with CRLF line endings in system entries", async () => { | ||
| const crlfContent = codingStandardsContent.replace(/\n/g, "\r\n"); | ||
| const output = { system: [crlfContent] }; | ||
| await plugin[hookName]({}, output); | ||
| // Should detect as duplicate despite CRLF vs LF difference | ||
| assert.equal(output.system.length, 1); | ||
| }); | ||
| }); | ||
| describe("coding-standards.md content", () => { | ||
| it("contains skeleton-then-replace rule", () => { | ||
| assert.ok(codingStandardsContent.includes("Skeleton-then-Replace")); | ||
| }); | ||
| it("contains prefer-edit rule", () => { | ||
| assert.ok(codingStandardsContent.includes("Prefer Edit Over Write")); | ||
| }); | ||
| it("contains prefer-master rule", () => { | ||
| assert.ok( | ||
| codingStandardsContent.includes("Prefer `master` Over `main`"), | ||
| ); | ||
| }); | ||
| it("contains context-handoff rule (Rule 4)", () => { | ||
| assert.ok( | ||
| codingStandardsContent.includes("Context Handoff via Temp Files"), | ||
| "Rule 4 heading must be present", | ||
| ); | ||
| assert.ok( | ||
| codingStandardsContent.includes("zooplankton-handoff-"), | ||
| "Rule 4 must document the temp file naming convention", | ||
| ); | ||
| }); | ||
| it("contains web-research-tools rule (Rule 5)", () => { | ||
| assert.ok( | ||
| codingStandardsContent.includes("Web Research Tools"), | ||
| "Rule 5 heading must be present", | ||
| ); | ||
| assert.ok( | ||
| codingStandardsContent.includes("context7"), | ||
| "Rule 5 must mention Context7", | ||
| ); | ||
| assert.ok( | ||
| codingStandardsContent.includes("gh_grep"), | ||
| "Rule 5 must mention gh_grep", | ||
| ); | ||
| }); | ||
| }); | ||
| }); | ||
| describe("_createPlugin with empty content (file-missing degradation)", () => { | ||
| const hookName = "experimental.chat.system.transform"; | ||
| let emptyPlugin; | ||
| beforeEach(() => { | ||
| emptyPlugin = _createPlugin(""); | ||
| }); | ||
| it("transform hook does not inject when content is empty", async () => { | ||
| const output = { system: ["existing"] }; | ||
| await emptyPlugin[hookName]({}, output); | ||
| assert.equal(output.system.length, 1); | ||
| assert.equal(output.system[0], "existing"); | ||
| }); | ||
| it("transform hook does not inject into empty system array when content is empty", async () => { | ||
| const output = { system: [] }; | ||
| await emptyPlugin[hookName]({}, output); | ||
| assert.equal(output.system.length, 0); | ||
| }); | ||
| }); |
@@ -120,19 +120,117 @@ /** | ||
| export const renderConsolidatedReviewBody = ({ title, marker, findings, supersedes = null }) => { | ||
| const lines = [marker, "", `### ${sanitizeInlineMarkdown(title)}`]; | ||
| const grouped = new Map(); | ||
| for (const finding of Array.isArray(findings) ? findings : []) { | ||
| const key = finding.reviewer_model || finding.reviewer_name || finding.sources?.[0]?.reviewer_model || "reviewer"; | ||
| if (!grouped.has(key)) grouped.set(key, []); | ||
| grouped.get(key).push(finding); | ||
| // Compute the "why it matters" sentence deterministically from blocking findings. | ||
| // Dedup by (area || path), preserve first-occurrence order. Return null if empty. | ||
| const computeWhyItMatters = (findings) => { | ||
| const seen = new Set(); | ||
| const ordered = []; | ||
| for (const finding of findings) { | ||
| if (finding?.severity !== "blocking") continue; | ||
| const key = finding.area || finding.path || null; | ||
| if (!key) continue; | ||
| const sanitized = sanitizeInlineMarkdown(key); | ||
| if (!sanitized || seen.has(sanitized)) continue; | ||
| seen.add(sanitized); | ||
| ordered.push(sanitized); | ||
| } | ||
| for (const [reviewer, items] of grouped.entries()) { | ||
| lines.push("", `**${sanitizeInlineMarkdown(reviewer)}:**`); | ||
| items.forEach((finding, index) => { | ||
| lines.push(`${index + 1}. [${sanitizeInlineMarkdown(finding.cluster_id)}] [${sanitizeInlineMarkdown(finding.severity)}] ${sanitizeInlineMarkdown(finding.summary)} - ${sanitizeInlineMarkdown(formatFindingLocation(finding))}`); | ||
| }); | ||
| if (ordered.length === 0) return null; | ||
| return `Blocking issues span: ${ordered.join(", ")}. See inline comments for details.`; | ||
| }; | ||
| const renderFindingLine = (finding) => | ||
| `- [${sanitizeInlineMarkdown(finding?.cluster_id)}] [${sanitizeInlineMarkdown(finding?.severity)}] ${sanitizeInlineMarkdown(finding?.summary)} — ${sanitizeInlineMarkdown(formatFindingLocation(finding))}`; | ||
| const renderFindingBlock = (finding) => { | ||
| const head = renderFindingLine(finding); | ||
| if (!finding?.detail) return head; | ||
| return `${head}\n\n${sanitizeBlockMarkdown(finding.detail)}`; | ||
| }; | ||
| /** | ||
| * Render the consolidated review `body` per the spec in | ||
| * skills/orchestrate/references/review-protocol.md (§ Body structure). | ||
| * | ||
| * Signature: positional `findings, reviewers` plus an options object. | ||
| * - `findings` — the consolidated cluster list (already deduped + cluster-IDed). | ||
| * - `reviewers` — array of `{ name, model }` objects used for the attribution line. | ||
| * - options: | ||
| * - `marker` — required marker string (from `createReviewMarker`). | ||
| * - `supersedes` — optional fingerprint of a superseded prior post. | ||
| * - `includeLineAnchored` — Tier 3 fallback flag. When `true`, line-anchored | ||
| * findings are appended to the body in full prose. | ||
| * Default `false` produces the compact body. | ||
| * | ||
| * Breaking change vs. the previous `({ title, marker, findings, supersedes })` | ||
| * shape: `title` is removed (the marker + summary header replace it) and the | ||
| * positional findings/reviewers args are now required. | ||
| */ | ||
| export const renderConsolidatedReviewBody = (findings, reviewers, { marker, supersedes = null, includeLineAnchored = false } = {}) => { | ||
| if (typeof marker !== "string" || marker.length === 0) { | ||
| throw new TypeError( | ||
| "renderConsolidatedReviewBody: `marker` is required and must be a non-empty string. " + | ||
| "Pass the value returned by createReviewMarker({ pr, round, fingerprint }) via options.marker." | ||
| ); | ||
| } | ||
| // Validate marker format to catch programmer errors that would silently break | ||
| // idempotency (hasReviewMarker matches by exact line content). Accepts the two | ||
| // shapes emitted by createReviewMarker: code review and security review. | ||
| if (!/^<!-- zooplankton-orchestrate: pr=\d+ (?:round=\d+|phase=security) fingerprint=[0-9a-f]{64} -->$/.test(marker)) { | ||
| throw new TypeError( | ||
| "renderConsolidatedReviewBody: `marker` does not match the expected format produced by " + | ||
| "createReviewMarker(). Construct it via createReviewMarker({ pr, round, fingerprint }) " + | ||
| "instead of building it by hand." | ||
| ); | ||
| } | ||
| const list = Array.isArray(findings) ? findings : []; | ||
| const reviewerList = Array.isArray(reviewers) ? reviewers : []; | ||
| const blocking = list.filter((f) => f?.severity === "blocking"); | ||
| const advisory = list.filter((f) => f?.severity === "advisory"); | ||
| const fileBranch = list.filter((f) => f?.anchor_scope === "file" || f?.anchor_scope === "branch"); | ||
| const lineAnchored = list.filter((f) => f?.anchor_scope === "line"); | ||
| const lines = [marker, ""]; | ||
| lines.push(`Total findings: ${list.length}`); | ||
| if (blocking.length > 0) lines.push(`- blocking: ${blocking.length}`); | ||
| if (advisory.length > 0) lines.push(`- advisory: ${advisory.length}`); | ||
| const why = computeWhyItMatters(list); | ||
| if (why) { | ||
| lines.push("", why); | ||
| } | ||
| if (reviewerList.length > 0) { | ||
| const attribution = reviewerList | ||
| .map((r) => { | ||
| const name = sanitizeInlineMarkdown(r?.name ?? "reviewer"); | ||
| const model = r?.model ? ` (${sanitizeInlineMarkdown(r.model)})` : ""; | ||
| return `${name}${model}`; | ||
| }) | ||
| .join(", "); | ||
| lines.push("", `Reviewers: ${attribution}`); | ||
| } | ||
| if (list.length > 0) { | ||
| lines.push("", "See inline comments below for line-by-line details. File-/branch-scope findings are listed in the next section."); | ||
| } | ||
| if (fileBranch.length > 0) { | ||
| lines.push("", "### File-/branch-scope findings", ""); | ||
| for (const finding of fileBranch) lines.push(renderFindingLine(finding)); | ||
| } | ||
| if (includeLineAnchored && lineAnchored.length > 0) { | ||
| lines.push("", "### Line-anchored findings (body-only fallback)", ""); | ||
| for (const finding of lineAnchored) { | ||
| lines.push(renderFindingBlock(finding)); | ||
| lines.push(""); | ||
| } | ||
| // Trim trailing blank from the last block. | ||
| if (lines[lines.length - 1] === "") lines.pop(); | ||
| } | ||
| if (supersedes) { | ||
| lines.push("", `Supersedes earlier review (fingerprint ${sanitizeInlineMarkdown(supersedes).slice(0, 8)}); see comment timestamps for ordering.`); | ||
| } | ||
| return lines.join("\n"); | ||
@@ -139,0 +237,0 @@ }; |
+2
-3
| { | ||
| "name": "opencode-plugin-zooplankton", | ||
| "version": "0.6.2", | ||
| "version": "0.6.3", | ||
| "description": "Unified global OpenCode plugin for ZooplanktonAI — coding standards, multi-agent skills (brainstorm / plan / orchestrate), commands, guides, and the autonomous-mode permission hook.", | ||
@@ -8,3 +8,3 @@ "type": "module", | ||
| "scripts": { | ||
| "test": "node --test 'test/**/*.test.js' 'tests/**/*.test.js'" | ||
| "test": "node --test 'tests/**/*.test.js'" | ||
| }, | ||
@@ -22,3 +22,2 @@ "license": "MIT", | ||
| "templates/", | ||
| "test/", | ||
| "tests/" | ||
@@ -25,0 +24,0 @@ ], |
@@ -111,3 +111,3 @@ # Review Protocol Reference | ||
| "event": "COMMENT", | ||
| "body": "<marker + per-reviewer prose headers + file/branch-scope findings>", | ||
| "body": "<marker + high-level summary header + file/branch-scope findings>", | ||
| "comments": [ | ||
@@ -120,7 +120,46 @@ { "path": "src/foo.ts", "position": 17, "body": "[R2-C03] [blocking] …" } | ||
| - Each `anchor_scope: "line"` finding becomes one entry in `comments[]`. **The `POST /pulls/{pull_number}/reviews` endpoint requires `comments[].position` (the diff position offset), not the file's `line`/`side`.** To resolve a file path + line number to a `position`, fetch the PR's unified diff with `gh pr diff $PR_NUMBER` and count: `position` is the count of lines from the file's first hunk header (`@@ ... @@`) through the target line in that diff, where the line **immediately below** the `@@` line is `position` 1, the next is `position` 2, and so on. Counting includes context, addition (`+`), and deletion (`-`) lines, and continues across additional hunks within the same file (the `@@` separator lines themselves are also counted). When the same line appears across re-hunks (e.g. on a force-push), recompute against the current diff before posting. | ||
| - Each `anchor_scope: "file"` and `anchor_scope: "branch"` finding is rendered into the review `body` only — GitHub has no per-file anchor for review comments outside hunks, so file/branch-scope findings live in the summary section. | ||
| - The summary `body` always begins with the zooplankton marker followed by per-reviewer prose headers (`**[Round N] <model-id>:**`). | ||
| - Each `anchor_scope: "file"` and `anchor_scope: "branch"` finding is rendered into the review `body` only — GitHub has no per-file anchor for review comments outside hunks, so file/branch-scope findings live in the body's "File-/branch-scope findings" section. | ||
| > **Note on `line`/`side`:** The separate per-comment endpoint `POST /repos/{owner}/{repo}/pulls/{pull_number}/comments` (used to create a single review comment outside a review object) accepts `line` + `side` directly and does not require diff-position computation. The consolidated review endpoint we use (`POST /pulls/{pull_number}/reviews`) does **not** — its `comments[]` payloads must use `position`. | ||
| ### Body structure (high-level summary) | ||
| The review `body` is a high-level executive summary — it tells the PR author *at a glance* what the review found and points them at the inline comments for detail. It does **not** repeat per-finding prose for line-anchored findings. The body is composed of four sections, in order: | ||
| 1. **Zooplankton marker** — `<!-- zooplankton-orchestrate: pr=<n> round=<n> fingerprint=<sha> -->`. Always first, on its own line. | ||
| 2. **Summary header** — derived deterministically (no LLM) from the consolidated cluster list: | ||
| - **Total finding count** — `Total findings: <n>`. | ||
| - **Severity breakdown** — one row per non-zero severity. Only the two valid values `blocking` and `advisory` (the reviewer schema defines no others). Omit a row whose count is zero. | ||
| - **Why it matters** — a single fixed-template sentence generated from the **blocking** findings only: | ||
| - Collect the unique `area` values from all `severity === "blocking"` findings, preserving first-occurrence order. Fall back to the finding's `path` when `area` is absent; if both are absent, skip that finding for this computation only (it still counts toward the severity total). | ||
| - If the resulting list is non-empty: emit `Blocking issues span: <areas>. See inline comments for details.` (`<areas>` is the comma-separated list, no Oxford comma, no trailing period inside the list). | ||
| - If the list is empty (zero blocking findings): omit this sentence entirely. Do **not** emit a placeholder like "No blocking issues" — the severity-count line already conveys this. | ||
| - **Reviewer attribution line** — `Reviewers: <name> (<model-id>), <name> (<model-id>), …`. Names + model IDs only, no per-reviewer prose. | ||
| - **Pointer line** — `See inline comments below for line-by-line details. File-/branch-scope findings are listed in the next section.` | ||
| 3. **File-/branch-scope findings section** — every `anchor_scope: "file"` and `anchor_scope: "branch"` finding rendered in full, each prefixed with its cluster ID and severity (`[<cluster_id>] [<severity>] <summary> — <path-or-branch-scope>`). Required because GitHub has no per-file anchor outside hunks. | ||
| 4. **Line-anchored findings (Tier 3 only)** — when the orchestrator falls back to a body-only review (Tier 3 retry, see below), every `anchor_scope: "line"` finding is appended here in full prose so nothing is lost. In the default (non-fallback) body, this section is omitted. | ||
| The per-reviewer prose dump (`**[Round N] <model-id>:** …` followed by per-finding prose) used in the previous spec is **removed** — the attribution line in §2 replaces the per-reviewer headers, and the per-finding prose lives only in `comments[]` for line-anchored findings or in §3 for file/branch-scope findings. | ||
| Example body (default, non-fallback): | ||
| ```markdown | ||
| <!-- zooplankton-orchestrate: pr=123 round=2 fingerprint=abc... --> | ||
| Total findings: 5 | ||
| - blocking: 2 | ||
| - advisory: 3 | ||
| Blocking issues span: auth, billing. See inline comments for details. | ||
| Reviewers: core-reviewer (claude-sonnet-4.6), reviewer (glm-4.6) | ||
| See inline comments below for line-by-line details. File-/branch-scope findings are listed in the next section. | ||
| ### File-/branch-scope findings | ||
| - [R2-C04] [advisory] Update README to mention new flag — README.md | ||
| - [R2-C05] [advisory] Tag release after merge — branch-scope | ||
| ``` | ||
| Example invocation (single-quoted heredoc to prevent shell expansion): | ||
@@ -133,3 +172,3 @@ | ||
| "event": "COMMENT", | ||
| "body": "<!-- zooplankton-orchestrate: pr=123 round=2 fingerprint=abc -->\n\n**[Round 2] core-reviewer (claude-sonnet-4.6):** …", | ||
| "body": "<!-- zooplankton-orchestrate: pr=123 round=2 fingerprint=abc -->\n\nTotal findings: 5\n- blocking: 2\n- advisory: 3\n\nBlocking issues span: auth, billing. See inline comments for details.\n\nReviewers: core-reviewer (claude-sonnet-4.6), reviewer (glm-4.6)\n\nSee inline comments below for line-by-line details. File-/branch-scope findings are listed in the next section.\n\n### File-/branch-scope findings\n\n- [R2-C04] [advisory] Update README to mention new flag — README.md", | ||
| "comments": [ | ||
@@ -156,3 +195,3 @@ { "path": "src/foo.ts", "position": 17, "body": "[R2-C03] [blocking] Null deref when `config.repo` is absent." } | ||
| 1. Build the body with `renderConsolidatedReviewBody(...)`. | ||
| 1. Build the body with `renderConsolidatedReviewBody(findings, reviewers, { marker })` (default `{ includeLineAnchored: false }`). `marker` is the string returned by `createReviewMarker({ pr, round, fingerprint })` and is required — the renderer throws `TypeError` if it is missing or empty. | ||
| 2. Build endpoint-neutral line anchors with `buildInlineCommentAnchors(findings)`. Each anchor has shape `{ path, position: null, _line, body }` — `position` is `null` as a placeholder; `_line` is the file line number retained as a resolution hint. Do NOT forward `_line` to GitHub. | ||
@@ -162,3 +201,3 @@ 3. Before calling GitHub, resolve each anchor's `position`. `POST /pulls/{pull_number}/reviews` review comments require `path` + `position` (with `commit_id` at the top level) — `position` is the diff-position integer described above, not the file line number. Use `_line` to find the target line in the unified diff, then count positions from the hunk header. The legacy `POST /pulls/{pull_number}/comments` per-comment endpoint (which does accept `path` + `line` + `side`) is **not** used by the consolidated post. | ||
| 5. Tier 2: deterministically halve inline comments and move the rest into the body with `planReviewPostRetry(comments, 2)`. | ||
| 6. Tier 3: body-only review (omit `comments[]` entirely, render every finding in `body`) with `planReviewPostRetry(comments, 3)`. | ||
| 6. Tier 3: body-only review (omit `comments[]` entirely) with `planReviewPostRetry(comments, 3)`. The orchestrator MUST re-render the body via `renderConsolidatedReviewBody(findings, reviewers, { marker, includeLineAnchored: true })` before re-posting so line-anchored findings are appended to the body in full prose. `planReviewPostRetry` itself does not call the renderer — it only computes the retry tier; the orchestrator inspects the planned tier and chooses the renderer flag. | ||
@@ -165,0 +204,0 @@ If all tiers fail, leave the local state marker as `IN_PROGRESS` and abort before evaluate/merge. |
@@ -120,6 +120,17 @@ --- | ||
| Invoke **all reviewers in a single parallel message** (one Task call per reviewer). Read `agents.coreReviewers` and `agents.reviewers` from `zooplankton.json` (or discover from MD files). Each entry is a `{ name }` object — use `.name` for `@<name>`. Skip the role if `null`; skip individual entries with `"disabled": true`. | ||
| Invoke **all reviewers in a single parallel message** (one Task call per reviewer). | ||
| - **Core reviewers** (blocking quorum): for each entry in `agents.coreReviewers`, invoke `@<entry.name>` and assign worktree `.worktrees/<branch>/<name>`. | ||
| - **Normal reviewers** (non-blocking, opportunistic): for each `agents.reviewers` entry, invoke `@<entry.name>` and assign 1–2 review areas via adaptive round-robin. | ||
| **Agent discovery (run fresh every round — do NOT rely on memory or prior-round lists):** | ||
| 1. Read `agents.coreReviewers`, `agents.reviewers`, and `agents.frontendReviewers` from `zooplankton.json` if the `agents` block is present. | ||
| 2. If any role key is absent from `zooplankton.json` (or the `agents` block is missing entirely), discover agents by globbing agent MD files: | ||
| - Core reviewers: `~/.config/opencode/agents/core-reviewer-*.md` and `.opencode/agents/core-reviewer-*.md` | ||
| - Normal reviewers: `~/.config/opencode/agents/reviewer-*.md` and `.opencode/agents/reviewer-*.md` | ||
| - Frontend reviewers: `~/.config/opencode/agents/frontend-reviewer-*.md` and `.opencode/agents/frontend-reviewer-*.md` | ||
| 3. **De-duplicate** across global and project paths by filename stem (e.g. `reviewer-glm` from either location counts once). | ||
| 4. **Always enumerate all discovered agents** — never omit an agent because it returned empty in a prior round. Empty results in a prior round are not a reason to skip in subsequent rounds. | ||
| 5. Each entry is a `{ name }` object — use `.name` for `@<name>`. Skip the role if `null`; skip individual entries with `"disabled": true`. | ||
| - **Core reviewers** (blocking quorum): for each entry in `agents.coreReviewers` (or discovered `core-reviewer-*`), invoke `@<entry.name>` and assign worktree `.worktrees/<branch>/<name>`. | ||
| - **Normal reviewers** (non-blocking, opportunistic): for each `agents.reviewers` entry (or discovered `reviewer-*`), invoke `@<entry.name>` and assign 1–2 review areas via adaptive round-robin. | ||
| - **Frontend reviewers** (blocking when frontend files are touched): if `agents.frontendReviewers` is not null **and** the change touches frontend files, invoke each non-disabled entry. They follow `guides/frontend-reviewer-guide.md` and join the quorum. | ||
@@ -126,0 +137,0 @@ |
| import { describe, it, beforeEach } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import { ZooplanktonPlugin, _createPlugin } from "../.opencode/plugins/opencode-plugin-zooplankton.js"; | ||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| const repoRoot = path.resolve(__dirname, ".."); | ||
| const expectedPath = path.join(repoRoot, "instructions", "coding-standards.md"); | ||
| const codingStandardsContent = fs.readFileSync(expectedPath, "utf8").trim(); | ||
| describe("ZooplanktonPlugin", () => { | ||
| let plugin; | ||
| beforeEach(async () => { | ||
| plugin = await ZooplanktonPlugin(); | ||
| }); | ||
| it("exports experimental.chat.system.transform as a function (no config hook)", async () => { | ||
| assert.equal(typeof plugin["experimental.chat.system.transform"], "function"); | ||
| assert.equal(plugin.config, undefined, "config hook must not exist to avoid OpenCode crash"); | ||
| }); | ||
| describe("experimental.chat.system.transform hook", () => { | ||
| const hookName = "experimental.chat.system.transform"; | ||
| it("injects coding standards into empty system array", async () => { | ||
| const output = { system: [] }; | ||
| await plugin[hookName]({}, output); | ||
| assert.equal(output.system.length, 1); | ||
| assert.equal(output.system[0], codingStandardsContent); | ||
| }); | ||
| it("appends to existing system entries", async () => { | ||
| const output = { system: ["You are a helpful assistant."] }; | ||
| await plugin[hookName]({}, output); | ||
| assert.equal(output.system.length, 2); | ||
| assert.equal(output.system[0], "You are a helpful assistant."); | ||
| assert.equal(output.system[1], codingStandardsContent); | ||
| }); | ||
| it("skips injection when content is already present", async () => { | ||
| const output = { | ||
| system: ["Instructions from: ...\n" + codingStandardsContent], | ||
| }; | ||
| await plugin[hookName]({}, output); | ||
| assert.equal(output.system.length, 1); | ||
| }); | ||
| it("skips injection when content is embedded in a longer entry", async () => { | ||
| const output = { | ||
| system: [ | ||
| "Some preamble\n\n" + codingStandardsContent + "\n\nMore text", | ||
| ], | ||
| }; | ||
| await plugin[hookName]({}, output); | ||
| assert.equal(output.system.length, 1); | ||
| }); | ||
| it("works with subagent-like input containing sessionID", async () => { | ||
| const input = { sessionID: "ses_abc123", model: { id: "test-model" } }; | ||
| const output = { system: ["Subagent prompt content."] }; | ||
| await plugin[hookName](input, output); | ||
| assert.equal(output.system.length, 2); | ||
| assert.equal(output.system[1], codingStandardsContent); | ||
| }); | ||
| it("does not duplicate on repeated calls", async () => { | ||
| const output = { system: [] }; | ||
| await plugin[hookName]({}, output); | ||
| await plugin[hookName]({}, output); | ||
| // Second call should detect content is already present and skip | ||
| assert.equal(output.system.length, 1); | ||
| }); | ||
| it("handles null output without throwing", async () => { | ||
| await assert.doesNotReject(() => plugin[hookName]({}, null)); | ||
| }); | ||
| it("handles output with missing system array without throwing", async () => { | ||
| await assert.doesNotReject(() => plugin[hookName]({}, {})); | ||
| await assert.doesNotReject(() => plugin[hookName]({}, { system: "str" })); | ||
| }); | ||
| it("handles non-string elements in system array without throwing", async () => { | ||
| const output = { system: [42, null, "__UNRELATED_MARKER__"] }; | ||
| await assert.doesNotReject(() => plugin[hookName]({}, output)); | ||
| // Should still append since no string element contains the coding standards | ||
| assert.equal(output.system.length, 4); | ||
| assert.equal(output.system[3], codingStandardsContent); | ||
| }); | ||
| it("deduplicates correctly with CRLF line endings in system entries", async () => { | ||
| const crlfContent = codingStandardsContent.replace(/\n/g, "\r\n"); | ||
| const output = { system: [crlfContent] }; | ||
| await plugin[hookName]({}, output); | ||
| // Should detect as duplicate despite CRLF vs LF difference | ||
| assert.equal(output.system.length, 1); | ||
| }); | ||
| }); | ||
| describe("coding-standards.md content", () => { | ||
| it("contains skeleton-then-replace rule", () => { | ||
| assert.ok(codingStandardsContent.includes("Skeleton-then-Replace")); | ||
| }); | ||
| it("contains prefer-edit rule", () => { | ||
| assert.ok(codingStandardsContent.includes("Prefer Edit Over Write")); | ||
| }); | ||
| it("contains prefer-master rule", () => { | ||
| assert.ok( | ||
| codingStandardsContent.includes("Prefer `master` Over `main`"), | ||
| ); | ||
| }); | ||
| it("contains context-handoff rule (Rule 4)", () => { | ||
| assert.ok( | ||
| codingStandardsContent.includes("Context Handoff via Temp Files"), | ||
| "Rule 4 heading must be present", | ||
| ); | ||
| assert.ok( | ||
| codingStandardsContent.includes("zooplankton-handoff-"), | ||
| "Rule 4 must document the temp file naming convention", | ||
| ); | ||
| }); | ||
| it("contains web-research-tools rule (Rule 5)", () => { | ||
| assert.ok( | ||
| codingStandardsContent.includes("Web Research Tools"), | ||
| "Rule 5 heading must be present", | ||
| ); | ||
| assert.ok( | ||
| codingStandardsContent.includes("context7"), | ||
| "Rule 5 must mention Context7", | ||
| ); | ||
| assert.ok( | ||
| codingStandardsContent.includes("gh_grep"), | ||
| "Rule 5 must mention gh_grep", | ||
| ); | ||
| }); | ||
| }); | ||
| }); | ||
| describe("_createPlugin with empty content (file-missing degradation)", () => { | ||
| const hookName = "experimental.chat.system.transform"; | ||
| let emptyPlugin; | ||
| beforeEach(() => { | ||
| emptyPlugin = _createPlugin(""); | ||
| }); | ||
| it("transform hook does not inject when content is empty", async () => { | ||
| const output = { system: ["existing"] }; | ||
| await emptyPlugin[hookName]({}, output); | ||
| assert.equal(output.system.length, 1); | ||
| assert.equal(output.system[0], "existing"); | ||
| }); | ||
| it("transform hook does not inject into empty system array when content is empty", async () => { | ||
| const output = { system: [] }; | ||
| await emptyPlugin[hookName]({}, output); | ||
| assert.equal(output.system.length, 0); | ||
| }); | ||
| }); |
Sorry, the diff of this file is too big to display
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
382743
7.61%62
21.57%3848
11.96%11
83.33%1
Infinity%