chore: 升级 Trellis 并精简规则(09-05-trellis-upgrade-rule-cleanup)
这个提交包含在:
@@ -0,0 +1,180 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户