mirror of
https://github.com/EveryInc/compound-engineering-plugin.git
synced 2026-06-19 15:41:46 +02:00
2c29da8e65
The coding-tutor plugin is no longer offered for install on any platform. Drops it from all three marketplace catalogs (Claude, Cursor, Codex) and from release-please's config and manifest so no further releases are cut for it, and strips it from the release tooling (ReleaseComponent union, component detection, metadata sync, and the Codex parity check), leaving compound-engineering as the sole tracked plugin.
172 lines
7.3 KiB
TypeScript
172 lines
7.3 KiB
TypeScript
import { readdirSync, readFileSync, statSync } from "fs"
|
|
import path from "path"
|
|
import { describe, expect, test } from "bun:test"
|
|
import { load } from "js-yaml"
|
|
import { formatFrontmatter, parseFrontmatter } from "../src/utils/frontmatter"
|
|
|
|
describe("frontmatter", () => {
|
|
test("parseFrontmatter returns body when no frontmatter", () => {
|
|
const raw = "Hello\nWorld"
|
|
const result = parseFrontmatter(raw)
|
|
expect(result.data).toEqual({})
|
|
expect(result.body).toBe(raw)
|
|
})
|
|
|
|
test("formatFrontmatter round trips", () => {
|
|
const body = "Body text"
|
|
const formatted = formatFrontmatter({ name: "agent", description: "Test" }, body)
|
|
const parsed = parseFrontmatter(formatted)
|
|
expect(parsed.data.name).toBe("agent")
|
|
expect(parsed.data.description).toBe("Test")
|
|
expect(parsed.body.trim()).toBe(body)
|
|
})
|
|
|
|
})
|
|
|
|
/**
|
|
* Collect all markdown files with YAML frontmatter from a plugin directory.
|
|
* Returns [relativePath, yamlText] pairs for each file with a frontmatter block.
|
|
*/
|
|
function collectFrontmatterFiles(pluginRoot: string): [string, string][] {
|
|
const results: [string, string][] = []
|
|
|
|
function walk(dir: string) {
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
if (entry.name === "node_modules" || entry.name === ".git") continue
|
|
walk(full)
|
|
continue
|
|
}
|
|
if (!entry.name.endsWith(".md")) continue
|
|
const raw = readFileSync(full, "utf8")
|
|
const lines = raw.split(/\r?\n/)
|
|
if (lines[0]?.trim() !== "---") continue
|
|
let end = -1
|
|
for (let i = 1; i < lines.length; i++) {
|
|
if (lines[i].trim() === "---") { end = i; break }
|
|
}
|
|
if (end === -1) continue
|
|
const yaml = lines.slice(1, end).join("\n")
|
|
const rel = path.relative(pluginRoot, full)
|
|
results.push([rel, yaml])
|
|
}
|
|
}
|
|
|
|
walk(pluginRoot)
|
|
return results
|
|
}
|
|
|
|
describe("frontmatter YAML validity", () => {
|
|
const MAX_SKILL_DESCRIPTION_LENGTH = 1024
|
|
const pluginRoots = [
|
|
"plugins/compound-engineering",
|
|
]
|
|
|
|
for (const pluginRoot of pluginRoots) {
|
|
const root = path.join(process.cwd(), pluginRoot)
|
|
try { statSync(root) } catch { continue }
|
|
const files = collectFrontmatterFiles(root)
|
|
|
|
for (const [rel, yaml] of files) {
|
|
test(`${pluginRoot}/${rel} has valid strict YAML frontmatter`, () => {
|
|
expect(() => load(yaml)).not.toThrow()
|
|
})
|
|
|
|
test(`${pluginRoot}/${rel} description has no unwrapped angle-bracket tokens`, () => {
|
|
const parsed = load(yaml) as Record<string, unknown> | null
|
|
const description = parsed && typeof parsed.description === "string" ? parsed.description : ""
|
|
// Strip backtick-delimited spans; what remains must not contain a bare <tag>.
|
|
// Cowork's plugin validator parses descriptions as HTML and rejects
|
|
// unknown tags with a silent "Plugin validation failed" banner. See issue #602.
|
|
const stripped = description.replace(/`[^`]*`/g, "")
|
|
const bareTag = stripped.match(/<[A-Za-z][\w-]*>/)
|
|
expect(bareTag, `Backtick-wrap or rephrase: ${bareTag?.[0] ?? ""}`).toBeNull()
|
|
})
|
|
|
|
if (/^skills\/[^/]+\/SKILL\.md$/.test(rel)) {
|
|
test(`${pluginRoot}/${rel} skill description fits 1024-char harness limit`, () => {
|
|
const parsed = load(yaml) as Record<string, unknown> | null
|
|
const description = parsed && typeof parsed.description === "string" ? parsed.description : ""
|
|
expect(
|
|
[...description].length,
|
|
`Shorten description to ${MAX_SKILL_DESCRIPTION_LENGTH} chars or less`,
|
|
).toBeLessThanOrEqual(MAX_SKILL_DESCRIPTION_LENGTH)
|
|
})
|
|
|
|
// Pi rejects skill names that don't match the parent directory or contain
|
|
// characters outside [a-z0-9-]. Upgrading from a pre-v3 install with
|
|
// `name: ce:brainstorm` frontmatter in a renamed `ce-brainstorm` directory
|
|
// triggered issue #449. Catch any reintroduction at the source.
|
|
test(`${pluginRoot}/${rel} skill frontmatter name matches directory and uses valid characters`, () => {
|
|
const parsed = load(yaml) as Record<string, unknown> | null
|
|
const name = parsed && typeof parsed.name === "string" ? parsed.name : ""
|
|
const dirName = path.basename(path.dirname(rel))
|
|
expect(name, `frontmatter name must be present`).not.toBe("")
|
|
expect(name, `frontmatter name "${name}" must match parent directory "${dirName}"`).toBe(dirName)
|
|
expect(name, `frontmatter name "${name}" must be lowercase a-z, 0-9, and hyphens`).toMatch(/^[a-z0-9-]+$/)
|
|
})
|
|
|
|
// All compound-engineering skills (and agents) must use the `ce-` prefix
|
|
// so they are unambiguously identifiable as compound-engineering
|
|
// components. See plugins/compound-engineering/AGENTS.md "Naming
|
|
// Convention". A small allowlist preserves three pre-existing skills
|
|
// that predate the rule -- no new entries should be added.
|
|
if (pluginRoot === "plugins/compound-engineering") {
|
|
const SKILL_PREFIX_ALLOWLIST = new Set([
|
|
"every-style-editor",
|
|
"file-todos",
|
|
"lfg",
|
|
])
|
|
test(`${pluginRoot}/${rel} skill name uses ce- prefix`, () => {
|
|
const dirName = path.basename(path.dirname(rel))
|
|
if (SKILL_PREFIX_ALLOWLIST.has(dirName)) return
|
|
expect(
|
|
dirName.startsWith("ce-"),
|
|
`Skill "${dirName}" must use the ce- prefix. ` +
|
|
`If this is a legacy skill that predates the rule, add it to ` +
|
|
`SKILL_PREFIX_ALLOWLIST in tests/frontmatter.test.ts.`,
|
|
).toBe(true)
|
|
})
|
|
}
|
|
}
|
|
|
|
if (
|
|
pluginRoot === "plugins/compound-engineering" &&
|
|
/^agents\/[^/]+\.md$/.test(rel)
|
|
) {
|
|
test(`${pluginRoot}/${rel} agent name uses ce- prefix`, () => {
|
|
const fileName = path.basename(rel, ".md")
|
|
expect(
|
|
fileName.startsWith("ce-"),
|
|
`Agent "${fileName}" must use the ce- prefix.`,
|
|
).toBe(true)
|
|
})
|
|
|
|
// Pure document-reasoning reviewers operate on document text already
|
|
// passed in their prompt and do not look at the codebase. Granting
|
|
// Bash invites the model -- especially on weaker models like haiku --
|
|
// to externalize state via temp-file scratchpads, which can hang
|
|
// indefinitely on platforms whose bash tool blocks on heredocs (see
|
|
// issue #832 for the OpenCode coherence-reviewer stall).
|
|
const NO_BASH_AGENTS = new Set([
|
|
"ce-coherence-reviewer",
|
|
])
|
|
const agentName = path.basename(rel, ".md")
|
|
if (NO_BASH_AGENTS.has(agentName)) {
|
|
test(`${pluginRoot}/${rel} pure document reviewer must not allow Bash`, () => {
|
|
const parsed = load(yaml) as Record<string, unknown> | null
|
|
const tools = parsed && typeof parsed.tools === "string" ? parsed.tools : ""
|
|
const toolList = tools.split(",").map((s) => s.trim())
|
|
expect(
|
|
toolList.includes("Bash"),
|
|
`Agent "${agentName}" reviews documents from prompt context only and does ` +
|
|
`not need shell access. Remove Bash from the tools allowlist (see issue #832).`,
|
|
).toBe(false)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|