181 行
7.1 KiB
JavaScript
181 行
7.1 KiB
JavaScript
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");
|
|
});
|
|
});
|