const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { spawnSync } = require("node:child_process"); const repoRoot = path.resolve(__dirname, "../../../.."); const scriptsRoot = path.join(repoRoot, ".trellis/scripts"); describe("Trellis runtime manifest contract", () => { let root; let taskDir; beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-runtime-contract-")); taskDir = path.join(root, ".trellis/tasks/fixture"); fs.mkdirSync(taskDir, { recursive: true }); fs.mkdirSync(path.join(root, ".trellis/spec")); fs.writeFileSync(path.join(root, ".trellis/spec/contract.md"), "# Contract\n"); fs.writeFileSync(path.join(taskDir, "task.json"), JSON.stringify({ status: "planning" })); }); afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); }); function runCase(mode, content, command = "validate") { fs.writeFileSync(path.join(root, ".trellis/config.yaml"), `session_auto_commit: false\ncodex:\n dispatch_mode: ${mode}\n`); for (const name of ["implement", "check"]) { fs.writeFileSync(path.join(taskDir, `${name}.jsonl`), content); } const runner = ` import argparse import sys from pathlib import Path from unittest.mock import patch sys.path.insert(0, sys.argv[1]) import task from common.task_context import cmd_validate root = Path(sys.argv[2]) args = argparse.Namespace(dir=".trellis/tasks/fixture", allow_empty_context=False) if sys.argv[3] == "start": with patch("task.get_repo_root", return_value=root), patch("task.resolve_context_key", return_value=None), patch("task._record_start_state"): result = task.cmd_start(args) else: with patch("common.task_context.get_repo_root", return_value=root): result = cmd_validate(args) sys.exit(0 if result == 0 else 1) `; const result = spawnSync("python3", ["-c", runner, scriptsRoot, root, command], { encoding: "utf8", env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" }, timeout: 10000, }); if (result.error) throw result.error; expect(result.stderr).not.toContain("Traceback"); return result; } it.each(["validate", "start"])("permits empty inline manifests at %s", (command) => { expect(runCase("inline", "", command).status).toBe(0); }); it.each(["validate", "start"])("keeps the sub-agent empty gate at %s", (command) => { expect(runCase("sub-agent", "", command).status).toBe(1); }); it("accepts legacy seeds only in inline mode", () => { const seed = JSON.stringify({ _example: "legacy" }); const result = runCase("inline", seed); expect(result.status).toBe(0); for (const name of ["implement", "check"]) { expect(result.stdout).toContain(`${name}.jsonl: ✓ (0 entries)`); } for (const mode of ["sub-agent", "auto"]) { expect(runCase(mode, seed).status).toBe(1); } }); describe.each(["inline", "sub-agent", "auto"])("mixed legacy markers in %s", (mode) => { it.each([ { file: "", reason: "" }, { file: false, reason: "invalid" }, { file: null, reason: "invalid" }, { path: "", reason: "invalid" }, { path: false, reason: "invalid" }, { path: null, reason: "invalid" }, { reason: "" }, { type: "directory" }, { unexpected: false }, { file: ".trellis/spec/contract.md", reason: "runtime contract" }, ])("rejects a marker combined with %j", (fields) => { const result = runCase(mode, JSON.stringify({ _example: "legacy", ...fields })); expect(result.status).toBe(1); for (const name of ["implement", "check"]) { expect(result.stdout).toContain(`${name}.jsonl:1: Placeholder \`_example\` row left by an older task.py create`); } }); }); it("does not let a pure seed hide an invalid following row", () => { const content = [ { _example: "legacy" }, { file: "", reason: "" }, ].map((entry) => JSON.stringify(entry)).join("\n"); const result = runCase("inline", content); expect(result.status).toBe(1); for (const name of ["implement", "check"]) { expect(result.stdout).toContain(`${name}.jsonl:2: file must be a non-empty string path`); } }); it.each(["inline", "auto"])("accepts curated entries in %s", (mode) => { const entry = JSON.stringify({ file: ".trellis/spec/contract.md", reason: "runtime contract" }); expect(runCase(mode, entry).status).toBe(0); expect(runCase(mode, entry, "start").status).toBe(0); }); it.each([ "{bad json", "[]", JSON.stringify({ file: ".trellis/spec/contract.md", reason: "" }), JSON.stringify({ file: "", reason: "missing path" }), JSON.stringify({ file: 42, reason: "wrong type" }), JSON.stringify({ file: ".trellis/spec/missing.md", reason: "missing file" }), ])("rejects malformed inline input: %s", (content) => { expect(runCase("inline", content).status).toBe(1); }); it("rejects paths and symlinks outside the project", () => { const outside = `${root}-outside.md`; fs.writeFileSync(outside, "# Outside\n"); try { fs.symlinkSync(outside, path.join(root, ".trellis/spec/outside.md")); for (const file of [outside, `../${path.basename(outside)}`, ".trellis/spec/outside.md"]) { expect(runCase("inline", JSON.stringify({ file, reason: "escape" })).status).toBe(1); } } finally { fs.rmSync(outside, { force: true }); } }); }); describe("Trellis workflow routing contract", () => { it("loads consultation and approved-planning rules through the real hook parser", () => { const runner = ` import importlib.util from pathlib import Path import sys root = Path(sys.argv[1]) sys.path.insert(0, str(root / ".trellis/scripts")) spec = importlib.util.spec_from_file_location("workflow_hook", root / ".codex/hooks/inject-workflow-state.py") hook = importlib.util.module_from_spec(spec) spec.loader.exec_module(hook) templates = hook.load_breadcrumbs(root) assert "without asking to create a task" in templates["no_task"] key = hook.resolve_breadcrumb_key("planning", "codex", {"codex": {"dispatch_mode": "inline"}}) assert key == "planning-inline" message = hook.build_breadcrumb("fixture", "planning", templates, breadcrumb_key=key) assert "already recorded approval satisfies this gate" in message assert "stay in planning" not in message assert "task_error" in templates assert "Do not create or activate another task" in templates["task_error"] print("Routing contract passed") `; const result = spawnSync("python3", ["-c", runner, repoRoot], { encoding: "utf8", env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" }, timeout: 10000, }); expect(result.stderr).not.toContain("Traceback"); expect(result.status).toBe(0); }); it("preserves explicit commit and archival boundaries", () => { const finish = fs.readFileSync(path.join(repoRoot, ".agents/skills/trellis-finish-work/SKILL.md"), "utf8"); expect(finish).toContain("Commit only when explicitly requested"); expect(finish).toContain("task is genuinely complete and archival is explicitly requested"); expect(finish).not.toContain("current active task is always archived"); }); });