/**
* Tests for the skill-contracts-source module: frontmatter → contract builder
* and the guarded sibling registration.
*/
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, sep } from "node:path";
import {
__resetSkillContracts,
buildEffectiveContracts,
drainSkillContractCollisions,
flushSkillContractProviders,
harvestStageContracts,
} from "@juicesharp/rpiv-workflow/internal";
import type { ConsumesSpec, ProducesSpec, SkillContract } from "@juicesharp/rpiv-workflow/registration";
import { defineWorkflow, produces } from "@juicesharp/rpiv-workflow/registration";
import { afterEach, describe, expect, it, vi } from "vitest";
import { builtInWorkflows } from "./built-in-workflows.js";
import { BUNDLED_SKILLS_DIR } from "./paths.js";
import {
artifactKindComparator,
buildSkillContractsFromFrontmatter,
buildUserSkillContracts,
isBundledSkillPath,
normalizeContract,
registerSkillContractsSource,
} from "./skill-contracts-source.js";
/** Create a temp dir with skill subdirs; caller must rmSync when done. */
function makeSkillsDir(baseDir: string, skills: Record): string {
for (const [name, content] of Object.entries(skills)) {
const skillDir = join(baseDir, name);
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), content);
}
return baseDir;
}
describe("buildSkillContractsFromFrontmatter", () => {
let tmpDir: string;
afterEach(() => {
if (tmpDir) {
rmSync(tmpDir, { recursive: true, force: true });
tmpDir = "" as string;
}
});
it("returns [] for a skills dir whose SKILL.md files carry no contract: block", () => {
tmpDir = join(process.env.HOME!, `test-skills-no-contract-${Math.random().toString(36).slice(2)}`);
mkdirSync(tmpDir, { recursive: true });
makeSkillsDir(tmpDir, {
research: `---\ndescription: Research skill\n---\nBody here`,
plan: `---\ndescription: Plan skill\n---\nBody here`,
});
const result = buildSkillContractsFromFrontmatter(tmpDir);
expect(result).toEqual([]);
});
it("returns [name, {source:'declared', ...}] for a skill with a contract: block", () => {
tmpDir = join(process.env.HOME!, `test-skills-with-contract-${Math.random().toString(36).slice(2)}`);
mkdirSync(tmpDir, { recursive: true });
makeSkillsDir(tmpDir, {
research: `---
description: Research skill
contract:
produces:
kind: produces
data:
type: object
properties:
findings:
type: string
meta:
artifactKind: research
---
Body here`,
});
const result = buildSkillContractsFromFrontmatter(tmpDir);
expect(result).toHaveLength(1);
expect(result[0][0]).toBe("research");
expect(result[0][1]).toEqual({
source: "declared",
produces: {
kind: "produces",
data: { type: "object", properties: { findings: { type: "string" } } },
meta: { artifactKind: "research" },
},
});
});
it("returns [] for a nonexistent skills dir", () => {
const result = buildSkillContractsFromFrontmatter(
`/nonexistent/path/skills-${Math.random().toString(36).slice(2)}`,
);
expect(result).toEqual([]);
});
it("defaults produces.kind to 'produces' when omitted in frontmatter", () => {
tmpDir = join(process.env.HOME!, `test-skills-default-kind-${Math.random().toString(36).slice(2)}`);
mkdirSync(tmpDir, { recursive: true });
makeSkillsDir(tmpDir, {
mySkill: `---
description: My skill
contract:
produces:
data:
type: object
---
Body here`,
});
const result = buildSkillContractsFromFrontmatter(tmpDir);
expect(result).toHaveLength(1);
expect(result[0][1].produces?.kind).toBe("produces");
});
it("carries a consumes block through, per-field, into the contract", () => {
tmpDir = join(process.env.HOME!, `test-skills-consumes-${Math.random().toString(36).slice(2)}`);
mkdirSync(tmpDir, { recursive: true });
makeSkillsDir(tmpDir, {
design: `---
description: Design skill
contract:
consumes:
data:
type: object
properties:
findings:
type: string
reads:
research: {}
meta:
artifactKind: research
---
Body here`,
});
const result = buildSkillContractsFromFrontmatter(tmpDir);
expect(result).toHaveLength(1);
expect(result[0][0]).toBe("design");
expect(result[0][1]).toEqual({
source: "declared",
consumes: {
data: { type: "object", properties: { findings: { type: "string" } } },
reads: { research: {} },
meta: { artifactKind: "research" },
},
});
});
it("skips non-object consumes sub-fields without crashing", () => {
tmpDir = join(process.env.HOME!, `test-skills-bad-consumes-${Math.random().toString(36).slice(2)}`);
mkdirSync(tmpDir, { recursive: true });
makeSkillsDir(tmpDir, {
design: `---
description: Design skill
contract:
consumes:
data: "not-an-object"
meta:
artifactKind: research
---
Body here`,
});
const result = buildSkillContractsFromFrontmatter(tmpDir);
expect(result).toHaveLength(1);
// data (a string) is dropped; the valid meta still carries through.
expect(result[0][1].consumes).toEqual({ meta: { artifactKind: "research" } });
});
it("skips non-object produces.data without crashing", () => {
tmpDir = join(process.env.HOME!, `test-skills-bad-data-${Math.random().toString(36).slice(2)}`);
mkdirSync(tmpDir, { recursive: true });
makeSkillsDir(tmpDir, {
mySkill: `---
description: My skill
contract:
produces:
kind: produces
data: "not-an-object"
---
Body here`,
});
const result = buildSkillContractsFromFrontmatter(tmpDir);
expect(result).toHaveLength(1);
// data should be omitted since it's a string, not an object
expect(result[0][1].produces?.data).toBeUndefined();
});
});
describe("normalizeContract produces.data keyword guard", () => {
it("drops produces.data lacking any recognized JSON Schema keyword", () => {
// A frontmatter typo like { data: { foo: 1 } } should be silently dropped,
// matching the parser's degrade-on-malformed posture.
const contract = normalizeContract({
produces: { kind: "produces", data: { foo: 1 } },
});
expect(contract.produces?.data).toBeUndefined();
});
it("keeps produces.data with a recognized JSON Schema keyword", () => {
const contract = normalizeContract({
produces: {
kind: "produces",
data: { type: "object", properties: { topic: { type: "string" } } },
},
});
expect(contract.produces?.data).toBeDefined();
expect((contract.produces!.data as Record).type).toBe("object");
});
});
describe("bundled skill contracts", () => {
// The 12 pipeline skills carry a contract: block. This is the count the /wf
// banner reports as "N declared" — a drift guard if a block is added,
// dropped, or fails to parse (a malformed block is silently skipped).
const declared = new Map(buildSkillContractsFromFrontmatter(BUNDLED_SKILLS_DIR));
it("declares a contract for the 20 pipeline + orthogonal skills", () => {
expect(declared.size).toBe(20);
for (const name of [
"discover",
"research",
"explore",
"design",
"plan",
"blueprint",
"architecture-review",
"code-review",
"validate",
"revise",
"implement",
"commit",
"annotate-guidance",
"annotate-inline",
"changelog",
"create-handoff",
"resume-handoff",
"frontend-design",
"migrate-to-guidance",
"pr-triage",
]) {
expect(declared.has(name)).toBe(true);
}
});
it("blueprint declares phases as a required produces.data field (derived from the body headings)", () => {
const data = declared.get("blueprint")?.produces?.data as
| { required?: string[]; properties?: { phases?: unknown } }
| undefined;
expect(data?.required).toContain("phases");
expect(data?.properties?.phases).toBeDefined();
});
it("architecture-review declares phases as a required produces.data field (derived from the body headings)", () => {
const data = declared.get("architecture-review")?.produces?.data as
| { required?: string[]; properties?: { phases?: unknown } }
| undefined;
expect(data?.required).toContain("phases");
expect(data?.properties?.phases).toBeDefined();
});
it("documents the declared-but-not-harvested orthogonal set", () => {
// These skills declare a contract but don't appear in any built-in workflow.
// The orthogonal set: 7 new + discover + explore + commit = 10 skills.
// (pr-triage IS harvested — it's dispatched by the pr-triage workflow.)
const harvested = harvestStageContracts(builtInWorkflows);
const notHarvested: string[] = [];
for (const [name] of declared) {
if (!harvested.has(name)) notHarvested.push(name);
}
expect(notHarvested.sort()).toEqual(
[
"annotate-guidance",
"annotate-inline",
"changelog",
"commit",
"create-handoff",
"discover",
"explore",
"frontend-design",
"migrate-to-guidance",
"resume-handoff",
].sort(),
);
});
it("every declared kind matches the harvested kind for the six built-in workflows", () => {
// Harvest derives each dispatched skill's kind from how the built-ins use
// it (produces() → "produces", acts() → "side-effect"). A declared kind
// that disagrees would make the rendered graph lie — catch it here.
// Side-effect skills legitimately declare `produces.kind: "side-effect"` in
// their contract (for graph rendering), but `harvestStageContracts` doesn't
// create a `produces` for stages without outputSchema — so the harvested
// contract has no `produces` at all. Skip the comparison for this case.
const harvested = harvestStageContracts(builtInWorkflows);
expect(harvested.size).toBeGreaterThan(0);
for (const [skill, h] of harvested) {
const d = declared.get(skill);
expect(d, `skill "${skill}" is dispatched by a built-in but declares no contract`).toBeDefined();
const declaredKind = d?.produces?.kind;
const harvestedKind = h.produces?.kind;
if (declaredKind === "side-effect" && harvestedKind === undefined) continue;
expect(declaredKind, `kind drift for "${skill}"`).toBe(harvestedKind);
}
});
it("implement declares the required artifactKind on its reads.plans channel", () => {
const reads = declared.get("implement")?.consumes?.reads as
| { plans?: { meta?: { artifactKind?: string } } }
| undefined;
expect(reads?.plans?.meta?.artifactKind).toBe("plan");
});
it("design, plan, and blueprint require a status:ready upstream via consumes.data", () => {
for (const skill of ["design", "plan", "blueprint"]) {
const data = declared.get(skill)?.consumes?.data as
| { properties?: { status?: { const?: string } } }
| undefined;
expect(data?.properties?.status?.const, skill).toBe("ready");
}
});
it("registerSkillContractsSource registers the plans composition comparator eagerly", async () => {
const { getCompositionComparators } = await import("@juicesharp/rpiv-workflow/internal");
await registerSkillContractsSource();
expect(getCompositionComparators().has("plans")).toBe(true);
});
it("plan's inline template (no templates/ dir) writes its required produces.data fields", () => {
const required = (declared.get("plan")?.produces?.data as { required?: string[] }).required!;
const body = readFileSync(join(BUNDLED_SKILLS_DIR, "plan", "SKILL.md"), "utf-8");
// Scan for ---…--- frontmatter regions (robust to the ```! executable block
// at SKILL.md:46 that shifts naive fence-pair parity, and to nested fences
// inside the ```markdown template). The artifact template's frontmatter is
// uniquely identified by having both phases: and phase_count:.
const fronts = [...body.matchAll(/---\n([\s\S]*?)\n---/g)].map((m) => m[1]!);
const template = fronts.find((b) => /\bphases:/.test(b) && /\bphase_count:/.test(b));
expect(template, "plan: no inline template block with phases + phase_count").toBeDefined();
for (const field of required) {
expect(template!, `plan template missing ${field}`).toMatch(new RegExp(`(^|\\n)\\s*${field}:`));
}
});
it("architecture-review template carries the required layer_count field", () => {
const required = (declared.get("architecture-review")?.produces?.data as { required?: string[] }).required!;
expect(required).toContain("layer_count");
const dir = join(BUNDLED_SKILLS_DIR, "architecture-review", "templates");
const text = readdirSync(dir)
.filter((f) => f.endsWith(".md"))
.map((f) => readFileSync(join(dir, f), "utf-8"))
.join("\n");
expect(text, "architecture-review template missing layer_count").toMatch(/(^|\n)layer_count:/);
});
it("every produces.data.required field is a declared property (contract self-coherence)", () => {
let checked = 0;
for (const [skill, contract] of declared) {
const data = contract.produces?.data as
| { required?: string[]; properties?: Record }
| undefined;
if (!data?.required?.length) continue;
for (const field of data.required) {
checked++;
expect(data.properties?.[field], `${skill}: required "${field}" is not a declared property`).toBeDefined();
}
}
expect(checked).toBeGreaterThan(0);
});
it("side-effect skills declare no produces.data", () => {
// kind: side-effect means the skill describes itself via meta/reads, not
// an adjudicated data channel — side-effects fill meta, not data.
const sideEffectSkills = [
"implement",
"commit",
"annotate-guidance",
"annotate-inline",
"changelog",
"resume-handoff",
"frontend-design",
"migrate-to-guidance",
];
for (const name of sideEffectSkills) {
expect(declared.get(name)?.produces?.kind, `${name}: kind`).toBe("side-effect");
expect(declared.get(name)?.produces?.data, `${name}: produces.data`).toBeUndefined();
}
});
// Template/contract coherence — the drift that bit us at RUNTIME (a validate
// artifact wrote `status: complete`, which the contract enum rejected). Catch it at build
// time instead: every enum-constrained `status`/`verdict` value a skill
// TEMPLATE tells the LLM to write must be a member of the contract enum.
// A template value is either a literal (`status: ready`) or a placeholder set
// (`status: {in-progress | ready}`) — every candidate must be in the enum.
it("each template's status/verdict frontmatter stays within the contract enum", () => {
const enumOf = (skill: string, field: string): string[] | undefined => {
const props = (declared.get(skill)?.produces?.data as { properties?: Record })
?.properties;
return props?.[field]?.enum;
};
const templateValues = (raw: string, field: string): string[] => {
const m = raw.match(new RegExp(`^${field}:\\s*(.+)$`, "m"));
if (!m) return [];
const val = m[1]!.trim();
const brace = val.match(/^\{(.+)\}$/);
return brace ? brace[1]!.split("|").map((s) => s.trim()) : [val];
};
let templatesChecked = 0;
for (const skill of declared.keys()) {
let files: string[];
try {
files = readdirSync(join(BUNDLED_SKILLS_DIR, skill, "templates")).filter((f) => f.endsWith(".md"));
} catch {
continue; // skill has no templates/ dir — body-only, nothing to cross-check here
}
for (const file of files) {
const raw = readFileSync(join(BUNDLED_SKILLS_DIR, skill, "templates", file), "utf-8");
for (const field of ["status", "verdict"]) {
const allowed = enumOf(skill, field);
if (!allowed) continue;
for (const value of templateValues(raw, field)) {
templatesChecked++;
expect(
allowed,
`${skill}/templates/${file}: ${field} "${value}" is not in the contract enum [${allowed.join(", ")}]`,
).toContain(value);
}
}
}
}
// Guard the guard — if this drops to 0 the check silently passes forever.
expect(templatesChecked).toBeGreaterThan(0);
});
});
describe("artifactKindComparator (plans channel)", () => {
const produces = (artifactKind?: string): ProducesSpec => ({
kind: "produces",
...(artifactKind ? { meta: { artifactKind } } : {}),
});
const consumes = (artifactKind?: string): ConsumesSpec => ({
reads: { plans: artifactKind ? { meta: { artifactKind } } : {} },
});
it("ok when producer kind matches the consumer's required kind", () => {
expect(artifactKindComparator(produces("plan"), consumes("plan"), "plans")).toEqual({ ok: true });
});
it("fails with a channel-named reason when kinds are disjoint", () => {
const r = artifactKindComparator(produces("design"), consumes("plan"), "plans");
expect(r.ok).toBe(false);
expect(r.reason).toContain("plans");
});
it("degrades (ok) when either side omits the kind", () => {
expect(artifactKindComparator(produces(), consumes("plan"), "plans")).toEqual({ ok: true });
expect(artifactKindComparator(produces("plan"), consumes(), "plans")).toEqual({ ok: true });
});
});
describe("registerSkillContractsSource", () => {
describe("when the rpiv-workflow sibling is absent", () => {
afterEach(() => {
vi.doUnmock("@juicesharp/rpiv-workflow/startup");
vi.resetModules();
});
it("no-ops without throwing", async () => {
vi.resetModules();
vi.doMock("@juicesharp/rpiv-workflow/startup", () => {
throw Object.assign(new Error("Cannot find package '@juicesharp/rpiv-workflow'"), {
code: "ERR_MODULE_NOT_FOUND",
});
});
const fresh = await import("./skill-contracts-source.js");
await expect(fresh.registerSkillContractsSource()).resolves.toBeUndefined();
});
});
});
describe("buildUserSkillContracts", () => {
const userSkillsDir = () => join(process.env.HOME!, ".pi", "agent", "skills");
let projectDir: string;
afterEach(() => {
rmSync(userSkillsDir(), { recursive: true, force: true });
if (projectDir) {
rmSync(projectDir, { recursive: true, force: true });
projectDir = "" as string;
}
});
const writeSkill = (baseDir: string, name: string, content: string) => {
const dir = join(baseDir, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "SKILL.md"), content);
};
const CONTRACT_SKILL = `---
description: Custom review skill
contract:
produces:
kind: produces
meta:
artifactKind: review
---
Body here`;
it("returns contracts for skills in the global user skills dir (/skills)", () => {
writeSkill(userSkillsDir(), "my-custom-review", CONTRACT_SKILL);
const result = buildUserSkillContracts();
expect(result).toHaveLength(1);
expect(result[0]![0]).toBe("my-custom-review");
expect(result[0]![1]).toEqual({
source: "declared",
produces: { kind: "produces", meta: { artifactKind: "review" } },
});
});
it("returns contracts for project-local skills (/.pi/skills)", () => {
projectDir = mkdtempSync(join(tmpdir(), "rpiv-user-skill-proj-"));
writeSkill(join(projectDir, ".pi", "skills"), "proj-review", CONTRACT_SKILL);
const result = buildUserSkillContracts(projectDir);
expect(result.map(([name]) => name)).toContain("proj-review");
});
it("skips skills without contract: frontmatter", () => {
writeSkill(
userSkillsDir(),
"plain-skill",
`---
description: Plain skill
---
Body here`,
);
expect(buildUserSkillContracts()).toEqual([]);
});
it("returns [] when no user skill dirs exist", () => {
projectDir = mkdtempSync(join(tmpdir(), "rpiv-user-skill-empty-"));
expect(buildUserSkillContracts(projectDir)).toEqual([]);
});
});
describe("isBundledSkillPath", () => {
it("a path inside the bundled skills dir is bundled; a sibling dir sharing the prefix is not", () => {
expect(isBundledSkillPath(join(BUNDLED_SKILLS_DIR, "research", "SKILL.md"))).toBe(true);
expect(isBundledSkillPath(`${BUNDLED_SKILLS_DIR}-extra${sep}research${sep}SKILL.md`)).toBe(false);
expect(isBundledSkillPath(join(tmpdir(), "elsewhere", "SKILL.md"))).toBe(false);
});
it.skipIf(process.platform === "win32")("resolves symlinks: a link into the bundled dir is bundled", () => {
const linkParent = mkdtempSync(join(tmpdir(), "rpiv-bundled-link-"));
try {
const link = join(linkParent, "skills-link");
symlinkSync(BUNDLED_SKILLS_DIR, link);
expect(isBundledSkillPath(link)).toBe(true);
} finally {
rmSync(linkParent, { recursive: true, force: true });
}
});
});
describe("registerUserSkillContractsSource", () => {
describe("when the rpiv-workflow sibling is absent", () => {
afterEach(() => {
vi.doUnmock("@juicesharp/rpiv-workflow/startup");
vi.resetModules();
});
it("no-ops without throwing", async () => {
vi.resetModules();
vi.doMock("@juicesharp/rpiv-workflow/startup", () => {
throw Object.assign(new Error("Cannot find package '@juicesharp/rpiv-workflow'"), {
code: "ERR_MODULE_NOT_FOUND",
});
});
const fresh = await import("./skill-contracts-source.js");
await expect(fresh.registerUserSkillContractsSource()).resolves.toBeUndefined();
});
});
});
describe("end-to-end: user-skill contract \u2192 effective contract map", () => {
let tmpDir: string;
afterEach(() => {
if (tmpDir) {
rmSync(tmpDir, { recursive: true, force: true });
tmpDir = "" as string;
}
__resetSkillContracts();
});
it("a stage dispatching a user-installed skill gets its contract in the effective map", async () => {
tmpDir = mkdtempSync(join(tmpdir(), "rpiv-e2e-user-skill-"));
// Project-local skill location — buildUserSkillContracts(tmpDir) reads /.pi/skills.
const skillDir = join(tmpDir, ".pi", "skills", "my-custom-review");
mkdirSync(skillDir, { recursive: true });
writeFileSync(
join(skillDir, "SKILL.md"),
`---
description: Custom review
contract:
produces:
kind: produces
meta:
artifactKind: review
---
Body`,
);
// Simulate what the loader does: register the user-skill contract provider, then flush
const { registerSkillContractsProvider, registerSkillContracts } = await import(
"@juicesharp/rpiv-workflow/startup"
);
registerSkillContractsProvider(() => {
registerSkillContracts(buildUserSkillContracts(tmpDir), "user-skills");
});
await flushSkillContractProviders();
// A workflow stage dispatching the user skill (stage name = skill name)
const w = defineWorkflow({
name: "test-e2e",
start: "my-custom-review",
stages: {
"my-custom-review": produces(),
},
edges: { "my-custom-review": "stop" },
});
const contracts = buildEffectiveContracts([w]);
expect(contracts.get("my-custom-review")?.produces?.meta).toEqual({ artifactKind: "review" });
});
});
describe("owner collision: user-skills vs rpiv-pi", () => {
afterEach(() => {
__resetSkillContracts();
});
it("surfaces collision when user-skill contract diverges from bundled", async () => {
const { registerSkillContracts } = await import("@juicesharp/rpiv-workflow/startup");
const bundled: SkillContract = {
source: "declared",
produces: { kind: "produces", meta: { artifactKind: "review" } },
};
const user: SkillContract = {
source: "declared",
produces: { kind: "produces", meta: { artifactKind: "custom-review" } },
};
registerSkillContracts([["code-review", bundled]], "rpiv-pi");
registerSkillContracts([["code-review", user]], "user-skills");
const collisions = drainSkillContractCollisions();
expect(collisions).toHaveLength(1);
expect(collisions[0]).toContain("code-review");
expect(collisions[0]).toContain("user-skills");
expect(collisions[0]).toContain("rpiv-pi");
});
});