| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147 |
- #!/usr/bin/env node
- // Validate the generated opencode port. Run after tools/build-maister-opencode.mjs.
- //
- // Catches the two ways this port can rot: a maister release that introduces a new
- // Claude-Code-only construct, and a rewrite rule that mangles ordinary prose.
- import { readFileSync, readdirSync, statSync, existsSync } from "node:fs"
- import { join, dirname, relative } from "node:path"
- import { fileURLToPath } from "node:url"
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..")
- const OUT = join(ROOT, ".opencode")
- const failures = []
- const check = (ok, message) => {
- if (!ok) failures.push(message)
- }
- function walk(dir, out = []) {
- if (!existsSync(dir)) return out
- for (const entry of readdirSync(dir)) {
- const full = join(dir, entry)
- if (statSync(full).isDirectory()) walk(full, out)
- else out.push(full)
- }
- return out
- }
- const files = [
- ...walk(join(OUT, "skill")).filter((f) => f.endsWith(".md")),
- ...walk(join(OUT, "agent")).filter((f) => f.endsWith(".md")),
- ...walk(join(OUT, "command")).filter((f) => f.endsWith(".md")),
- ]
- check(files.length > 0, "no generated markdown found — run the build first")
- // --- 1. no Claude-Code-only constructs survive -----------------------------
- const FORBIDDEN = [
- ["AskUserQuestion", "Claude Code's user-question tool"],
- ["TaskCreate", "Claude Code's incremental task tool"],
- ["TaskUpdate", "Claude Code's incremental task tool"],
- ["TaskList", "Claude Code's task-list tool"],
- ["EnterPlanMode", "Claude Code's plan-mode tool"],
- ["ExitPlanMode", "Claude Code's plan-mode tool"],
- ["CLAUDE.md", "Claude Code's project instructions file"],
- ["CLAUDE_PLUGIN_ROOT", "Claude Code's plugin-root variable"],
- ["general-purpose", "Claude Code's general subagent"],
- ["maister:", "the Claude Code plugin namespace"],
- ]
- for (const file of files) {
- const text = readFileSync(file, "utf8")
- for (const [needle, what] of FORBIDDEN) {
- // the generated header documents the mapping on purpose
- const lines = text.split("\n").filter((l) => !l.includes("opencode port") && !l.trim().startsWith("> "))
- if (lines.some((l) => l.includes(needle))) {
- check(false, `${relative(ROOT, file)}: still mentions ${needle} (${what})`)
- }
- }
- }
- // --- 2. no mangled prose ---------------------------------------------------
- const SUSPECT = [
- [/\bquestion tool tool\b/, "duplicated 'tool'"],
- [/`explore` (the|a|an|for|to|and|this|it)\b/, "'Explore' verb rewritten as a subagent reference"],
- [/\bthe the\b/, "duplicated article"],
- [/\btool tool\b/, "duplicated 'tool'"],
- [/opencode's auto-approve permission mode, or\b/, "unfinished permission-mode rewrite"],
- ]
- for (const file of files) {
- const lines = readFileSync(file, "utf8").split("\n").filter((l) => !l.includes("opencode port") && !l.trim().startsWith("> "))
- for (const [pattern, what] of SUSPECT) {
- if (lines.some((l) => pattern.test(l))) check(false, `${relative(ROOT, file)}: ${what}`)
- }
- }
- // --- 3. every referenced skill and agent exists ----------------------------
- const skillNames = new Set(readdirSync(join(OUT, "skill")))
- const agentNames = new Set(
- readdirSync(join(OUT, "agent"))
- .filter((f) => f.endsWith(".md"))
- .map((f) => f.replace(/\.md$/, "")),
- )
- const commandNames = new Set(
- readdirSync(join(OUT, "command"))
- .filter((f) => f.endsWith(".md"))
- .map((f) => f.replace(/\.md$/, "")),
- )
- for (const file of files) {
- const text = readFileSync(file, "utf8")
- // every project-relative path into the generated tree must exist on disk
- for (const m of text.matchAll(/`(\.opencode\/skill\/[A-Za-z0-9._/-]+)`/g)) {
- check(existsSync(join(ROOT, m[1])), `${relative(ROOT, file)}: path does not exist: ${m[1]}`)
- }
- for (const m of text.matchAll(/(?<![\w./-])`(maister-[a-z0-9-]+)`/g)) {
- const name = m[1]
- // a name may be a skill, an agent or a command
- check(
- skillNames.has(name) || agentNames.has(name) || commandNames.has(name),
- `${relative(ROOT, file)}: references unknown identifier ${name}`,
- )
- }
- }
- // --- 4. frontmatter sanity -------------------------------------------------
- for (const file of walk(join(OUT, "skill")).filter((f) => f.endsWith(".md") && f.split("/").pop() === "SKILL.md")) {
- const text = readFileSync(file, "utf8")
- const fm = text.match(/^---\n([\s\S]*?)\n---\n/)
- check(!!fm, `${relative(ROOT, file)}: missing frontmatter`)
- if (!fm) continue
- check(/^name: [a-z0-9-]+$/m.test(fm[1]), `${relative(ROOT, file)}: skill name must be lowercase-hyphenated`)
- check(/^description: .+/m.test(fm[1]), `${relative(ROOT, file)}: skill has no description`)
- const dir = file.split("/").pop() === "SKILL.md" ? file.slice(0, file.lastIndexOf("/")) : ""
- const name = fm[1].match(/^name: (.+)$/m)?.[1]
- check(dir.endsWith(name), `${relative(ROOT, file)}: name "${name}" does not match its directory`)
- }
- for (const file of walk(join(OUT, "agent")).filter((f) => f.endsWith(".md"))) {
- const text = readFileSync(file, "utf8")
- const fm = text.match(/^---\n([\s\S]*?)\n---\n/)
- check(!!fm, `${relative(ROOT, file)}: missing frontmatter`)
- if (!fm) continue
- check(/^mode: subagent$/m.test(fm[1]), `${relative(ROOT, file)}: agent is not mode: subagent`)
- check(!/^name:/m.test(fm[1]), `${relative(ROOT, file)}: agent frontmatter must not set name (the filename does)`)
- check(!/^model:/m.test(fm[1]), `${relative(ROOT, file)}: agent frontmatter must not set model`)
- check(
- !/^color: (?!(primary|secondary|accent|success|warning|error|info|#[0-9a-fA-F]{6})$)/m.test(fm[1]),
- `${relative(ROOT, file)}: agent colour is not an opencode theme colour`,
- )
- }
- for (const file of walk(join(OUT, "command")).filter((f) => f.endsWith(".md"))) {
- const text = readFileSync(file, "utf8")
- check(text.includes("$ARGUMENTS"), `${relative(ROOT, file)}: command never mentions $ARGUMENTS`)
- }
- // --- 5. the hand-written pieces exist -------------------------------------
- check(existsSync(join(OUT, "plugin", "maister.ts")), "missing .opencode/plugin/maister.ts")
- check(existsSync(join(ROOT, "opencode.json")), "missing opencode.json")
- // --- report ---------------------------------------------------------------
- if (failures.length) {
- console.error(`FAIL — ${failures.length} problem(s):`)
- for (const f of failures) console.error(` - ${f}`)
- process.exit(1)
- }
- console.log(`OK — ${skillNames.size} skills, ${agentNames.size} agents, ${commandNames.size} commands validated`)
|