feat(ack): add project knowledge guardrails
This commit is contained in:
@@ -0,0 +1,937 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
ACK_DIR = REPO_ROOT / "skills" / "ack"
|
||||
SCRIPTS_DIR = ACK_DIR / "scripts"
|
||||
SCHEMA_PATH = ACK_DIR / "templates" / "knowledge.schema.json"
|
||||
EXAMPLE_PATH = ACK_DIR / "examples" / "knowledge.example.yaml"
|
||||
TASKS_EXAMPLE_PATH = ACK_DIR / "examples" / "tasks.example.yaml"
|
||||
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
import select_knowledge # noqa: E402
|
||||
import validate_knowledge # noqa: E402
|
||||
|
||||
|
||||
def example_data() -> dict:
|
||||
return yaml.safe_load(EXAMPLE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class AckKnowledgeTests(unittest.TestCase):
|
||||
def test_schema_template_and_example_are_aligned(self) -> None:
|
||||
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
||||
template = yaml.safe_load(
|
||||
(ACK_DIR / "templates" / "knowledge.template.yaml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
schema["definitions"]["entry"]["properties"]["kind"]["enum"],
|
||||
["guardrail", "pitfall", "verification"],
|
||||
)
|
||||
self.assertEqual(
|
||||
schema["definitions"]["entry"]["properties"]["status"]["enum"],
|
||||
["active", "stale", "superseded", "archived"],
|
||||
)
|
||||
self.assertEqual(template["entries"], [])
|
||||
self.assertEqual(template["verificationRegistry"], {})
|
||||
|
||||
errors, _ = validate_knowledge.validate_all(
|
||||
example_data(), SCHEMA_PATH, use_schema=False
|
||||
)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(
|
||||
validate_knowledge.stable_ref(example_data()["entries"][0]), "K-001@1"
|
||||
)
|
||||
|
||||
def test_semantics_run_even_when_schema_reports_no_errors(self) -> None:
|
||||
data = example_data()
|
||||
data["entries"].append(copy.deepcopy(data["entries"][0]))
|
||||
with mock.patch.object(
|
||||
validate_knowledge, "validate_with_schema", return_value=[]
|
||||
):
|
||||
errors, mode = validate_knowledge.validate_all(
|
||||
data, SCHEMA_PATH, use_schema=True
|
||||
)
|
||||
self.assertIn("内置语义", mode)
|
||||
self.assertTrue(any("稳定引用 K-001@1 重复" in error for error in errors))
|
||||
|
||||
def test_blank_required_text_fails_in_builtin_and_schema_modes(self) -> None:
|
||||
data = example_data()
|
||||
data["entries"][0]["title"] = " \t "
|
||||
|
||||
builtin_errors, _ = validate_knowledge.validate_all(
|
||||
data, SCHEMA_PATH, use_schema=False
|
||||
)
|
||||
with mock.patch.object(
|
||||
validate_knowledge, "validate_with_schema", return_value=[]
|
||||
):
|
||||
schema_errors, _ = validate_knowledge.validate_all(
|
||||
data, SCHEMA_PATH, use_schema=True
|
||||
)
|
||||
|
||||
self.assertTrue(any("title: 必须是非空字符串" in e for e in builtin_errors))
|
||||
self.assertTrue(any("title: 必须是非空字符串" in e for e in schema_errors))
|
||||
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
||||
self.assertEqual(
|
||||
schema["definitions"]["entry"]["properties"]["title"]["pattern"],
|
||||
"\\S",
|
||||
)
|
||||
|
||||
def test_temporary_entry_requires_removal_condition_and_review_date(self) -> None:
|
||||
data = example_data()
|
||||
entry = data["entries"][0]
|
||||
entry["temporary"] = True
|
||||
entry["removalCondition"] = None
|
||||
entry["reviewAfter"] = None
|
||||
errors = validate_knowledge.validate_semantics(data)
|
||||
self.assertTrue(any("removalCondition" in error for error in errors))
|
||||
self.assertTrue(any("reviewAfter" in error for error in errors))
|
||||
|
||||
def test_overdue_active_entry_requires_revalidation(self) -> None:
|
||||
data = example_data()
|
||||
data["entries"][0]["reviewAfter"] = "2020-01-01T00:00:00+00:00"
|
||||
|
||||
errors = validate_knowledge.validate_semantics(data)
|
||||
|
||||
self.assertTrue(any("必须重新验证或标记 stale" in error for error in errors))
|
||||
|
||||
def test_global_active_entry_requires_decision_owner_approval(self) -> None:
|
||||
data = example_data()
|
||||
data["entries"][0]["scope"] = {
|
||||
"all": True,
|
||||
"components": [],
|
||||
"paths": [],
|
||||
"dependencies": [],
|
||||
"versions": [],
|
||||
"tags": [],
|
||||
"symbols": [],
|
||||
"errorSignatures": [],
|
||||
}
|
||||
|
||||
errors = validate_knowledge.validate_semantics(data)
|
||||
|
||||
self.assertTrue(any("Decision Owner approval" in error for error in errors))
|
||||
|
||||
def test_active_conflicts_and_duplicate_active_revision_are_rejected(self) -> None:
|
||||
data = example_data()
|
||||
conflicting = copy.deepcopy(data["entries"][0])
|
||||
conflicting.update(
|
||||
{
|
||||
"id": "K-002",
|
||||
"subject": "service-process-alignment",
|
||||
"conflictsWith": ["K-001@1"],
|
||||
}
|
||||
)
|
||||
data["entries"].append(conflicting)
|
||||
errors = validate_knowledge.validate_semantics(data)
|
||||
self.assertTrue(any("active 条目不能与 active K-001@1 冲突" in e for e in errors))
|
||||
|
||||
conflicting["id"] = "K-001"
|
||||
conflicting["revision"] = 2
|
||||
conflicting["conflictsWith"] = []
|
||||
errors = validate_knowledge.validate_semantics(data)
|
||||
self.assertTrue(any("多个 active revision" in error for error in errors))
|
||||
|
||||
def test_verification_ref_must_resolve_to_contained_registry_target(self) -> None:
|
||||
data = example_data()
|
||||
data["entries"][0]["verification"]["ref"] = "missing-check"
|
||||
errors = validate_knowledge.validate_semantics(data)
|
||||
self.assertTrue(any("未在 verificationRegistry 注册" in e for e in errors))
|
||||
|
||||
data = example_data()
|
||||
data["verificationRegistry"]["service-worktree-alignment"]["path"] = "../run.sh"
|
||||
errors = validate_knowledge.validate_semantics(data)
|
||||
self.assertTrue(any("必须是仓库内相对路径" in error for error in errors))
|
||||
|
||||
def test_verification_registry_rejects_any_symlink_component(self) -> None:
|
||||
data = example_data()
|
||||
data["verificationRegistry"]["service-worktree-alignment"]["path"] = (
|
||||
"checks/verify.py"
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
base = Path(temp_dir)
|
||||
project = base / "project"
|
||||
outside = base / "outside"
|
||||
project.mkdir()
|
||||
outside.mkdir()
|
||||
(project / "checks").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
errors = validate_knowledge.validate_semantics(
|
||||
data, project_root=project
|
||||
)
|
||||
|
||||
self.assertTrue(any("symlink" in error for error in errors))
|
||||
|
||||
data = example_data()
|
||||
data["verificationRegistry"]["service-worktree-alignment"]["path"] = (
|
||||
"checks/verify.py"
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
project = Path(temp_dir) / "project"
|
||||
real_checks = project / "real-checks"
|
||||
real_checks.mkdir(parents=True)
|
||||
target = real_checks / "verify.py"
|
||||
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
target.chmod(target.stat().st_mode | 0o111)
|
||||
(project / "checks").symlink_to(
|
||||
real_checks,
|
||||
target_is_directory=True,
|
||||
)
|
||||
|
||||
errors = validate_knowledge.validate_semantics(
|
||||
data,
|
||||
project_root=project,
|
||||
)
|
||||
|
||||
self.assertTrue(any("symlink" in error for error in errors))
|
||||
|
||||
data = example_data()
|
||||
data["verificationRegistry"]["service-worktree-alignment"]["path"] = (
|
||||
"tests/check.sh;touch"
|
||||
)
|
||||
errors = validate_knowledge.validate_semantics(data)
|
||||
self.assertTrue(any("必须是仓库内相对路径" in error for error in errors))
|
||||
|
||||
def test_verification_registry_requires_regular_executable_target(self) -> None:
|
||||
data = example_data()
|
||||
data["verificationRegistry"]["service-worktree-alignment"]["path"] = (
|
||||
"checks/verify"
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
project = Path(temp_dir) / "project"
|
||||
project.mkdir()
|
||||
|
||||
missing = validate_knowledge.validate_semantics(
|
||||
data,
|
||||
project_root=project,
|
||||
)
|
||||
self.assertTrue(any("不存在" in error for error in missing))
|
||||
|
||||
target = project / "checks" / "verify"
|
||||
target.parent.mkdir()
|
||||
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
not_executable = validate_knowledge.validate_semantics(
|
||||
data,
|
||||
project_root=project,
|
||||
)
|
||||
self.assertTrue(
|
||||
any("不可执行" in error for error in not_executable)
|
||||
)
|
||||
|
||||
target.chmod(target.stat().st_mode | 0o111)
|
||||
valid = validate_knowledge.validate_semantics(
|
||||
data,
|
||||
project_root=project,
|
||||
)
|
||||
self.assertFalse(
|
||||
any("verificationRegistry" in error for error in valid),
|
||||
valid,
|
||||
)
|
||||
|
||||
def test_free_command_fields_are_rejected_and_never_executed(self) -> None:
|
||||
data = example_data()
|
||||
data["entries"][0]["command"] = "echo unsafe"
|
||||
errors = validate_knowledge.validate_semantics(data)
|
||||
self.assertTrue(any("不保存或执行自由命令" in error for error in errors))
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
marker = Path(temp_dir) / "should-not-exist"
|
||||
safe = example_data()
|
||||
safe["entries"][0]["directive"] = f"touch {marker}"
|
||||
selected = select_knowledge.select_entries(
|
||||
safe,
|
||||
{
|
||||
"components": ["web"],
|
||||
"paths": ["web/app.py"],
|
||||
"dependencies": [],
|
||||
"versions": [],
|
||||
"tags": ["long-running-service"],
|
||||
"symbols": [],
|
||||
"errorSignatures": [],
|
||||
},
|
||||
)
|
||||
self.assertEqual(len(selected), 1)
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
def test_common_secret_material_is_rejected(self) -> None:
|
||||
data = example_data()
|
||||
data["entries"][0]["rationale"] = (
|
||||
"debug token=sk-abcdefghijklmnopqrstuvwxyz123456"
|
||||
)
|
||||
|
||||
errors = validate_knowledge.validate_semantics(data)
|
||||
|
||||
self.assertTrue(any("OpenAI-style token" in error for error in errors))
|
||||
self.assertTrue(any("脱敏摘要" in error for error in errors))
|
||||
|
||||
def test_selection_is_active_deterministic_and_limited(self) -> None:
|
||||
data = example_data()
|
||||
stale = copy.deepcopy(data["entries"][0])
|
||||
stale.update(
|
||||
{
|
||||
"id": "K-002",
|
||||
"status": "stale",
|
||||
"statusReason": "相关服务已移除",
|
||||
}
|
||||
)
|
||||
global_entry = copy.deepcopy(data["entries"][0])
|
||||
global_entry.update(
|
||||
{
|
||||
"id": "K-003",
|
||||
"subject": "global-release-check",
|
||||
"scope": {
|
||||
"all": True,
|
||||
"components": [],
|
||||
"paths": [],
|
||||
"dependencies": [],
|
||||
"versions": [],
|
||||
"tags": [],
|
||||
"symbols": [],
|
||||
"errorSignatures": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
data["entries"].extend([stale, global_entry])
|
||||
context = {
|
||||
"components": ["web"],
|
||||
"paths": ["web/app.py"],
|
||||
"dependencies": [],
|
||||
"versions": [],
|
||||
"tags": ["long-running-service"],
|
||||
"symbols": [],
|
||||
"errorSignatures": [],
|
||||
}
|
||||
selected = select_knowledge.select_entries(data, context, limit=1)
|
||||
self.assertEqual(
|
||||
[validate_knowledge.stable_ref(entry) for entry in selected], ["K-003@1"]
|
||||
)
|
||||
self.assertFalse(
|
||||
select_knowledge.scope_matches(
|
||||
data["entries"][0]["scope"],
|
||||
{**context, "tags": []},
|
||||
)
|
||||
)
|
||||
|
||||
def test_global_entries_are_never_silently_dropped_by_limit(self) -> None:
|
||||
data = example_data()
|
||||
for number in range(2, 13):
|
||||
scoped = copy.deepcopy(data["entries"][0])
|
||||
scoped.update(
|
||||
{
|
||||
"id": f"K-{number:03d}",
|
||||
"subject": f"scoped-check-{number}",
|
||||
}
|
||||
)
|
||||
data["entries"].append(scoped)
|
||||
global_entry = copy.deepcopy(data["entries"][0])
|
||||
global_entry.update(
|
||||
{
|
||||
"id": "K-999",
|
||||
"subject": "global-release-check",
|
||||
"scope": {
|
||||
"all": True,
|
||||
"components": [],
|
||||
"paths": [],
|
||||
"dependencies": [],
|
||||
"versions": [],
|
||||
"tags": [],
|
||||
"symbols": [],
|
||||
"errorSignatures": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
data["entries"].append(global_entry)
|
||||
context = {
|
||||
"components": ["web"],
|
||||
"paths": ["web/app.py"],
|
||||
"dependencies": [],
|
||||
"versions": [],
|
||||
"tags": ["long-running-service"],
|
||||
"symbols": [],
|
||||
"errorSignatures": [],
|
||||
}
|
||||
|
||||
selected = select_knowledge.select_entries(data, context, limit=10)
|
||||
|
||||
refs = [validate_knowledge.stable_ref(entry) for entry in selected]
|
||||
self.assertEqual(len(refs), 10)
|
||||
self.assertIn("K-999@1", refs)
|
||||
|
||||
second_global = copy.deepcopy(global_entry)
|
||||
second_global.update({"id": "K-998", "subject": "global-security-check"})
|
||||
data["entries"].append(second_global)
|
||||
with self.assertRaisesRegex(ValueError, "不能静默丢弃"):
|
||||
select_knowledge.select_entries(data, context, limit=1)
|
||||
|
||||
def test_path_glob_does_not_let_single_star_cross_directory(self) -> None:
|
||||
self.assertTrue(select_knowledge._path_glob_matches("web/app.py", "web/*"))
|
||||
self.assertFalse(
|
||||
select_knowledge._path_glob_matches("web/pages/app.py", "web/*")
|
||||
)
|
||||
self.assertTrue(
|
||||
select_knowledge._path_glob_matches("web/pages/app.py", "web/**")
|
||||
)
|
||||
|
||||
def test_limit_prefers_narrow_scope_without_rewarding_or_patterns(self) -> None:
|
||||
data = example_data()
|
||||
narrow = copy.deepcopy(data["entries"][0])
|
||||
narrow.update({"id": "K-002", "subject": "narrow-service-check"})
|
||||
narrow["scope"]["paths"] = ["web/special/**"]
|
||||
broad_or = copy.deepcopy(data["entries"][0])
|
||||
broad_or.update({"id": "K-003", "subject": "broad-or-service-check"})
|
||||
broad_or["scope"]["paths"] = ["web/special/**", "api/**"]
|
||||
data["entries"].extend([narrow, broad_or])
|
||||
context = {
|
||||
"components": ["web"],
|
||||
"paths": ["web/special/pages/app.py"],
|
||||
"dependencies": [],
|
||||
"versions": [],
|
||||
"tags": ["long-running-service"],
|
||||
"symbols": [],
|
||||
"errorSignatures": [],
|
||||
}
|
||||
|
||||
selected = select_knowledge.select_entries(data, context, limit=1)
|
||||
|
||||
self.assertEqual(validate_knowledge.stable_ref(selected[0]), "K-002@1")
|
||||
|
||||
def test_task_cross_validation_requires_active_exact_ref_and_passed_check(self) -> None:
|
||||
data = example_data()
|
||||
task = {
|
||||
"id": "BUG-002",
|
||||
"status": "verified",
|
||||
"knowledgeRefs": ["K-001@1"],
|
||||
"knowledgeApplied": [
|
||||
{
|
||||
"ref": "K-001@1",
|
||||
"result": "applied",
|
||||
"evidence": "developer report",
|
||||
}
|
||||
],
|
||||
"knowledgeChecks": [
|
||||
{
|
||||
"ref": "K-001@1",
|
||||
"result": "passed",
|
||||
"evidence": "test report",
|
||||
}
|
||||
],
|
||||
"dispatch": {
|
||||
"rounds": [
|
||||
{
|
||||
"round": 1,
|
||||
"attemptId": "BUG-002-A1",
|
||||
"result": "failed",
|
||||
},
|
||||
{
|
||||
"round": 2,
|
||||
"attemptId": "BUG-002-A2",
|
||||
"result": "passed",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
tasks_data = {
|
||||
"project": {
|
||||
"name": "notes-web",
|
||||
"knowledgeFile": "docs/ack/knowledge.yaml",
|
||||
},
|
||||
"tasks": [task],
|
||||
}
|
||||
self.assertEqual(
|
||||
validate_knowledge.validate_task_references(data, tasks_data), []
|
||||
)
|
||||
|
||||
task["knowledgeChecks"][0]["result"] = "not_applicable"
|
||||
errors = validate_knowledge.validate_task_references(data, tasks_data)
|
||||
self.assertTrue(any("缺少 passed knowledgeCheck" in error for error in errors))
|
||||
|
||||
data["entries"][0]["kind"] = "pitfall"
|
||||
errors = validate_knowledge.validate_task_references(data, tasks_data)
|
||||
self.assertTrue(any("缺少 passed knowledgeCheck" in error for error in errors))
|
||||
|
||||
task["knowledgeRefs"] = ["K-001@2"]
|
||||
errors = validate_knowledge.validate_task_references(data, tasks_data)
|
||||
self.assertTrue(any("找不到精确版本 K-001@2" in error for error in errors))
|
||||
|
||||
def test_task_cross_validation_rejects_another_project(self) -> None:
|
||||
data = example_data()
|
||||
tasks = {
|
||||
"project": {
|
||||
"name": "another-project",
|
||||
"knowledgeFile": "docs/ack/knowledge.yaml",
|
||||
},
|
||||
"tasks": [],
|
||||
}
|
||||
|
||||
errors = validate_knowledge.validate_task_references(data, tasks)
|
||||
|
||||
self.assertTrue(any("与知识库项目" in error for error in errors))
|
||||
|
||||
def test_cross_validation_requires_traceable_explicit_attempt_id(self) -> None:
|
||||
data = example_data()
|
||||
task = {
|
||||
"id": "BUG-002",
|
||||
"status": "open",
|
||||
"dispatch": {
|
||||
"rounds": [
|
||||
{
|
||||
"round": 1,
|
||||
"attemptId": "BUG-002-A1",
|
||||
"result": "failed",
|
||||
},
|
||||
{
|
||||
"round": 2,
|
||||
"attemptId": "orca-dispatch-91",
|
||||
"result": "passed",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
tasks_data = {
|
||||
"project": {
|
||||
"name": "notes-web",
|
||||
"knowledgeFile": "docs/ack/knowledge.yaml",
|
||||
},
|
||||
"tasks": [task],
|
||||
}
|
||||
|
||||
errors = validate_knowledge.validate_task_references(data, tasks_data)
|
||||
|
||||
self.assertTrue(any("必须精确等于 'BUG-002-A2'" in e for e in errors))
|
||||
self.assertTrue(any("未命中任务 'BUG-002'" in e for e in errors))
|
||||
|
||||
task["dispatch"]["rounds"][1]["attemptId"] = "BUG-002-A2"
|
||||
errors = validate_knowledge.validate_task_references(data, tasks_data)
|
||||
self.assertFalse(any("provenance" in error for error in errors))
|
||||
|
||||
def test_cross_validation_rejects_non_contiguous_rounds(self) -> None:
|
||||
data = example_data()
|
||||
data["entries"][0]["provenance"]["attemptId"] = "BUG-002-A3"
|
||||
tasks_data = {
|
||||
"project": {
|
||||
"name": "notes-web",
|
||||
"knowledgeFile": "docs/ack/knowledge.yaml",
|
||||
},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "BUG-002",
|
||||
"status": "open",
|
||||
"dispatch": {
|
||||
"rounds": [
|
||||
{
|
||||
"round": 3,
|
||||
"attemptId": "BUG-002-A3",
|
||||
"result": "passed",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
errors = validate_knowledge.validate_task_references(data, tasks_data)
|
||||
|
||||
self.assertTrue(any("round 必须按 1..N 连续" in error for error in errors))
|
||||
|
||||
def test_cross_validation_binds_configured_knowledge_file(self) -> None:
|
||||
data = example_data()
|
||||
task = {
|
||||
"id": "BUG-002",
|
||||
"status": "open",
|
||||
"dispatch": {
|
||||
"rounds": [
|
||||
{
|
||||
"round": 1,
|
||||
"attemptId": "BUG-002-A1",
|
||||
"result": "failed",
|
||||
},
|
||||
{
|
||||
"round": 2,
|
||||
"attemptId": "BUG-002-A2",
|
||||
"result": "passed",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
project = Path(temp_dir) / "project"
|
||||
ack_dir = project / "docs" / "ack"
|
||||
ack_dir.mkdir(parents=True)
|
||||
knowledge_path = ack_dir / "knowledge.yaml"
|
||||
tasks_path = ack_dir / "tasks.yaml"
|
||||
knowledge_path.write_text(
|
||||
yaml.safe_dump(data, allow_unicode=True), encoding="utf-8"
|
||||
)
|
||||
tasks_data = {
|
||||
"project": {
|
||||
"name": "notes-web",
|
||||
"repoPath": str(project),
|
||||
"knowledgeFile": "docs/ack/other.yaml",
|
||||
},
|
||||
"tasks": [task],
|
||||
}
|
||||
|
||||
errors = validate_knowledge.validate_task_references(
|
||||
data,
|
||||
tasks_data,
|
||||
knowledge_path=knowledge_path,
|
||||
tasks_path=tasks_path,
|
||||
)
|
||||
self.assertTrue(any("与当前知识文件" in error for error in errors))
|
||||
|
||||
del tasks_data["project"]["knowledgeFile"]
|
||||
errors = validate_knowledge.validate_task_references(data, tasks_data)
|
||||
self.assertTrue(any("project.knowledgeFile 必填" in e for e in errors))
|
||||
|
||||
tasks_data["project"]["knowledgeFile"] = "docs/ack/knowledge.yaml"
|
||||
staging_root = Path(temp_dir) / "staging"
|
||||
staged = staging_root / "docs" / "ack" / "knowledge.yaml"
|
||||
staged.parent.mkdir(parents=True)
|
||||
staged.write_text(
|
||||
yaml.safe_dump(data, allow_unicode=True), encoding="utf-8"
|
||||
)
|
||||
errors = validate_knowledge.validate_task_references(
|
||||
data,
|
||||
tasks_data,
|
||||
knowledge_path=staged,
|
||||
tasks_path=tasks_path,
|
||||
project_root=staging_root,
|
||||
project_root_is_explicit=True,
|
||||
)
|
||||
self.assertFalse(any("knowledgeFile" in error for error in errors))
|
||||
|
||||
def test_cli_fails_closed_for_missing_repo_root_and_wrong_binding(self) -> None:
|
||||
data = example_data()
|
||||
task = {
|
||||
"id": "BUG-002",
|
||||
"title": "source task",
|
||||
"status": "open",
|
||||
"dispatch": {
|
||||
"rounds": [
|
||||
{
|
||||
"round": 1,
|
||||
"attemptId": "BUG-002-A1",
|
||||
"result": "failed",
|
||||
},
|
||||
{
|
||||
"round": 2,
|
||||
"attemptId": "BUG-002-A2",
|
||||
"result": "passed",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
base = Path(temp_dir)
|
||||
project = base / "project"
|
||||
ack_dir = project / "docs" / "ack"
|
||||
outside = base / "outside"
|
||||
ack_dir.mkdir(parents=True)
|
||||
outside.mkdir()
|
||||
(project / "checks").symlink_to(outside, target_is_directory=True)
|
||||
data["verificationRegistry"]["service-worktree-alignment"]["path"] = (
|
||||
"checks/verify.py"
|
||||
)
|
||||
knowledge_path = ack_dir / "knowledge.yaml"
|
||||
knowledge_path.write_text(
|
||||
yaml.safe_dump(data, allow_unicode=True), encoding="utf-8"
|
||||
)
|
||||
tasks_data = {
|
||||
"version": 1,
|
||||
"project": {
|
||||
"name": "notes-web",
|
||||
"repoPath": str(project / "missing"),
|
||||
"knowledgeFile": "docs/ack/not-the-current-file.yaml",
|
||||
},
|
||||
"tasks": [task],
|
||||
}
|
||||
tasks_path = ack_dir / "tasks.yaml"
|
||||
tasks_path.write_text(
|
||||
yaml.safe_dump(tasks_data, allow_unicode=True), encoding="utf-8"
|
||||
)
|
||||
|
||||
command = [
|
||||
sys.executable,
|
||||
str(SCRIPTS_DIR / "validate_knowledge.py"),
|
||||
str(knowledge_path),
|
||||
"--tasks",
|
||||
str(tasks_path),
|
||||
]
|
||||
inferred = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
explicit = subprocess.run(
|
||||
[*command, "--project-root", str(project)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(inferred.returncode, 1)
|
||||
self.assertIn("project.repoPath", inferred.stderr)
|
||||
self.assertIn("与当前知识文件", inferred.stderr)
|
||||
self.assertIn("symlink", inferred.stderr)
|
||||
self.assertEqual(explicit.returncode, 1)
|
||||
self.assertIn("与当前知识文件", explicit.stderr)
|
||||
self.assertIn("symlink", explicit.stderr)
|
||||
|
||||
def test_cli_requires_root_for_nonempty_registry_outside_project_layout(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
knowledge_path = Path(temp_dir) / "knowledge.yaml"
|
||||
knowledge_path.write_text(
|
||||
EXAMPLE_PATH.read_text(encoding="utf-8"), encoding="utf-8"
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPTS_DIR / "validate_knowledge.py"),
|
||||
str(knowledge_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("无法确定项目根目录", result.stderr)
|
||||
self.assertIn("--project-root", result.stderr)
|
||||
|
||||
def test_terminal_task_keeps_historical_ref_after_entry_becomes_stale(self) -> None:
|
||||
data = example_data()
|
||||
data["entries"][0]["status"] = "stale"
|
||||
data["entries"][0]["statusReason"] = "service architecture changed"
|
||||
task = {
|
||||
"id": "BUG-002",
|
||||
"status": "verified",
|
||||
"knowledgeRefs": ["K-001@1"],
|
||||
"knowledgeApplied": [],
|
||||
"knowledgeChecks": [
|
||||
{
|
||||
"ref": "K-001@1",
|
||||
"result": "passed",
|
||||
"evidence": "historical test evidence",
|
||||
}
|
||||
],
|
||||
"dispatch": {
|
||||
"rounds": [
|
||||
{
|
||||
"round": 1,
|
||||
"attemptId": "BUG-002-A1",
|
||||
"result": "failed",
|
||||
},
|
||||
{
|
||||
"round": 2,
|
||||
"attemptId": "BUG-002-A2",
|
||||
"result": "passed",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
tasks_data = {
|
||||
"project": {
|
||||
"name": "notes-web",
|
||||
"knowledgeFile": "docs/ack/knowledge.yaml",
|
||||
},
|
||||
"tasks": [task],
|
||||
}
|
||||
|
||||
terminal_errors = validate_knowledge.validate_task_references(
|
||||
data, tasks_data
|
||||
)
|
||||
self.assertFalse(any("必须 active" in error for error in terminal_errors))
|
||||
|
||||
task["status"] = "open"
|
||||
active_errors = validate_knowledge.validate_task_references(
|
||||
data, tasks_data
|
||||
)
|
||||
self.assertTrue(any("必须 active" in error for error in active_errors))
|
||||
|
||||
def test_cli_validates_tasks_and_selector_outputs_stable_refs(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
project = Path(temp_dir) / "project"
|
||||
ack_dir = project / "docs" / "ack"
|
||||
ack_dir.mkdir(parents=True)
|
||||
knowledge_path = ack_dir / "knowledge.yaml"
|
||||
knowledge_path.write_text(
|
||||
EXAMPLE_PATH.read_text(encoding="utf-8"), encoding="utf-8"
|
||||
)
|
||||
target = project / "tests" / "ack" / "check_service_worktree.py"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
target.chmod(target.stat().st_mode | 0o111)
|
||||
tasks = {
|
||||
"project": {
|
||||
"name": "notes-web",
|
||||
"repoPath": str(project),
|
||||
"knowledgeFile": "docs/ack/knowledge.yaml",
|
||||
},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "BUG-002",
|
||||
"status": "verified",
|
||||
"knowledgeRefs": ["K-001@1"],
|
||||
"knowledgeApplied": [],
|
||||
"knowledgeChecks": [
|
||||
{
|
||||
"ref": "K-001@1",
|
||||
"result": "passed",
|
||||
"evidence": "independent retest",
|
||||
}
|
||||
],
|
||||
"dispatch": {
|
||||
"rounds": [
|
||||
{
|
||||
"round": 1,
|
||||
"attemptId": "BUG-002-A1",
|
||||
"result": "failed",
|
||||
},
|
||||
{
|
||||
"round": 2,
|
||||
"attemptId": "BUG-002-A2",
|
||||
"result": "passed",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
tasks_path = ack_dir / "tasks.yaml"
|
||||
tasks_path.write_text(
|
||||
yaml.safe_dump(tasks, allow_unicode=True), encoding="utf-8"
|
||||
)
|
||||
validated = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPTS_DIR / "validate_knowledge.py"),
|
||||
str(knowledge_path),
|
||||
"--tasks",
|
||||
str(tasks_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(validated.returncode, 0, validated.stderr)
|
||||
|
||||
selected = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPTS_DIR / "select_knowledge.py"),
|
||||
str(knowledge_path),
|
||||
"--component",
|
||||
"web",
|
||||
"--path",
|
||||
"web/app.py",
|
||||
"--tag",
|
||||
"long-running-service",
|
||||
"--limit",
|
||||
"1",
|
||||
"--format",
|
||||
"refs",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(selected.returncode, 0, selected.stderr)
|
||||
self.assertEqual(selected.stdout.strip(), "K-001@1")
|
||||
|
||||
selected_json = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPTS_DIR / "select_knowledge.py"),
|
||||
str(knowledge_path),
|
||||
"--component",
|
||||
"web",
|
||||
"--path",
|
||||
"web/app.py",
|
||||
"--tag",
|
||||
"long-running-service",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(selected_json.returncode, 0, selected_json.stderr)
|
||||
payload = json.loads(selected_json.stdout)
|
||||
self.assertEqual(
|
||||
payload["entries"][0]["verificationTarget"]["path"],
|
||||
"tests/ack/check_service_worktree.py",
|
||||
)
|
||||
|
||||
def test_paired_examples_validate_when_installed_in_project_layout(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
project = Path(temp_dir) / "notes-web"
|
||||
ack_dir = project / "docs" / "ack"
|
||||
ack_dir.mkdir(parents=True)
|
||||
knowledge_path = ack_dir / "knowledge.yaml"
|
||||
tasks_path = ack_dir / "tasks.yaml"
|
||||
knowledge_path.write_text(
|
||||
EXAMPLE_PATH.read_text(encoding="utf-8"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
tasks = yaml.safe_load(
|
||||
TASKS_EXAMPLE_PATH.read_text(encoding="utf-8")
|
||||
)
|
||||
tasks["project"]["repoPath"] = str(project)
|
||||
tasks["project"]["devWorktree"] = str(project)
|
||||
tasks_path.write_text(
|
||||
yaml.safe_dump(tasks, allow_unicode=True, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
target = project / "tests" / "ack" / "check_service_worktree.py"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
target.chmod(target.stat().st_mode | 0o111)
|
||||
|
||||
tasks_result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPTS_DIR / "validate_tasks.py"),
|
||||
str(tasks_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
knowledge_result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPTS_DIR / "validate_knowledge.py"),
|
||||
str(knowledge_path),
|
||||
"--tasks",
|
||||
str(tasks_path),
|
||||
"--project-root",
|
||||
str(project),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(tasks_result.returncode, 0, tasks_result.stderr)
|
||||
self.assertEqual(
|
||||
knowledge_result.returncode,
|
||||
0,
|
||||
knowledge_result.stderr,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -15,8 +15,11 @@ class AckSkillContentTests(unittest.TestCase):
|
||||
"skiff init ack --project <project-root>",
|
||||
"docs/ack/project.md",
|
||||
"docs/ack/tasks.yaml",
|
||||
"docs/ack/knowledge.yaml",
|
||||
"tasks: []",
|
||||
"validate_tasks.py",
|
||||
"validate_knowledge.py",
|
||||
"select_knowledge.py",
|
||||
"references/kickoff.md",
|
||||
"不要修改项目的 `AGENTS.md`",
|
||||
"当前会话担任 Coordinator",
|
||||
@@ -31,6 +34,20 @@ class AckSkillContentTests(unittest.TestCase):
|
||||
self.assertIn('display_name: "ACK"', metadata)
|
||||
self.assertIn("allow_implicit_invocation: false", metadata)
|
||||
|
||||
def test_ack_knowledge_resources_and_version_are_present(self) -> None:
|
||||
ack_dir = REPO_ROOT / "skills" / "ack"
|
||||
|
||||
for relative_path in (
|
||||
"templates/knowledge.template.yaml",
|
||||
"templates/knowledge.schema.json",
|
||||
"examples/knowledge.example.yaml",
|
||||
"scripts/validate_knowledge.py",
|
||||
"scripts/select_knowledge.py",
|
||||
"scripts/run_verification.py",
|
||||
):
|
||||
self.assertTrue((ack_dir / relative_path).is_file(), relative_path)
|
||||
self.assertEqual((ack_dir / "VERSION").read_text(encoding="utf-8").strip(), "0.9.0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
VALIDATOR = REPO_ROOT / "skills" / "ack" / "scripts" / "validate_tasks.py"
|
||||
EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "tasks.example.yaml"
|
||||
|
||||
|
||||
def valid_knowledge_board() -> dict:
|
||||
return {
|
||||
"version": 1,
|
||||
"project": {"name": "demo"},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "T-1",
|
||||
"title": "validate knowledge fields",
|
||||
"status": "open",
|
||||
"knowledgeRefs": ["K-001@1"],
|
||||
"knowledgeApplied": [
|
||||
{
|
||||
"ref": "K-001@1",
|
||||
"result": "applied",
|
||||
"evidence": "followed the guardrail",
|
||||
}
|
||||
],
|
||||
"knowledgeCandidates": [
|
||||
{
|
||||
"kind": "pitfall",
|
||||
"title": "candidate",
|
||||
"claim": "the failure is reproducible",
|
||||
"scope": {"components": ["web"]},
|
||||
"appliesWhen": "the web component changes",
|
||||
"directive": "run the reviewed check",
|
||||
"rationale": "avoid the repeated failure",
|
||||
"evidenceRefs": ["tasks.yaml#T-1"],
|
||||
"proposedBy": "developer",
|
||||
"proposedAt": "2026-07-31T10:00:00+08:00",
|
||||
}
|
||||
],
|
||||
"knowledgeChecks": [
|
||||
{
|
||||
"ref": "K-001@1",
|
||||
"result": "passed",
|
||||
"evidence": "independently verified",
|
||||
"checkedBy": "test",
|
||||
"checkedAt": "2026-07-31T10:05:00+08:00",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class AckTaskValidationTests(unittest.TestCase):
|
||||
def run_validator(
|
||||
self,
|
||||
content: str | None = None,
|
||||
*extra_args: str,
|
||||
no_site_packages: bool = False,
|
||||
suffix: str = ".yaml",
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
command = [sys.executable]
|
||||
if no_site_packages:
|
||||
command.append("-S")
|
||||
command.append(str(VALIDATOR))
|
||||
|
||||
if content is None:
|
||||
return subprocess.run(
|
||||
[*command, *extra_args, str(EXAMPLE)],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
task_file = Path(temp_dir) / f"tasks{suffix}"
|
||||
task_file.write_text(textwrap.dedent(content), encoding="utf-8")
|
||||
return subprocess.run(
|
||||
[*command, *extra_args, str(task_file)],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def assert_board_rejected_in_all_modes(
|
||||
self,
|
||||
board: dict,
|
||||
*expected_messages: str,
|
||||
) -> None:
|
||||
for no_site_packages in (False, True):
|
||||
with self.subTest(no_site_packages=no_site_packages):
|
||||
result = self.run_validator(
|
||||
json.dumps(board),
|
||||
no_site_packages=no_site_packages,
|
||||
suffix=".json",
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 1, result.stdout)
|
||||
for message in expected_messages:
|
||||
self.assertIn(message, result.stderr)
|
||||
if no_site_packages:
|
||||
self.assertIn("内置语义规则", result.stderr)
|
||||
self.assertNotIn("[schema]", result.stderr)
|
||||
|
||||
def assert_board_accepted_in_all_modes(self, board: dict) -> None:
|
||||
for no_site_packages in (False, True):
|
||||
with self.subTest(no_site_packages=no_site_packages):
|
||||
result = self.run_validator(
|
||||
json.dumps(board),
|
||||
no_site_packages=no_site_packages,
|
||||
suffix=".json",
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_example_with_knowledge_fields_is_valid(self) -> None:
|
||||
result = self.run_validator()
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("任务板校验通过", result.stdout)
|
||||
|
||||
def test_knowledge_applied_and_checks_must_reference_selected_knowledge(self) -> None:
|
||||
result = self.run_validator(
|
||||
"""
|
||||
version: 1
|
||||
project:
|
||||
name: demo
|
||||
tasks:
|
||||
- id: T-1
|
||||
title: invalid refs
|
||||
status: verified
|
||||
knowledgeRefs: ["K-001@1"]
|
||||
knowledgeApplied:
|
||||
- ref: "K-002@1"
|
||||
result: applied
|
||||
evidence: "used the rule"
|
||||
knowledgeChecks:
|
||||
- ref: "K-003@1"
|
||||
result: failed
|
||||
evidence: "still broken"
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("K-002@1 不在 knowledgeRefs 中", result.stderr)
|
||||
self.assertIn("K-003@1 不在 knowledgeRefs 中", result.stderr)
|
||||
self.assertIn("verified 任务不能保留失败", result.stderr)
|
||||
|
||||
def test_knowledge_refs_require_revision(self) -> None:
|
||||
result = self.run_validator(
|
||||
"""
|
||||
version: 1
|
||||
project:
|
||||
name: demo
|
||||
tasks:
|
||||
- id: T-1
|
||||
title: invalid ref
|
||||
status: open
|
||||
knowledgeRefs: ["K-001"]
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("K-<id>@<revision>", result.stderr)
|
||||
|
||||
def test_candidate_requires_actionable_scope_and_evidence(self) -> None:
|
||||
result = self.run_validator(
|
||||
"""
|
||||
version: 1
|
||||
project:
|
||||
name: demo
|
||||
tasks:
|
||||
- id: T-1
|
||||
title: invalid candidate
|
||||
status: open
|
||||
knowledgeCandidates:
|
||||
- kind: guess
|
||||
title: maybe
|
||||
claim: uncertain
|
||||
scope: {}
|
||||
appliesWhen: sometimes
|
||||
directive: retry
|
||||
rationale: unknown
|
||||
evidenceRefs: []
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn(".kind: 必须是", result.stderr)
|
||||
self.assertIn(".scope: 至少包含一个非空作用域", result.stderr)
|
||||
self.assertIn(".evidenceRefs: 必须是非空字符串列表", result.stderr)
|
||||
|
||||
def test_forbidden_knowledge_properties_fail_with_and_without_jsonschema(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
task = board["tasks"][0]
|
||||
task["knowledgeApplied"][0]["unexpectedApplication"] = True
|
||||
task["knowledgeCandidates"][0]["unexpectedCandidate"] = True
|
||||
task["knowledgeChecks"][0]["unexpectedCheck"] = True
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"knowledgeApplied[0]: 未知字段 'unexpectedApplication'",
|
||||
"knowledgeCandidates[0]: 未知字段 'unexpectedCandidate'",
|
||||
"knowledgeChecks[0]: 未知字段 'unexpectedCheck'",
|
||||
)
|
||||
|
||||
def test_valid_optional_knowledge_fields_pass_in_all_modes(self) -> None:
|
||||
self.assert_board_accepted_in_all_modes(valid_knowledge_board())
|
||||
|
||||
def test_explicit_null_knowledge_collections_fail_in_all_modes(self) -> None:
|
||||
board = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo"},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "T-REFS",
|
||||
"title": "null refs",
|
||||
"status": "open",
|
||||
"knowledgeRefs": None,
|
||||
},
|
||||
{
|
||||
"id": "T-APPLIED",
|
||||
"title": "null applications",
|
||||
"status": "open",
|
||||
"knowledgeApplied": None,
|
||||
},
|
||||
{
|
||||
"id": "T-CANDIDATES",
|
||||
"title": "null candidates",
|
||||
"status": "open",
|
||||
"knowledgeCandidates": None,
|
||||
},
|
||||
{
|
||||
"id": "T-CHECKS",
|
||||
"title": "null checks",
|
||||
"status": "open",
|
||||
"knowledgeChecks": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"knowledgeRefs: 必须是列表",
|
||||
"knowledgeApplied: 必须是列表",
|
||||
"knowledgeCandidates: 必须是列表",
|
||||
"knowledgeChecks: 必须是列表",
|
||||
)
|
||||
|
||||
def test_knowledge_item_types_and_blank_evidence_fail_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
task = board["tasks"][0]
|
||||
task["knowledgeApplied"][0].update(
|
||||
{"ref": 1, "result": "unknown", "evidence": " "}
|
||||
)
|
||||
task["knowledgeChecks"][0].update(
|
||||
{"ref": False, "result": "unknown", "evidence": "\t"}
|
||||
)
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"knowledgeApplied[0].ref: 必须使用 K-<id>@<revision> 格式",
|
||||
"knowledgeApplied[0].result: 必须是 applied/not_applicable",
|
||||
"knowledgeApplied[0].evidence: 必须提供非空证据",
|
||||
"knowledgeChecks[0].ref: 必须使用 K-<id>@<revision> 格式",
|
||||
"knowledgeChecks[0].result: 必须是",
|
||||
"knowledgeChecks[0].evidence: 必须提供非空证据",
|
||||
)
|
||||
|
||||
def test_candidate_text_scope_and_evidence_refs_fail_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
candidate = board["tasks"][0]["knowledgeCandidates"][0]
|
||||
candidate.update(
|
||||
{
|
||||
"title": 1,
|
||||
"claim": " ",
|
||||
"appliesWhen": [],
|
||||
"directive": "",
|
||||
"rationale": None,
|
||||
"scope": {
|
||||
"components": ["web", "web"],
|
||||
"paths": [" "],
|
||||
},
|
||||
"evidenceRefs": ["tasks.yaml#T-1", "tasks.yaml#T-1"],
|
||||
}
|
||||
)
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"knowledgeCandidates[0].title: 必须是非空字符串",
|
||||
"knowledgeCandidates[0].claim: 必须是非空字符串",
|
||||
"knowledgeCandidates[0].appliesWhen: 必须是非空字符串",
|
||||
"knowledgeCandidates[0].directive: 必须是非空字符串",
|
||||
"knowledgeCandidates[0].rationale: 必须是非空字符串",
|
||||
"knowledgeCandidates[0].scope.components: 不能包含重复值",
|
||||
"knowledgeCandidates[0].scope.paths: 必须是非空字符串列表",
|
||||
"knowledgeCandidates[0].evidenceRefs: 不能包含重复值",
|
||||
)
|
||||
|
||||
def test_candidate_evidence_refs_reject_blank_strings_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board["tasks"][0]["knowledgeCandidates"][0]["evidenceRefs"] = [" "]
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"knowledgeCandidates[0].evidenceRefs: 必须是非空字符串列表",
|
||||
)
|
||||
|
||||
def test_optional_knowledge_field_types_fail_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
candidate = board["tasks"][0]["knowledgeCandidates"][0]
|
||||
candidate["proposedBy"] = 1
|
||||
candidate["proposedAt"] = []
|
||||
check = board["tasks"][0]["knowledgeChecks"][0]
|
||||
check["checkedBy"] = False
|
||||
check["checkedAt"] = {}
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"knowledgeCandidates[0].proposedBy: 必须是字符串",
|
||||
"knowledgeCandidates[0].proposedAt: 必须是字符串",
|
||||
"knowledgeChecks[0].checkedBy: 必须是字符串",
|
||||
"knowledgeChecks[0].checkedAt: 必须是字符串",
|
||||
)
|
||||
|
||||
def test_attempt_id_must_match_task_and_round_and_be_unique(self) -> None:
|
||||
board = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo"},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "BUG-017",
|
||||
"title": "invalid attempt ids",
|
||||
"status": "failed_retest",
|
||||
"dispatch": {
|
||||
"rounds": [
|
||||
{
|
||||
"round": 1,
|
||||
"attemptId": "OTHER-A1",
|
||||
"result": "failed",
|
||||
},
|
||||
{
|
||||
"round": 1,
|
||||
"attemptId": "OTHER-A1",
|
||||
"result": "failed",
|
||||
},
|
||||
{
|
||||
"round": 3,
|
||||
"attemptId": "invalid/attempt",
|
||||
"result": "failed",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"dispatch.rounds[0].attemptId: 应为 BUG-017-A1",
|
||||
"dispatch.rounds[1].attemptId: 轮次内不能重复: OTHER-A1",
|
||||
"dispatch.rounds[2].attemptId: 必须使用 <task-id>-A<round> 格式",
|
||||
)
|
||||
|
||||
def test_valid_optional_attempt_ids_pass_in_all_modes(self) -> None:
|
||||
board = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo"},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "BUG-017",
|
||||
"title": "valid attempt ids",
|
||||
"status": "failed_retest",
|
||||
"dispatch": {
|
||||
"rounds": [
|
||||
{
|
||||
"round": 1,
|
||||
"attemptId": "BUG-017-A1",
|
||||
"result": "failed",
|
||||
},
|
||||
{
|
||||
"round": 2,
|
||||
"attemptId": "BUG-017-A2",
|
||||
"result": "failed",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
self.assert_board_accepted_in_all_modes(board)
|
||||
|
||||
def test_boolean_version_fails_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board["version"] = True
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"version 必须是 >=1 的整数",
|
||||
)
|
||||
|
||||
def test_knowledge_file_is_fixed_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board["project"]["knowledgeFile"] = "docs/ack/alternate.yaml"
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"project.knowledgeFile 必须固定为 docs/ack/knowledge.yaml",
|
||||
)
|
||||
|
||||
def test_basic_identifiers_must_be_nonempty_strings_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board["project"]["name"] = 7
|
||||
board["tasks"][0]["id"] = 9
|
||||
board["tasks"][0]["title"] = " "
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"project.name 必须是非空字符串",
|
||||
"id 必须是非空字符串",
|
||||
"title 必须是非空字符串",
|
||||
)
|
||||
|
||||
def test_root_project_and_summary_types_match_schema_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board.update(
|
||||
{
|
||||
"updatedAt": [],
|
||||
"source": {},
|
||||
"ackVersion": 1,
|
||||
"kitVersion": False,
|
||||
"summary": {
|
||||
"verified": [1],
|
||||
"open": {},
|
||||
"failedRetest": [False],
|
||||
"leftovers": None,
|
||||
},
|
||||
"statusReference": [],
|
||||
}
|
||||
)
|
||||
board["project"].update(
|
||||
{
|
||||
"repoPath": [],
|
||||
"baseUrl": {},
|
||||
"devWorktree": 1,
|
||||
"overlayFile": False,
|
||||
}
|
||||
)
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"<root>.updatedAt: 必须是字符串",
|
||||
"<root>.source: 必须是字符串",
|
||||
"<root>.ackVersion: 必须是字符串",
|
||||
"<root>.kitVersion: 必须是字符串",
|
||||
"project.repoPath: 必须是字符串",
|
||||
"project.baseUrl: 必须是字符串",
|
||||
"project.devWorktree: 必须是字符串",
|
||||
"project.overlayFile: 必须是字符串",
|
||||
"summary.verified: 列表项必须是字符串",
|
||||
"summary.open: 必须是列表",
|
||||
"summary.failedRetest: 列表项必须是字符串",
|
||||
"summary.leftovers: 必须是列表",
|
||||
"statusReference 必须是对象",
|
||||
)
|
||||
|
||||
def test_task_optional_types_match_schema_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board["tasks"][0].update(
|
||||
{
|
||||
"type": [],
|
||||
"priority": {},
|
||||
"assignee": False,
|
||||
"component": 1,
|
||||
"specRefs": {},
|
||||
"testRefs": [1],
|
||||
"description": [],
|
||||
"stepsToReproduce": [{}],
|
||||
"expected": False,
|
||||
"actual": None,
|
||||
"evidence": [],
|
||||
"verification": [],
|
||||
}
|
||||
)
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
".type: 必须是字符串",
|
||||
".priority: 必须是字符串",
|
||||
".assignee: 必须是字符串",
|
||||
".component: 必须是字符串",
|
||||
".specRefs: 必须是列表",
|
||||
".testRefs: 列表项必须是字符串",
|
||||
".description: 必须是字符串",
|
||||
".stepsToReproduce: 列表项必须是字符串",
|
||||
".expected: 必须是字符串",
|
||||
".actual: 必须是字符串",
|
||||
".evidence: 必须是对象",
|
||||
".verification: 必须是对象",
|
||||
)
|
||||
|
||||
def test_dispatch_and_resolution_types_match_schema_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board["tasks"][0].update(
|
||||
{
|
||||
"dispatch": {
|
||||
"taskId": [],
|
||||
"dispatchId": {},
|
||||
"worker": False,
|
||||
"rounds": [
|
||||
{
|
||||
"round": 1,
|
||||
"result": "failed",
|
||||
"evidence": [],
|
||||
}
|
||||
],
|
||||
},
|
||||
"resolution": {
|
||||
"fixedBy": [],
|
||||
"verifiedBy": {},
|
||||
"verifiedAt": False,
|
||||
"leftoverReason": 1,
|
||||
"evidence": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
".dispatch.taskId: 必须是字符串或 null",
|
||||
".dispatch.dispatchId: 必须是字符串或 null",
|
||||
".dispatch.worker: 必须是字符串或 null",
|
||||
".dispatch.rounds[0].evidence: 必须是字符串",
|
||||
".resolution.fixedBy: 必须是字符串或 null",
|
||||
".resolution.verifiedBy: 必须是字符串或 null",
|
||||
".resolution.verifiedAt: 必须是字符串或 null",
|
||||
".resolution.leftoverReason: 必须是字符串或 null",
|
||||
".resolution.evidence: 必须是对象",
|
||||
)
|
||||
|
||||
def test_explicit_null_structures_fail_in_all_modes(self) -> None:
|
||||
board = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo"},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "T-DISPATCH",
|
||||
"title": "invalid dispatch",
|
||||
"status": "open",
|
||||
"dispatch": None,
|
||||
},
|
||||
{
|
||||
"id": "T-ROUNDS",
|
||||
"title": "invalid rounds",
|
||||
"status": "open",
|
||||
"dispatch": {"rounds": None},
|
||||
},
|
||||
{
|
||||
"id": "T-RESOLUTION",
|
||||
"title": "invalid resolution",
|
||||
"status": "open",
|
||||
"resolution": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"T-DISPATCH.dispatch: 必须是对象",
|
||||
"T-ROUNDS.dispatch.rounds: 必须是列表",
|
||||
"T-RESOLUTION.resolution: 必须是对象",
|
||||
)
|
||||
|
||||
def test_valid_optional_schema_fields_pass_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board.update(
|
||||
{
|
||||
"updatedAt": "2026-07-31T10:00:00+08:00",
|
||||
"source": "manual",
|
||||
"ackVersion": "0.9.0",
|
||||
"kitVersion": "0.8.0",
|
||||
"summary": {
|
||||
"verified": ["T-1"],
|
||||
"open": [],
|
||||
"failedRetest": [],
|
||||
"leftovers": [],
|
||||
},
|
||||
"statusReference": {},
|
||||
}
|
||||
)
|
||||
board["project"].update(
|
||||
{
|
||||
"repoPath": "/repo",
|
||||
"baseUrl": "http://127.0.0.1:3000",
|
||||
"devWorktree": "/repo-dev",
|
||||
"overlayFile": "docs/ack/project.md",
|
||||
"knowledgeFile": "docs/ack/knowledge.yaml",
|
||||
}
|
||||
)
|
||||
board["tasks"][0].update(
|
||||
{
|
||||
"type": "bug",
|
||||
"priority": "P1",
|
||||
"assignee": "developer",
|
||||
"component": "web",
|
||||
"specRefs": ["spec.md"],
|
||||
"testRefs": ["tests/test_web.py"],
|
||||
"description": "description",
|
||||
"stepsToReproduce": ["open page"],
|
||||
"expected": "works",
|
||||
"actual": "fails",
|
||||
"evidence": {},
|
||||
"verification": {},
|
||||
"dispatch": {
|
||||
"taskId": "orca-task",
|
||||
"dispatchId": None,
|
||||
"worker": "worker-1",
|
||||
"rounds": [
|
||||
{
|
||||
"round": 1,
|
||||
"attemptId": "T-1-A1",
|
||||
"result": "failed",
|
||||
"evidence": "test output",
|
||||
}
|
||||
],
|
||||
},
|
||||
"resolution": {
|
||||
"fixedBy": "developer",
|
||||
"verifiedBy": None,
|
||||
"verifiedAt": None,
|
||||
"leftoverReason": None,
|
||||
"evidence": {},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assert_board_accepted_in_all_modes(board)
|
||||
|
||||
def test_round_numbers_must_be_contiguous_and_within_budget(self) -> None:
|
||||
board = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo"},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "BUG-017",
|
||||
"title": "invalid round number",
|
||||
"status": "failed_retest",
|
||||
"dispatch": {
|
||||
"rounds": [
|
||||
{
|
||||
"round": 999,
|
||||
"attemptId": "BUG-017-A999",
|
||||
"result": "failed",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"dispatch.rounds[0].round: 必须是 1..3 的整数",
|
||||
"dispatch.rounds: round 必须从 1 连续递增且不重复",
|
||||
)
|
||||
|
||||
def test_leftover_reason_must_be_nonempty_string_in_all_modes(self) -> None:
|
||||
board = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo"},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "T-1",
|
||||
"title": "invalid leftover reason",
|
||||
"status": "leftover",
|
||||
"resolution": {"leftoverReason": True},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"leftover 必须填 resolution.leftoverReason",
|
||||
)
|
||||
|
||||
def test_explicit_missing_schema_is_an_environment_error(self) -> None:
|
||||
result = self.run_validator(None, "--schema", "/definitely/missing/schema.json")
|
||||
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("找不到指定的 schema 文件", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,591 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNNER = REPO_ROOT / "skills" / "ack" / "scripts" / "run_verification.py"
|
||||
sys.path.insert(0, str(RUNNER.parent))
|
||||
RUNNER_SPEC = importlib.util.spec_from_file_location("ack_run_verification", RUNNER)
|
||||
assert RUNNER_SPEC is not None and RUNNER_SPEC.loader is not None
|
||||
RUNNER_MODULE = importlib.util.module_from_spec(RUNNER_SPEC)
|
||||
RUNNER_SPEC.loader.exec_module(RUNNER_MODULE)
|
||||
|
||||
|
||||
def knowledge_with_target(path: str, args: list[str]) -> dict:
|
||||
return {
|
||||
"version": 1,
|
||||
"updatedAt": "2026-07-31T12:00:00+08:00",
|
||||
"project": {"name": "demo"},
|
||||
"verificationRegistry": {
|
||||
"reviewed-check": {
|
||||
"path": path,
|
||||
"args": args,
|
||||
}
|
||||
},
|
||||
"entries": [],
|
||||
}
|
||||
|
||||
|
||||
class AckVerificationRunnerTests(unittest.TestCase):
|
||||
def run_check(
|
||||
self,
|
||||
project: Path,
|
||||
knowledge: dict,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
knowledge_path = project / "docs" / "ack" / "knowledge.yaml"
|
||||
knowledge_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
knowledge_path.write_text(
|
||||
yaml.safe_dump(knowledge, allow_unicode=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(RUNNER),
|
||||
str(knowledge_path),
|
||||
"reviewed-check",
|
||||
"--project-root",
|
||||
str(project),
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def test_executes_reviewed_target_with_structured_args(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
project = Path(temp_dir) / "project"
|
||||
target = project / "checks" / "write-marker"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text(
|
||||
"#!/bin/sh\nprintf 'passed' > \"$1\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
target.chmod(target.stat().st_mode | 0o111)
|
||||
|
||||
result = self.run_check(
|
||||
project,
|
||||
knowledge_with_target(
|
||||
"checks/write-marker",
|
||||
["verification-marker.txt"],
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(
|
||||
(project / "verification-marker.txt").read_text(encoding="utf-8"),
|
||||
"passed",
|
||||
)
|
||||
|
||||
def test_exposes_project_rooted_execution_contract(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
project = Path(temp_dir) / "project"
|
||||
target = project / "checks" / "show-context"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text(
|
||||
"#!/bin/sh\n"
|
||||
"printf '%s\\n%s\\n%s\\n%s\\n' "
|
||||
'"$PWD" "$ACK_PROJECT_ROOT" "$ACK_VERIFICATION_REF" '
|
||||
'"$ACK_VERIFICATION_PATH" > "$1"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
target.chmod(target.stat().st_mode | 0o111)
|
||||
|
||||
result = self.run_check(
|
||||
project,
|
||||
knowledge_with_target(
|
||||
"checks/show-context",
|
||||
["verification-context.txt"],
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
context = (
|
||||
(project / "verification-context.txt")
|
||||
.read_text(encoding="utf-8")
|
||||
.splitlines()
|
||||
)
|
||||
self.assertEqual(context[0], str(project))
|
||||
self.assertRegex(context[1], r"^/(?:proc/self|dev)/fd/[0-9]+$")
|
||||
self.assertEqual(context[2:], ["reviewed-check", "checks/show-context"])
|
||||
|
||||
def test_rejects_missing_or_non_executable_target(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
project = Path(temp_dir) / "project"
|
||||
project.mkdir()
|
||||
|
||||
missing = self.run_check(
|
||||
project,
|
||||
knowledge_with_target("checks/missing", []),
|
||||
)
|
||||
self.assertEqual(missing.returncode, 1)
|
||||
self.assertIn("不存在", missing.stderr)
|
||||
|
||||
target = project / "checks" / "not-executable"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
target.chmod(target.stat().st_mode & ~0o111)
|
||||
not_executable = self.run_check(
|
||||
project,
|
||||
knowledge_with_target("checks/not-executable", []),
|
||||
)
|
||||
|
||||
self.assertEqual(not_executable.returncode, 1)
|
||||
self.assertIn("不可执行", not_executable.stderr)
|
||||
|
||||
def test_rejects_project_root_that_is_wider_than_knowledge_project(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
base = Path(temp_dir)
|
||||
project = base / "project"
|
||||
outside = base / "outside"
|
||||
outside.mkdir()
|
||||
target = outside / "unsafe"
|
||||
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
target.chmod(target.stat().st_mode | 0o111)
|
||||
knowledge_path = project / "docs" / "ack" / "knowledge.yaml"
|
||||
knowledge_path.parent.mkdir(parents=True)
|
||||
knowledge_path.write_text(
|
||||
yaml.safe_dump(
|
||||
knowledge_with_target("outside/unsafe", []),
|
||||
allow_unicode=True,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(RUNNER),
|
||||
str(knowledge_path),
|
||||
"reviewed-check",
|
||||
"--project-root",
|
||||
str(base),
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("推断的项目根目录", result.stderr)
|
||||
|
||||
def test_rejects_duplicate_registry_ids_before_execution(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
project = Path(temp_dir) / "project"
|
||||
checks = project / "checks"
|
||||
checks.mkdir(parents=True)
|
||||
marker = project / "unsafe-marker"
|
||||
for name in ("safe", "unsafe"):
|
||||
target = checks / name
|
||||
target.write_text(
|
||||
f"#!/bin/sh\nprintf '{name}' > {marker}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
target.chmod(target.stat().st_mode | 0o111)
|
||||
knowledge_path = project / "docs" / "ack" / "knowledge.yaml"
|
||||
knowledge_path.parent.mkdir(parents=True)
|
||||
knowledge_path.write_text(
|
||||
"""\
|
||||
version: 1
|
||||
updatedAt: "2026-07-31T12:00:00+08:00"
|
||||
project:
|
||||
name: demo
|
||||
verificationRegistry:
|
||||
reviewed-check:
|
||||
path: checks/safe
|
||||
args: []
|
||||
reviewed-check:
|
||||
path: checks/unsafe
|
||||
args: []
|
||||
entries: []
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(RUNNER),
|
||||
str(knowledge_path),
|
||||
"reviewed-check",
|
||||
"--project-root",
|
||||
str(project),
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("duplicate key", result.stderr)
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
@unittest.skipUnless(hasattr(os, "symlink"), "requires symlink support")
|
||||
def test_rejects_non_authoritative_knowledge_files(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
base = Path(temp_dir)
|
||||
project = base / "project"
|
||||
ack_dir = project / "docs" / "ack"
|
||||
ack_dir.mkdir(parents=True)
|
||||
document = yaml.safe_dump(
|
||||
knowledge_with_target("checks/reviewed", []),
|
||||
allow_unicode=True,
|
||||
)
|
||||
|
||||
for label, knowledge_path in (
|
||||
("external", base / "attacker-knowledge.yaml"),
|
||||
("alternate", ack_dir / "alternate.yaml"),
|
||||
):
|
||||
with self.subTest(label=label):
|
||||
knowledge_path.write_text(document, encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(RUNNER),
|
||||
str(knowledge_path),
|
||||
"reviewed-check",
|
||||
"--project-root",
|
||||
str(project),
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("权威知识库", result.stderr)
|
||||
|
||||
outside = base / "outside-knowledge.yaml"
|
||||
outside.write_text(document, encoding="utf-8")
|
||||
canonical = ack_dir / "knowledge.yaml"
|
||||
canonical.symlink_to(outside)
|
||||
symlinked = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(RUNNER),
|
||||
str(canonical),
|
||||
"reviewed-check",
|
||||
"--project-root",
|
||||
str(project),
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(symlinked.returncode, 2)
|
||||
self.assertIn("路径包含软链接", symlinked.stderr)
|
||||
|
||||
def test_authoritative_knowledge_is_read_from_an_opened_snapshot(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
base = Path(temp_dir)
|
||||
project = base / "project"
|
||||
canonical = project / "docs" / "ack" / "knowledge.yaml"
|
||||
canonical.parent.mkdir(parents=True)
|
||||
safe_document = knowledge_with_target("checks/safe", [])
|
||||
forged_document = knowledge_with_target("checks/danger", [])
|
||||
canonical.write_text(
|
||||
yaml.safe_dump(safe_document, allow_unicode=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
forged_path = base / "forged.yaml"
|
||||
forged_path.write_text(
|
||||
yaml.safe_dump(forged_document, allow_unicode=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
original_reader = RUNNER_MODULE._read_stable_bytes
|
||||
|
||||
def swap_after_open(
|
||||
source_fd: int,
|
||||
*,
|
||||
maximum: int,
|
||||
) -> tuple[bytes | None, str | None]:
|
||||
canonical.unlink()
|
||||
canonical.symlink_to(forged_path)
|
||||
return original_reader(source_fd, maximum=maximum)
|
||||
|
||||
with mock.patch.object(
|
||||
RUNNER_MODULE,
|
||||
"_read_stable_bytes",
|
||||
side_effect=swap_after_open,
|
||||
):
|
||||
data, error = RUNNER_MODULE.load_authoritative_knowledge(project)
|
||||
|
||||
self.assertIsNone(error)
|
||||
self.assertEqual(
|
||||
data["verificationRegistry"]["reviewed-check"]["path"],
|
||||
"checks/safe",
|
||||
)
|
||||
|
||||
def test_authoritative_knowledge_rejects_same_inode_change_during_read(
|
||||
self,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
project = Path(temp_dir) / "project"
|
||||
canonical = project / "docs" / "ack" / "knowledge.yaml"
|
||||
canonical.parent.mkdir(parents=True)
|
||||
canonical.write_text(
|
||||
yaml.safe_dump(
|
||||
knowledge_with_target("checks/safe", []),
|
||||
allow_unicode=True,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
source_fd, error = RUNNER_MODULE._open_regular_beneath(
|
||||
project,
|
||||
"docs/ack/knowledge.yaml",
|
||||
require_executable=False,
|
||||
)
|
||||
self.assertIsNone(error)
|
||||
self.assertIsNotNone(source_fd)
|
||||
assert source_fd is not None
|
||||
original_read = os.read
|
||||
changed = False
|
||||
|
||||
def mutate_during_read(
|
||||
file_descriptor: int,
|
||||
size: int,
|
||||
) -> bytes:
|
||||
nonlocal changed
|
||||
chunk = original_read(file_descriptor, size)
|
||||
if not changed:
|
||||
changed = True
|
||||
canonical.write_text(
|
||||
yaml.safe_dump(
|
||||
knowledge_with_target("checks/danger", []),
|
||||
allow_unicode=True,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return chunk
|
||||
|
||||
try:
|
||||
with mock.patch.object(
|
||||
RUNNER_MODULE.os,
|
||||
"read",
|
||||
side_effect=mutate_during_read,
|
||||
):
|
||||
content, read_error = RUNNER_MODULE._read_stable_bytes(
|
||||
source_fd,
|
||||
maximum=RUNNER_MODULE.MAX_KNOWLEDGE_BYTES,
|
||||
)
|
||||
finally:
|
||||
os.close(source_fd)
|
||||
|
||||
self.assertIsNone(content)
|
||||
self.assertIn("读取期间发生变化", read_error)
|
||||
|
||||
@unittest.skipUnless(hasattr(os, "symlink"), "requires symlink support")
|
||||
def test_rejects_target_that_resolves_outside_project(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
base = Path(temp_dir)
|
||||
project = base / "project"
|
||||
outside = base / "outside"
|
||||
project.mkdir()
|
||||
outside.mkdir()
|
||||
target = outside / "unsafe"
|
||||
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
target.chmod(target.stat().st_mode | 0o111)
|
||||
(project / "checks").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
result = self.run_check(
|
||||
project,
|
||||
knowledge_with_target("checks/unsafe", []),
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("不能包含 symlink", result.stderr)
|
||||
|
||||
@unittest.skipUnless(
|
||||
hasattr(os, "O_NOFOLLOW") and Path("/proc/self/fd").is_dir(),
|
||||
"requires fd-based POSIX execution",
|
||||
)
|
||||
def test_opened_target_cannot_be_swapped_before_execution(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
base = Path(temp_dir)
|
||||
project = base / "project"
|
||||
target = project / "checks" / "reviewed"
|
||||
outside = base / "outside"
|
||||
marker = project / "marker.txt"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text(
|
||||
"#!/bin/sh\nprintf 'reviewed' > \"$1\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
target.chmod(target.stat().st_mode | 0o111)
|
||||
outside.write_text(
|
||||
"#!/bin/sh\nprintf 'swapped' > \"$1\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
outside.chmod(outside.stat().st_mode | 0o111)
|
||||
data = knowledge_with_target("checks/reviewed", [str(marker)])
|
||||
|
||||
target_fd, target_args, error = RUNNER_MODULE.open_target(
|
||||
data,
|
||||
"reviewed-check",
|
||||
project,
|
||||
)
|
||||
self.assertIsNone(error)
|
||||
self.assertIsNotNone(target_fd)
|
||||
self.assertIsNotNone(target_args)
|
||||
assert target_fd is not None and target_args is not None
|
||||
target.unlink()
|
||||
target.symlink_to(outside)
|
||||
executable = RUNNER_MODULE._fd_executable_path(target_fd)
|
||||
self.assertIsNotNone(executable)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[str(executable), *target_args],
|
||||
cwd=project,
|
||||
pass_fds=(target_fd,),
|
||||
check=False,
|
||||
)
|
||||
finally:
|
||||
os.close(target_fd)
|
||||
|
||||
self.assertEqual(completed.returncode, 0)
|
||||
self.assertEqual(marker.read_text(encoding="utf-8"), "reviewed")
|
||||
|
||||
@unittest.skipUnless(
|
||||
hasattr(os, "O_NOFOLLOW") and Path("/proc/self/fd").is_dir(),
|
||||
"requires fd-based POSIX execution",
|
||||
)
|
||||
def test_opened_target_snapshot_ignores_same_inode_rewrite(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
project = Path(temp_dir) / "project"
|
||||
target = project / "checks" / "reviewed"
|
||||
marker = project / "marker.txt"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text(
|
||||
"#!/bin/sh\nprintf 'reviewed' > \"$1\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
target.chmod(target.stat().st_mode | 0o111)
|
||||
data = knowledge_with_target("checks/reviewed", [str(marker)])
|
||||
|
||||
target_fd, target_args, error = RUNNER_MODULE.open_target(
|
||||
data,
|
||||
"reviewed-check",
|
||||
project,
|
||||
)
|
||||
self.assertIsNone(error)
|
||||
self.assertIsNotNone(target_fd)
|
||||
self.assertIsNotNone(target_args)
|
||||
assert target_fd is not None and target_args is not None
|
||||
target.write_text(
|
||||
"#!/bin/sh\nprintf 'mutated' > \"$1\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
target.chmod(target.stat().st_mode | 0o111)
|
||||
executable = RUNNER_MODULE._fd_executable_path(target_fd)
|
||||
self.assertIsNotNone(executable)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[str(executable), *target_args],
|
||||
cwd=project,
|
||||
pass_fds=(target_fd,),
|
||||
check=False,
|
||||
)
|
||||
finally:
|
||||
os.close(target_fd)
|
||||
|
||||
self.assertEqual(completed.returncode, 0)
|
||||
self.assertEqual(marker.read_text(encoding="utf-8"), "reviewed")
|
||||
|
||||
@unittest.skipUnless(
|
||||
hasattr(os, "O_NOFOLLOW") and Path("/proc/self/fd").is_dir(),
|
||||
"requires fd-based POSIX execution",
|
||||
)
|
||||
def test_project_root_replacement_cannot_swap_target_or_cwd(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
base = Path(temp_dir)
|
||||
project = base / "project"
|
||||
original = base / "original-project"
|
||||
target = project / "checks" / "reviewed"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text(
|
||||
"#!/bin/sh\nprintf 'reviewed' > marker.txt\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
target.chmod(target.stat().st_mode | 0o111)
|
||||
knowledge_path = project / "docs" / "ack" / "knowledge.yaml"
|
||||
knowledge_path.parent.mkdir(parents=True)
|
||||
knowledge_path.write_text(
|
||||
yaml.safe_dump(
|
||||
knowledge_with_target("checks/reviewed", []),
|
||||
allow_unicode=True,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
original_loader = RUNNER_MODULE.load_authoritative_knowledge
|
||||
|
||||
def replace_root_after_knowledge(
|
||||
project_root: Path | int,
|
||||
) -> tuple[dict | None, str | None]:
|
||||
data, error = original_loader(project_root)
|
||||
project.rename(original)
|
||||
replacement = project / "checks" / "reviewed"
|
||||
replacement.parent.mkdir(parents=True)
|
||||
replacement.write_text(
|
||||
"#!/bin/sh\nprintf 'swapped' > marker.txt\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
replacement.chmod(replacement.stat().st_mode | 0o111)
|
||||
return data, error
|
||||
|
||||
with mock.patch.object(
|
||||
RUNNER_MODULE,
|
||||
"load_authoritative_knowledge",
|
||||
side_effect=replace_root_after_knowledge,
|
||||
):
|
||||
result = RUNNER_MODULE.main(
|
||||
[
|
||||
str(knowledge_path),
|
||||
"reviewed-check",
|
||||
"--project-root",
|
||||
str(project),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(
|
||||
(original / "marker.txt").read_text(encoding="utf-8"),
|
||||
"reviewed",
|
||||
)
|
||||
self.assertFalse((project / "marker.txt").exists())
|
||||
|
||||
def test_rejects_oversized_verification_target_before_snapshot(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
project = Path(temp_dir) / "project"
|
||||
target = project / "checks" / "oversized"
|
||||
target.parent.mkdir(parents=True)
|
||||
with target.open("wb") as target_file:
|
||||
target_file.truncate(RUNNER_MODULE.MAX_TARGET_BYTES + 1)
|
||||
target.chmod(target.stat().st_mode | 0o111)
|
||||
|
||||
target_fd, target_args, error = RUNNER_MODULE.open_target(
|
||||
knowledge_with_target("checks/oversized", []),
|
||||
"reviewed-check",
|
||||
project,
|
||||
)
|
||||
|
||||
self.assertIsNone(target_fd)
|
||||
self.assertIsNone(target_args)
|
||||
self.assertIn("超过大小上限", error)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS_DIR = REPO_ROOT / "skills" / "ack" / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import validate_knowledge # noqa: E402
|
||||
import validate_tasks # noqa: E402
|
||||
from yaml_subset import YamlSubsetError, load_yaml_subset # noqa: E402
|
||||
|
||||
|
||||
class AckYamlSubsetTests(unittest.TestCase):
|
||||
def test_real_templates_and_examples_parse_under_clean_python(self) -> None:
|
||||
script = """
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path('skills/ack/scripts').resolve()))
|
||||
from yaml_subset import load_yaml_subset
|
||||
paths = (
|
||||
Path('skills/ack/templates/tasks.template.yaml'),
|
||||
Path('skills/ack/examples/tasks.example.yaml'),
|
||||
Path('skills/ack/templates/knowledge.template.yaml'),
|
||||
Path('skills/ack/examples/knowledge.example.yaml'),
|
||||
)
|
||||
for path in paths:
|
||||
value = load_yaml_subset(path.read_text(encoding='utf-8'))
|
||||
if not isinstance(value, dict):
|
||||
raise SystemExit(f'{path}: top-level value is not a mapping')
|
||||
print('parsed=4')
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-S", "-c", script],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), "parsed=4")
|
||||
|
||||
def test_tasks_validator_runs_without_site_packages(self) -> None:
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-S",
|
||||
str(SCRIPTS_DIR / "validate_tasks.py"),
|
||||
str(REPO_ROOT / "skills" / "ack" / "examples" / "tasks.example.yaml"),
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("任务板校验通过", result.stdout)
|
||||
|
||||
def test_supported_subset_types_and_block_scalars(self) -> None:
|
||||
document = load_yaml_subset(
|
||||
"""
|
||||
# comment
|
||||
root:
|
||||
list:
|
||||
- null
|
||||
- true
|
||||
- -2
|
||||
- name: 'single quoted'
|
||||
flags: [false, "double quoted", {count: 3}]
|
||||
emptyList: []
|
||||
emptyMap: {}
|
||||
folded: >
|
||||
first line
|
||||
second line
|
||||
|
||||
next paragraph
|
||||
literal: |
|
||||
first line
|
||||
second line
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(document["root"]["list"][:3], [None, True, -2])
|
||||
self.assertEqual(
|
||||
document["root"]["list"][3],
|
||||
{
|
||||
"name": "single quoted",
|
||||
"flags": [False, "double quoted", {"count": 3}],
|
||||
},
|
||||
)
|
||||
self.assertEqual(document["root"]["emptyList"], [])
|
||||
self.assertEqual(document["root"]["emptyMap"], {})
|
||||
self.assertEqual(
|
||||
document["root"]["folded"],
|
||||
"first line second line\nnext paragraph\n",
|
||||
)
|
||||
self.assertEqual(
|
||||
document["root"]["literal"],
|
||||
"first line\nsecond line\n",
|
||||
)
|
||||
|
||||
def test_quoted_mapping_key_supports_yaml_single_quote_escape(self) -> None:
|
||||
self.assertEqual(load_yaml_subset("'owner''s-key': value\n"), {"owner's-key": "value"})
|
||||
|
||||
def test_subset_rejects_duplicate_keys_at_any_depth(self) -> None:
|
||||
invalid_documents = (
|
||||
"name: first\nname: second\n",
|
||||
"outer:\n name: first\n name: second\n",
|
||||
"outer: {name: first, name: second}\n",
|
||||
)
|
||||
for document in invalid_documents:
|
||||
with self.subTest(document=document), self.assertRaises(YamlSubsetError):
|
||||
load_yaml_subset(document)
|
||||
|
||||
def test_subset_rejects_unsupported_yaml_instead_of_guessing(self) -> None:
|
||||
invalid_documents = (
|
||||
"root: &node\n value: 1\ncopy: *node\n",
|
||||
"root: !custom value\n",
|
||||
"root: {<<: {value: 1}}\n",
|
||||
"---\nroot: value\n",
|
||||
"root: >-\n value\n",
|
||||
"root: 1.25\n",
|
||||
"root:\n\tchild: value\n",
|
||||
)
|
||||
for document in invalid_documents:
|
||||
with self.subTest(document=document), self.assertRaises(YamlSubsetError):
|
||||
load_yaml_subset(document)
|
||||
|
||||
|
||||
class AckDocumentLoaderTests(unittest.TestCase):
|
||||
def test_tasks_yaml_loader_rejects_duplicate_keys_with_pyyaml(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
path = Path(temp_dir) / "tasks.yaml"
|
||||
path.write_text("version: 1\nversion: 2\n", encoding="utf-8")
|
||||
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
validate_tasks.load_document(path)
|
||||
|
||||
self.assertEqual(raised.exception.code, 1)
|
||||
|
||||
def test_knowledge_yaml_loader_rejects_alias_graphs(self) -> None:
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
validate_knowledge.load_yaml_text(
|
||||
"root: &root\n child: *root\n",
|
||||
"知识库",
|
||||
)
|
||||
|
||||
self.assertEqual(raised.exception.code, 1)
|
||||
|
||||
def test_tasks_json_loader_rejects_duplicate_keys(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
path = Path(temp_dir) / "tasks.json"
|
||||
path.write_text('{"version": 1, "version": 2}', encoding="utf-8")
|
||||
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
validate_tasks.load_document(path)
|
||||
|
||||
self.assertEqual(raised.exception.code, 1)
|
||||
|
||||
def test_knowledge_json_loader_rejects_nested_duplicate_keys(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
path = Path(temp_dir) / "knowledge.json"
|
||||
path.write_text(
|
||||
'{"project": {"name": "first", "name": "second"}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
validate_knowledge.load_yaml(path, "知识库")
|
||||
|
||||
self.assertEqual(raised.exception.code, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,7 +6,10 @@ import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
import skiff.cli
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -34,6 +37,18 @@ class SkillInitTests(unittest.TestCase):
|
||||
' devWorktree: "<dev_worktree>"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(skill / "templates" / "knowledge.template.yaml").write_text(
|
||||
'updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"\n'
|
||||
'project:\n'
|
||||
' name: "<project_name>"\n'
|
||||
' repoPath: "<repo_path>"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
for validator_name in ("validate_tasks.py", "validate_knowledge.py"):
|
||||
(skill / "scripts" / validator_name).write_text(
|
||||
"raise SystemExit(0)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp_dir.cleanup()
|
||||
@@ -63,10 +78,13 @@ class SkillInitTests(unittest.TestCase):
|
||||
self.assertFalse((target / "framework").exists())
|
||||
project_content = (target / "project.md").read_text(encoding="utf-8")
|
||||
tasks_content = (target / "tasks.yaml").read_text(encoding="utf-8")
|
||||
knowledge_content = (target / "knowledge.yaml").read_text(encoding="utf-8")
|
||||
self.assertIn("# sample-app", project_content)
|
||||
self.assertIn("version=1.2.3", project_content)
|
||||
self.assertIn(f'repoPath: "{project}"', tasks_content)
|
||||
self.assertIn(f'repoPath: "{project}"', knowledge_content)
|
||||
self.assertNotIn("<project_name>", tasks_content)
|
||||
self.assertNotIn("<project_name>", knowledge_content)
|
||||
|
||||
def test_init_refuses_to_overwrite_existing_files(self) -> None:
|
||||
project = self.home / "existing-app"
|
||||
@@ -81,6 +99,604 @@ class SkillInitTests(unittest.TestCase):
|
||||
self.assertIn("拒绝覆盖已有路径", result.stderr)
|
||||
self.assertEqual(existing.read_text(encoding="utf-8"), "keep me")
|
||||
self.assertFalse((target / "tasks.yaml").exists())
|
||||
self.assertFalse((target / "knowledge.yaml").exists())
|
||||
|
||||
def test_init_refuses_to_overwrite_existing_knowledge_file(self) -> None:
|
||||
project = self.home / "existing-knowledge-app"
|
||||
target = project / "docs" / "ack"
|
||||
target.mkdir(parents=True)
|
||||
existing = target / "knowledge.yaml"
|
||||
existing.write_text("keep me", encoding="utf-8")
|
||||
|
||||
result = self.run_skiff("init", "ack", "--project", str(project))
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("拒绝覆盖已有路径", result.stderr)
|
||||
self.assertEqual(existing.read_text(encoding="utf-8"), "keep me")
|
||||
self.assertFalse((target / "project.md").exists())
|
||||
self.assertFalse((target / "tasks.yaml").exists())
|
||||
|
||||
def test_init_rejects_symlinked_destination_directories(self) -> None:
|
||||
for symlink_level in ("docs", "ack"):
|
||||
with self.subTest(symlink_level=symlink_level):
|
||||
project = self.home / f"symlink-{symlink_level}-app"
|
||||
outside = self.home / f"symlink-{symlink_level}-outside"
|
||||
project.mkdir()
|
||||
outside.mkdir()
|
||||
if symlink_level == "docs":
|
||||
(project / "docs").symlink_to(
|
||||
outside,
|
||||
target_is_directory=True,
|
||||
)
|
||||
else:
|
||||
(project / "docs").mkdir()
|
||||
(project / "docs" / "ack").symlink_to(
|
||||
outside,
|
||||
target_is_directory=True,
|
||||
)
|
||||
|
||||
result = self.run_skiff(
|
||||
"init",
|
||||
"ack",
|
||||
"--project",
|
||||
str(project),
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("不能是软链接", result.stderr)
|
||||
self.assertFalse((outside / "project.md").exists())
|
||||
self.assertFalse((outside / "tasks.yaml").exists())
|
||||
self.assertFalse((outside / "knowledge.yaml").exists())
|
||||
|
||||
def test_init_rejects_path_like_skill_name_before_resolving_targets(self) -> None:
|
||||
project = self.home / "path-traversal-app"
|
||||
outside = self.home / "path-traversal-outside"
|
||||
project.mkdir()
|
||||
outside.mkdir()
|
||||
(project / "skills").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
result = self.run_skiff(
|
||||
"init",
|
||||
"../skills/ack",
|
||||
"--project",
|
||||
str(project),
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("skill 名称无效", result.stderr)
|
||||
self.assertFalse((outside / "ack").exists())
|
||||
|
||||
def test_atomic_publish_failure_never_exposes_partial_ack_directory(self) -> None:
|
||||
project = self.home / "atomic-publish-app"
|
||||
project.mkdir()
|
||||
|
||||
with (
|
||||
mock.patch.object(skiff.cli, "SKILLS_HOME", self.skills_home),
|
||||
mock.patch.object(
|
||||
skiff.cli,
|
||||
"SKILLS_DIR",
|
||||
self.skills_home / "skills",
|
||||
),
|
||||
mock.patch.object(skiff.cli, "ensure_skills_home"),
|
||||
mock.patch.object(
|
||||
skiff.cli,
|
||||
"_rename_directory_noreplace",
|
||||
side_effect=RuntimeError("publish interrupted"),
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, "publish interrupted"):
|
||||
skiff.cli.cmd_init(
|
||||
SimpleNamespace(name="ack", project=str(project))
|
||||
)
|
||||
|
||||
self.assertFalse((project / "docs" / "ack").exists())
|
||||
docs = project / "docs"
|
||||
if docs.exists():
|
||||
self.assertEqual(list(docs.iterdir()), [])
|
||||
|
||||
def test_atomic_publish_never_replaces_a_raced_destination(self) -> None:
|
||||
project = self.home / "atomic-no-replace-app"
|
||||
project.mkdir()
|
||||
raced_inode: int | None = None
|
||||
real_publish = skiff.cli._rename_directory_noreplace
|
||||
|
||||
def create_destination_then_publish(
|
||||
source_parent_fd: int,
|
||||
source_name: str,
|
||||
destination_parent_fd: int,
|
||||
destination_name: str,
|
||||
) -> None:
|
||||
nonlocal raced_inode
|
||||
os.mkdir(destination_name, mode=0o711, dir_fd=destination_parent_fd)
|
||||
raced_inode = os.stat(
|
||||
destination_name,
|
||||
dir_fd=destination_parent_fd,
|
||||
follow_symlinks=False,
|
||||
).st_ino
|
||||
real_publish(
|
||||
source_parent_fd,
|
||||
source_name,
|
||||
destination_parent_fd,
|
||||
destination_name,
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(skiff.cli, "SKILLS_HOME", self.skills_home),
|
||||
mock.patch.object(
|
||||
skiff.cli,
|
||||
"SKILLS_DIR",
|
||||
self.skills_home / "skills",
|
||||
),
|
||||
mock.patch.object(skiff.cli, "ensure_skills_home"),
|
||||
mock.patch.object(
|
||||
skiff.cli,
|
||||
"_rename_directory_noreplace",
|
||||
side_effect=create_destination_then_publish,
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(SystemExit, "拒绝覆盖已有路径"):
|
||||
skiff.cli.cmd_init(
|
||||
SimpleNamespace(name="ack", project=str(project))
|
||||
)
|
||||
|
||||
target = project / "docs" / "ack"
|
||||
self.assertTrue(target.is_dir())
|
||||
self.assertEqual(target.stat().st_ino, raced_inode)
|
||||
self.assertEqual(list(target.iterdir()), [])
|
||||
self.assertEqual(target.stat().st_mode & 0o777, 0o711)
|
||||
|
||||
def test_published_destination_replacement_never_reports_success(self) -> None:
|
||||
project = self.home / "published-destination-app"
|
||||
moved_target = project / "docs" / "ack-moved"
|
||||
project.mkdir()
|
||||
real_publish = skiff.cli._rename_directory_noreplace
|
||||
|
||||
def replace_destination_after_publish(
|
||||
source_parent_fd: int,
|
||||
source_name: str,
|
||||
destination_parent_fd: int,
|
||||
destination_name: str,
|
||||
) -> None:
|
||||
real_publish(
|
||||
source_parent_fd,
|
||||
source_name,
|
||||
destination_parent_fd,
|
||||
destination_name,
|
||||
)
|
||||
target = project / "docs" / destination_name
|
||||
target.rename(moved_target)
|
||||
target.mkdir()
|
||||
|
||||
with (
|
||||
mock.patch.object(skiff.cli, "SKILLS_HOME", self.skills_home),
|
||||
mock.patch.object(
|
||||
skiff.cli,
|
||||
"SKILLS_DIR",
|
||||
self.skills_home / "skills",
|
||||
),
|
||||
mock.patch.object(skiff.cli, "ensure_skills_home"),
|
||||
mock.patch.object(
|
||||
skiff.cli,
|
||||
"_rename_directory_noreplace",
|
||||
side_effect=replace_destination_after_publish,
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(SystemExit, "ACK 目录已被替换"):
|
||||
skiff.cli.cmd_init(
|
||||
SimpleNamespace(name="ack", project=str(project))
|
||||
)
|
||||
|
||||
self.assertEqual(list((project / "docs" / "ack").iterdir()), [])
|
||||
self.assertEqual(
|
||||
sorted(path.name for path in moved_target.iterdir()),
|
||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
||||
)
|
||||
|
||||
def test_transaction_container_replacement_cannot_forge_payload(self) -> None:
|
||||
project = self.home / "transaction-source-app"
|
||||
attacker = self.home / "transaction-attacker"
|
||||
project.mkdir()
|
||||
attacker.mkdir()
|
||||
(attacker / "marker").write_text("forged", encoding="utf-8")
|
||||
real_publish = skiff.cli._rename_directory_noreplace
|
||||
|
||||
def replace_outer_transaction_then_publish(
|
||||
source_parent_fd: int,
|
||||
source_name: str,
|
||||
destination_parent_fd: int,
|
||||
destination_name: str,
|
||||
) -> None:
|
||||
docs = project / "docs"
|
||||
transactions = [
|
||||
path
|
||||
for path in docs.iterdir()
|
||||
if path.name.startswith(".ack-init-")
|
||||
]
|
||||
self.assertEqual(len(transactions), 1)
|
||||
transaction = transactions[0]
|
||||
saved = docs / f"{transaction.name}.saved"
|
||||
transaction.rename(saved)
|
||||
transaction.symlink_to(attacker, target_is_directory=True)
|
||||
real_publish(
|
||||
source_parent_fd,
|
||||
source_name,
|
||||
destination_parent_fd,
|
||||
destination_name,
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(skiff.cli, "SKILLS_HOME", self.skills_home),
|
||||
mock.patch.object(
|
||||
skiff.cli,
|
||||
"SKILLS_DIR",
|
||||
self.skills_home / "skills",
|
||||
),
|
||||
mock.patch.object(skiff.cli, "ensure_skills_home"),
|
||||
mock.patch.object(
|
||||
skiff.cli,
|
||||
"_rename_directory_noreplace",
|
||||
side_effect=replace_outer_transaction_then_publish,
|
||||
),
|
||||
):
|
||||
skiff.cli.cmd_init(
|
||||
SimpleNamespace(name="ack", project=str(project))
|
||||
)
|
||||
|
||||
target = project / "docs" / "ack"
|
||||
self.assertTrue(target.is_dir())
|
||||
self.assertFalse(target.is_symlink())
|
||||
self.assertFalse((target / "marker").exists())
|
||||
self.assertEqual(
|
||||
sorted(path.name for path in target.iterdir()),
|
||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
||||
)
|
||||
|
||||
def test_post_publish_fsync_failure_preserves_complete_state(self) -> None:
|
||||
project = self.home / "post-publish-fsync-app"
|
||||
project.mkdir()
|
||||
real_fsync = os.fsync
|
||||
calls = 0
|
||||
|
||||
def fail_directory_fsync_after_publish(file_descriptor: int) -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 7:
|
||||
raise OSError("simulated directory fsync failure")
|
||||
real_fsync(file_descriptor)
|
||||
|
||||
with (
|
||||
mock.patch.object(skiff.cli, "SKILLS_HOME", self.skills_home),
|
||||
mock.patch.object(
|
||||
skiff.cli,
|
||||
"SKILLS_DIR",
|
||||
self.skills_home / "skills",
|
||||
),
|
||||
mock.patch.object(skiff.cli, "ensure_skills_home"),
|
||||
mock.patch.object(
|
||||
skiff.cli.os,
|
||||
"fsync",
|
||||
side_effect=fail_directory_fsync_after_publish,
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(SystemExit, "已完整发布"):
|
||||
skiff.cli.cmd_init(
|
||||
SimpleNamespace(name="ack", project=str(project))
|
||||
)
|
||||
|
||||
target = project / "docs" / "ack"
|
||||
self.assertEqual(
|
||||
sorted(path.name for path in target.iterdir()),
|
||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
||||
)
|
||||
|
||||
def test_project_root_replacement_aborts_before_publish(self) -> None:
|
||||
project = self.home / "root-replacement-app"
|
||||
moved_project = self.home / "root-replacement-moved"
|
||||
project.mkdir()
|
||||
real_open_docs = skiff.cli._open_or_create_directory_at
|
||||
replaced = False
|
||||
|
||||
def replace_root_then_open_docs(
|
||||
parent_fd: int,
|
||||
name: str,
|
||||
) -> tuple[int, bool]:
|
||||
nonlocal replaced
|
||||
if not replaced:
|
||||
project.rename(moved_project)
|
||||
project.mkdir()
|
||||
replaced = True
|
||||
return real_open_docs(parent_fd, name)
|
||||
|
||||
with (
|
||||
mock.patch.object(skiff.cli, "SKILLS_HOME", self.skills_home),
|
||||
mock.patch.object(
|
||||
skiff.cli,
|
||||
"SKILLS_DIR",
|
||||
self.skills_home / "skills",
|
||||
),
|
||||
mock.patch.object(skiff.cli, "ensure_skills_home"),
|
||||
mock.patch.object(
|
||||
skiff.cli,
|
||||
"_open_or_create_directory_at",
|
||||
side_effect=replace_root_then_open_docs,
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(SystemExit, "项目目录已被替换"):
|
||||
skiff.cli.cmd_init(
|
||||
SimpleNamespace(name="ack", project=str(project))
|
||||
)
|
||||
|
||||
self.assertFalse((project / "docs" / "ack").exists())
|
||||
self.assertFalse((moved_project / "docs" / "ack").exists())
|
||||
|
||||
def test_project_root_replacement_at_publish_never_reports_success(self) -> None:
|
||||
project = self.home / "publish-root-replacement-app"
|
||||
moved_project = self.home / "publish-root-replacement-moved"
|
||||
project.mkdir()
|
||||
real_publish = skiff.cli._rename_directory_noreplace
|
||||
|
||||
def replace_root_then_publish(
|
||||
source_parent_fd: int,
|
||||
source_name: str,
|
||||
destination_parent_fd: int,
|
||||
destination_name: str,
|
||||
) -> None:
|
||||
project.rename(moved_project)
|
||||
project.mkdir()
|
||||
real_publish(
|
||||
source_parent_fd,
|
||||
source_name,
|
||||
destination_parent_fd,
|
||||
destination_name,
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(skiff.cli, "SKILLS_HOME", self.skills_home),
|
||||
mock.patch.object(
|
||||
skiff.cli,
|
||||
"SKILLS_DIR",
|
||||
self.skills_home / "skills",
|
||||
),
|
||||
mock.patch.object(skiff.cli, "ensure_skills_home"),
|
||||
mock.patch.object(
|
||||
skiff.cli,
|
||||
"_rename_directory_noreplace",
|
||||
side_effect=replace_root_then_publish,
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(SystemExit, "ACK 目录已移动或不可访问"):
|
||||
skiff.cli.cmd_init(
|
||||
SimpleNamespace(name="ack", project=str(project))
|
||||
)
|
||||
|
||||
self.assertFalse((project / "docs" / "ack").exists())
|
||||
target = moved_project / "docs" / "ack"
|
||||
self.assertEqual(
|
||||
sorted(path.name for path in target.iterdir()),
|
||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
||||
)
|
||||
|
||||
def test_docs_replacement_aborts_before_publish(self) -> None:
|
||||
project = self.home / "docs-replacement-app"
|
||||
moved_docs = project / "docs-moved"
|
||||
project.mkdir()
|
||||
real_assert_binding = skiff.cli._assert_open_directory_path
|
||||
replaced = False
|
||||
|
||||
def replace_docs_at_publish_check(
|
||||
directory_fd: int,
|
||||
path: Path,
|
||||
*,
|
||||
phase: str,
|
||||
label: str = "项目目录",
|
||||
) -> None:
|
||||
nonlocal replaced
|
||||
if label == "docs 目录" and phase == "发布" and not replaced:
|
||||
(project / "docs").rename(moved_docs)
|
||||
(project / "docs").mkdir()
|
||||
replaced = True
|
||||
real_assert_binding(
|
||||
directory_fd,
|
||||
path,
|
||||
phase=phase,
|
||||
label=label,
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(skiff.cli, "SKILLS_HOME", self.skills_home),
|
||||
mock.patch.object(
|
||||
skiff.cli,
|
||||
"SKILLS_DIR",
|
||||
self.skills_home / "skills",
|
||||
),
|
||||
mock.patch.object(skiff.cli, "ensure_skills_home"),
|
||||
mock.patch.object(
|
||||
skiff.cli,
|
||||
"_assert_open_directory_path",
|
||||
side_effect=replace_docs_at_publish_check,
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(SystemExit, "docs 目录已被替换"):
|
||||
skiff.cli.cmd_init(
|
||||
SimpleNamespace(name="ack", project=str(project))
|
||||
)
|
||||
|
||||
self.assertFalse((project / "docs" / "ack").exists())
|
||||
self.assertFalse((moved_docs / "ack").exists())
|
||||
|
||||
def test_docs_replacement_at_publish_never_reports_success(self) -> None:
|
||||
project = self.home / "publish-docs-replacement-app"
|
||||
moved_docs = project / "docs-moved"
|
||||
project.mkdir()
|
||||
real_publish = skiff.cli._rename_directory_noreplace
|
||||
|
||||
def replace_docs_then_publish(
|
||||
source_parent_fd: int,
|
||||
source_name: str,
|
||||
destination_parent_fd: int,
|
||||
destination_name: str,
|
||||
) -> None:
|
||||
(project / "docs").rename(moved_docs)
|
||||
(project / "docs").mkdir()
|
||||
real_publish(
|
||||
source_parent_fd,
|
||||
source_name,
|
||||
destination_parent_fd,
|
||||
destination_name,
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(skiff.cli, "SKILLS_HOME", self.skills_home),
|
||||
mock.patch.object(
|
||||
skiff.cli,
|
||||
"SKILLS_DIR",
|
||||
self.skills_home / "skills",
|
||||
),
|
||||
mock.patch.object(skiff.cli, "ensure_skills_home"),
|
||||
mock.patch.object(
|
||||
skiff.cli,
|
||||
"_rename_directory_noreplace",
|
||||
side_effect=replace_docs_then_publish,
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(SystemExit, "ACK 目录已移动或不可访问"):
|
||||
skiff.cli.cmd_init(
|
||||
SimpleNamespace(name="ack", project=str(project))
|
||||
)
|
||||
|
||||
self.assertFalse((project / "docs" / "ack").exists())
|
||||
target = moved_docs / "ack"
|
||||
self.assertEqual(
|
||||
sorted(path.name for path in target.iterdir()),
|
||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
||||
)
|
||||
|
||||
def test_ack_init_requires_knowledge_template(self) -> None:
|
||||
project = self.home / "missing-knowledge-template-app"
|
||||
project.mkdir()
|
||||
(
|
||||
self.skills_home
|
||||
/ "skills"
|
||||
/ "ack"
|
||||
/ "templates"
|
||||
/ "knowledge.template.yaml"
|
||||
).unlink()
|
||||
|
||||
result = self.run_skiff("init", "ack", "--project", str(project))
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("knowledge.template.yaml", result.stderr)
|
||||
self.assertFalse((project / "docs" / "ack").exists())
|
||||
|
||||
def test_ack_init_requires_both_validators(self) -> None:
|
||||
for validator_name in ("validate_tasks.py", "validate_knowledge.py"):
|
||||
with self.subTest(validator_name=validator_name):
|
||||
project = self.home / f"missing-{validator_name}-app"
|
||||
project.mkdir()
|
||||
validator = (
|
||||
self.skills_home
|
||||
/ "skills"
|
||||
/ "ack"
|
||||
/ "scripts"
|
||||
/ validator_name
|
||||
)
|
||||
original = validator.read_text(encoding="utf-8")
|
||||
validator.unlink()
|
||||
try:
|
||||
result = self.run_skiff(
|
||||
"init",
|
||||
"ack",
|
||||
"--project",
|
||||
str(project),
|
||||
)
|
||||
finally:
|
||||
validator.write_text(original, encoding="utf-8")
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("缺少初始化校验器", result.stderr)
|
||||
self.assertIn(validator_name, result.stderr)
|
||||
self.assertFalse((project / "docs" / "ack").exists())
|
||||
|
||||
def test_validator_failure_leaves_no_partial_initialization(self) -> None:
|
||||
project = self.home / "invalid-knowledge-app"
|
||||
project.mkdir()
|
||||
validator = (
|
||||
self.skills_home / "skills" / "ack" / "scripts" / "validate_knowledge.py"
|
||||
)
|
||||
validator.write_text("raise SystemExit(1)\n", encoding="utf-8")
|
||||
|
||||
result = self.run_skiff("init", "ack", "--project", str(project))
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertNotIn("Traceback", result.stderr)
|
||||
self.assertIn("初始化知识库校验失败", result.stderr)
|
||||
self.assertFalse((project / "docs" / "ack").exists())
|
||||
|
||||
def test_validator_cannot_replace_staged_bytes_before_install(self) -> None:
|
||||
project = self.home / "mutated-staging-app"
|
||||
project.mkdir()
|
||||
validator = (
|
||||
self.skills_home / "skills" / "ack" / "scripts" / "validate_knowledge.py"
|
||||
)
|
||||
validator.write_text(
|
||||
"import sys\n"
|
||||
"from pathlib import Path\n"
|
||||
"Path(sys.argv[1]).write_text('forged: true\\n', encoding='utf-8')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_skiff("init", "ack", "--project", str(project))
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("临时文件在校验期间发生变化", result.stderr)
|
||||
self.assertFalse((project / "docs" / "ack").exists())
|
||||
|
||||
def test_ack_init_validates_mirrored_staging_root(self) -> None:
|
||||
project = self.home / "staged-knowledge-app"
|
||||
project.mkdir()
|
||||
validator = (
|
||||
self.skills_home / "skills" / "ack" / "scripts" / "validate_knowledge.py"
|
||||
)
|
||||
validator.write_text(
|
||||
"import sys\n"
|
||||
"from pathlib import Path\n"
|
||||
"required = ['--tasks', '--project-root']\n"
|
||||
"if any(item not in sys.argv for item in required):\n"
|
||||
" raise SystemExit(3)\n"
|
||||
"root = Path(sys.argv[sys.argv.index('--project-root') + 1])\n"
|
||||
"knowledge = Path(sys.argv[1])\n"
|
||||
"tasks = Path(sys.argv[sys.argv.index('--tasks') + 1])\n"
|
||||
"expected = root / 'docs' / 'ack'\n"
|
||||
"raise SystemExit(0 if knowledge.parent == expected and "
|
||||
"tasks.parent == expected else 4)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_skiff("init", "ack", "--project", str(project))
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertTrue((project / "docs" / "ack" / "knowledge.yaml").is_file())
|
||||
|
||||
def test_non_ack_init_still_requires_only_project_and_tasks_templates(self) -> None:
|
||||
skill = self.skills_home / "skills" / "plain"
|
||||
(skill / "templates").mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text("---\nname: plain\n---\n", encoding="utf-8")
|
||||
(skill / "templates" / "project.template.md").write_text(
|
||||
"# <project_name>\n", encoding="utf-8"
|
||||
)
|
||||
(skill / "templates" / "tasks.template.yaml").write_text(
|
||||
'project: "<project_name>"\n', encoding="utf-8"
|
||||
)
|
||||
project = self.home / "plain-app"
|
||||
project.mkdir()
|
||||
|
||||
result = self.run_skiff("init", "plain", "--project", str(project))
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
target = project / "docs" / "plain"
|
||||
self.assertTrue((target / "project.md").is_file())
|
||||
self.assertTrue((target / "tasks.yaml").is_file())
|
||||
self.assertFalse((target / "knowledge.yaml").exists())
|
||||
|
||||
def test_init_rejects_missing_project_directory(self) -> None:
|
||||
project = self.home / "missing-app"
|
||||
|
||||
Reference in New Issue
Block a user