feat: release Dever 0.1.4 with enforceable project guidance
This commit is contained in:
1 parent
201bdee372
commit
89629c2197
86 files changed
+2798
-338
No files matched your search
+4
-2
@@ -68,11 +68,13 @@ python3 -B test/ci/run.py run --stage sandbox
|
||||
python3 -B test/ci/run.py run --stage bounded-perf
|
||||
```
|
||||
|
||||
以上命令由同一受限托管进程串行执行。若 author 改变输入,应在作者准备完成后再跑正式静态/default/all-feature门;较早输入版本的证据会显示过期。每阶段写入新的独占时间目录,开始即记录 running;缺输入记录 blocked,失败命令保留 exit/count/日志与原始异常。`summary` 与 inventory 消费者只接受最新一次尝试的当前源码/输入证据,不能越过较新的失败或中断回用旧 pass;所有必选阶段通过才返回0。源码摘要覆盖未提交文件,不只看 Git HEAD。
|
||||
以上命令由同一受限托管进程串行执行。若 author 改变输入,应在作者准备完成后再跑正式静态/default/all-feature门;较早输入版本的证据会显示过期。每阶段写入新的独占时间目录,开始即记录 running;缺输入记录 blocked,失败命令保留 exit/count/日志与原始异常。`summary` 与 inventory 消费者只接受最新一次尝试的当前源码/输入证据,不能越过较新的失败或中断回用旧 pass;所有必选阶段通过才返回0。源码摘要覆盖未提交文件及 `skills/` 的指引、模板和示例,不只看 Git HEAD;修改或删除这些文件会使旧结果失效。
|
||||
|
||||
default/all-features 由实际 Cargo metadata 注册 target 和已解析 feature 形成串行矩阵,覆盖 lib/bin/integration/doctest;default 显式保留 workspace 解析的 package features,最小 feature 阶段另按单 package 验证。只有显式启用 `remove_test_executables` 时才删除 Cargo 本次返回、`profile.test=true` 且位于 target 下 `deps/` 的测试可执行文件,失败阶段同样清理;不删除作者工具、静态 init、依赖 archive 或整个 target。删除对象在日志 JSON 中可审计,重跑 Cargo 可恢复。inventory 按 package/target/name 三元组核对完整性,跨 target 同名用例不能代替缺失项。
|
||||
|
||||
Python 阶段包含 `test/ci`、`test/performance`、`test/ecosystem-release` 三个目录;后两者必须已配置 `performance.network_peer/live_peer`,真实自有 loopback检查由主代理执行。三 SDK 路径全部必需,三个明确成功标志全部出现才通过。
|
||||
Python 阶段包含 `test/ci`、`test/performance`、`test/ecosystem-release` 三个目录,并执行 `test/skill_examples.py`,检查 skill 安装后的本地链接、文件预算、标准库签名和教程代码与源码的一致性。教程代码块须链接到受检查项目中的唯一源码片段;文档或源码漂移都会失败。performance 测试必须已配置 `performance.network_peer/live_peer`,真实自有 loopback 检查由主代理执行。三 SDK 路径全部必需,三个明确成功标志全部出现才通过。
|
||||
|
||||
只验证本次 CI 或 skill 改动时,可运行 `python3 -B test/ci/test_gate.py -v` 和 `python3 -B test/skill_examples.py -v`,不需要启动完整 CI、数据库或压力测试。
|
||||
|
||||
PostgreSQL 只接受显式 loopback端口和恰好一个 `{case}` 库名模板,拒绝 query 覆盖 host/dbname 等。增加 `performance.linux_ci.owned_postgres: {"data_directory":"/absolute/owned-cluster","port":51234}`,由托管者创建所需基库并在 finally 停止该自有实例;gate复用现有用例的 schema/tenant 清理,不替用户启动或连接既有业务数据库。
|
||||
|
||||
|
||||
+3
-2
@@ -20,7 +20,7 @@ STAGES = ("static", "default", "all-features", "features", "python", "sdk",
|
||||
"inventory", "author", "native", "postgres", "sandbox", "bounded-perf",
|
||||
"cms-64", "cms-128", "http2-64", "http2-128", "recovery-64", "recovery-128",
|
||||
"live-64", "live-128")
|
||||
SOURCE_ROOTS = ("crates", "test", "sdk", "library", "examples", ".cargo")
|
||||
SOURCE_ROOTS = ("crates", "test", "sdk", "library", "examples", "skills", ".cargo")
|
||||
|
||||
|
||||
class MissingInput(Exception):
|
||||
@@ -129,7 +129,8 @@ def command_plan(stage, config):
|
||||
python = str(absolute_file(tools.get("python"), "performance.linux_ci.tools.python"))
|
||||
if stage == "python":
|
||||
return [[python, "-B", "-m", "unittest", "discover", "-s", directory, "-p", "test_*.py", "-v"]
|
||||
for directory in ("test/ci", "test/performance", "test/ecosystem-release")]
|
||||
for directory in ("test/ci", "test/performance", "test/ecosystem-release")] + [
|
||||
[python, "-B", "test/skill_examples.py", "-v"]]
|
||||
if stage == "sdk":
|
||||
return [[python, "-B", "test/external-sdk/check.py", "--python", python,
|
||||
"--node", str(absolute_file(tools.get("node"), "performance.linux_ci.tools.node")),
|
||||
|
||||
@@ -5,6 +5,7 @@ import contextlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -177,6 +178,81 @@ class GateTests(unittest.TestCase):
|
||||
(root / "Cargo.lock").write_text("changed immutable input")
|
||||
self.assertNotEqual(author_before, gate.input_fingerprint({}, config, "author"))
|
||||
|
||||
def test_skill_sources_invalidate_evidence_but_generated_caches_do_not(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
for name in ("Cargo.toml", "Cargo.lock"):
|
||||
(root / name).write_text(name)
|
||||
sources = ("skills/assets/template/AGENTS.md", "skills/references/development.md",
|
||||
"skills/assets/examples/jobs/module/report/archive/app.dever")
|
||||
for name in sources:
|
||||
path = root / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("original")
|
||||
original = gate.source_fingerprint(root)
|
||||
for name in sources:
|
||||
path = root / name
|
||||
with self.subTest(source=name):
|
||||
path.write_text("changed")
|
||||
self.assertNotEqual(original, gate.source_fingerprint(root))
|
||||
path.unlink()
|
||||
self.assertNotEqual(original, gate.source_fingerprint(root))
|
||||
path.write_text("original")
|
||||
self.assertEqual(original, gate.source_fingerprint(root))
|
||||
for cache in ("__pycache__", "target", ".git"):
|
||||
path = root / "skills/assets/examples/jobs" / cache / "generated"
|
||||
path.parent.mkdir()
|
||||
path.write_text("generated bytes")
|
||||
self.assertEqual(original, gate.source_fingerprint(root))
|
||||
path.write_text("changed generated bytes")
|
||||
self.assertEqual(original, gate.source_fingerprint(root))
|
||||
path.unlink()
|
||||
self.assertEqual(original, gate.source_fingerprint(root))
|
||||
|
||||
def test_python_plan_runs_real_skill_checks_and_rejects_each_failure(self):
|
||||
config = {"tools": {"cargo": sys.executable, "python": sys.executable}}
|
||||
commands = gate.command_plan("python", config)
|
||||
skill_commands = [command for command in commands if "test/skill_examples.py" in command]
|
||||
self.assertEqual(len(skill_commands), 1)
|
||||
command = skill_commands[0]
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
(root / "test").mkdir()
|
||||
shutil.copyfile(gate.ROOT / "test/skill_examples.py", root / "test/skill_examples.py")
|
||||
for name in ("skills", "library"):
|
||||
shutil.copytree(gate.ROOT / name, root / name)
|
||||
logs = root / "logs"
|
||||
logs.mkdir()
|
||||
with patch.object(gate, "ROOT", root):
|
||||
record, content = gate.run_command(command, logs, 0, 10)
|
||||
gate.check_output("python", record, content)
|
||||
test_count = record["python_tests"]
|
||||
self.assertGreater(test_count, 0)
|
||||
for number, (relative, broken, case) in enumerate((
|
||||
("skills/SKILL.md", "[broken](missing.md)",
|
||||
"test_relocated_skill_links_and_release_file_limits"),
|
||||
("skills/references/signatures.md", "```signatures\ndever.io.missing() ()\n```\n",
|
||||
"test_signature_index_matches_public_library_declarations"),
|
||||
("skills/references/language.md",
|
||||
(root / "skills/references/language.md").read_text().replace(
|
||||
'"Hello, "', '"Incorrect tutorial, "', 1
|
||||
),
|
||||
"test_tutorial_code_matches_relocated_checked_sources"),
|
||||
), 1):
|
||||
path = root / relative
|
||||
original = path.read_bytes()
|
||||
path.write_text(broken)
|
||||
with self.subTest(check=case), self.assertRaisesRegex(RuntimeError, "command exited 1"):
|
||||
gate.run_command(command, logs, number, 10)
|
||||
result = json.loads((logs / f"{number:03d}.command.json").read_text())
|
||||
self.assertEqual(result["exit_code"], 1)
|
||||
self.assertEqual(result["python_tests"], test_count)
|
||||
log = (logs / f"{number:03d}.log").read_text()
|
||||
self.assertTrue(
|
||||
f"FAIL: {case}" in log or f"ERROR: {case}" in log, log
|
||||
)
|
||||
path.write_bytes(original)
|
||||
|
||||
def test_cleanup_cannot_delete_author_binary(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
|
||||
@@ -151,6 +151,12 @@ fn application_check_rejects_redundancy_before_preparing_build_resources() {
|
||||
] {
|
||||
assert!(diagnostic.contains(expected), "{expected}: {diagnostic}");
|
||||
}
|
||||
assert_eq!(diagnostic.matches("error[C016]").count(), 3, "{diagnostic}");
|
||||
assert!(!diagnostic.contains("warning[W005]"), "{diagnostic}");
|
||||
assert!(
|
||||
diagnostic.contains("sample/profile/domain.dever:1:1: note:"),
|
||||
"{diagnostic}"
|
||||
);
|
||||
// The same source fails before native packs, output publication or execution.
|
||||
let executable = root.join("must-not-build");
|
||||
for (action, args) in [
|
||||
@@ -166,6 +172,120 @@ fn application_check_rejects_redundancy_before_preparing_build_resources() {
|
||||
assert!(!toolchain.path().join("cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_check_rejects_proven_duplicates_before_runtime_or_database_access() {
|
||||
let toolchain = TemporaryDirectory::new();
|
||||
let compiler = compiler(toolchain.path(), "dever");
|
||||
for (domain, app, diagnostic, location, warning) in [
|
||||
(
|
||||
"first(value: Int) (answer: Int) pure { answer = value + 1 }\nsecond(renamed: Int) (answer: Int) pure { answer = renamed + 1 }",
|
||||
"total(value: Int) (answer: Int) { first = domain.first(value)\nsecond = domain.second(value)\nanswer = first + second }",
|
||||
"same implementation and contract",
|
||||
"sample/count/domain.dever:2:1: error[C016]",
|
||||
"warning[W003]",
|
||||
),
|
||||
(
|
||||
"calculate(value: Int) (answer: Int) pure { answer = value + 1 }",
|
||||
"total(value: Int) (answer: Int) { first = domain.calculate(value)\nsecond = domain.calculate(value)\nanswer = first + second }",
|
||||
"earlier result is still available",
|
||||
"sample/count/app.dever:2:",
|
||||
"warning[W004]",
|
||||
),
|
||||
] {
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
let root = project_dir.path();
|
||||
write(root, "config/setting.json", "{}");
|
||||
write(root, "module/sample/count/model.dever", "type Count { value: Int }");
|
||||
write(root, "module/sample/count/domain.dever", domain);
|
||||
write(root, "module/sample/count/app.dever", app);
|
||||
write(root, "module/sample/count/api.dever", "cmd total = app.total");
|
||||
let checked = cli(&compiler, root, "check", &[]);
|
||||
for expected in ["C016", diagnostic, location, "previous implementation or computation"] {
|
||||
failure(&checked, expected);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&checked.stderr);
|
||||
assert_eq!(stderr.matches("error[C016]").count(), 1, "{stderr}");
|
||||
assert!(!stderr.contains(warning), "{stderr}");
|
||||
assert_eq!(
|
||||
stderr.matches("note: previous implementation or computation").count(),
|
||||
1,
|
||||
"{stderr}"
|
||||
);
|
||||
let executable = root.join("must-not-build");
|
||||
for (action, arguments) in [
|
||||
("run", vec!["--", "sample.count.total", "{\"value\":1}"]),
|
||||
("build", vec!["--output", executable.to_str().unwrap()]),
|
||||
] {
|
||||
let rejected = cli(&compiler, root, action, &arguments);
|
||||
assert!(!rejected.status.success());
|
||||
assert_eq!(rejected.stderr, checked.stderr);
|
||||
}
|
||||
let snapshot = cli(&compiler, root, "api", &[]);
|
||||
success(&snapshot);
|
||||
let stderr = String::from_utf8_lossy(&snapshot.stderr);
|
||||
assert!(stderr.contains(warning), "{stderr}");
|
||||
assert!(!stderr.contains("C016"), "{stderr}");
|
||||
write(
|
||||
root,
|
||||
"test/sample/count/value.dever",
|
||||
"value() () { assert_eq(app.total(1), 4) }",
|
||||
);
|
||||
let tests = cli(&compiler, root, "test", &[]);
|
||||
failure(&tests, "runtime pack");
|
||||
let stderr = String::from_utf8_lossy(&tests.stderr);
|
||||
assert!(stderr.contains(warning), "{stderr}");
|
||||
assert!(!stderr.contains("C016"), "{stderr}");
|
||||
write(root, "dever.api", "invalid baseline");
|
||||
let baseline = cli(&compiler, root, "check", &[]);
|
||||
failure(&baseline, "invalid API baseline");
|
||||
let stderr = String::from_utf8_lossy(&baseline.stderr);
|
||||
assert!(stderr.contains(warning), "{stderr}");
|
||||
assert!(!stderr.contains("C016"), "{stderr}");
|
||||
assert!(!executable.exists());
|
||||
assert!(!root.join("data").exists());
|
||||
}
|
||||
assert!(!toolchain.path().join("cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_check_keeps_warnings_with_distinct_evidence_and_other_codes() {
|
||||
let toolchain = TemporaryDirectory::new();
|
||||
let compiler = compiler(toolchain.path(), "dever");
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
let root = project_dir.path();
|
||||
write(root, "config/setting.json", "{}");
|
||||
write(
|
||||
root,
|
||||
"module/sample/count/domain.dever",
|
||||
"orphan(value: Int) (answer: Int) pure { answer = value + 1 }\nfirst(value: Int) (answer: Int) pure { answer = value + 1 }\nsecond(value: Int) (answer: Int) pure { answer = value + 1 }",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/sample/count/app.dever",
|
||||
"total(value: Int) (answer: Int) { unused = 0\nfirst = domain.first(value)\nsecond = domain.second(value)\nanswer = first + second }",
|
||||
);
|
||||
write(root, "module/sample/count/api.dever", "cmd total = app.total");
|
||||
let checked = cli(&compiler, root, "check", &[]);
|
||||
failure(&checked, "C016");
|
||||
let stderr = String::from_utf8_lossy(&checked.stderr);
|
||||
assert_eq!(stderr.matches("error[C016]").count(), 2, "{stderr}");
|
||||
// Warning proof includes the orphan; production proof starts at the first reachable function.
|
||||
assert_eq!(stderr.matches("warning[W003]").count(), 2, "{stderr}");
|
||||
assert_eq!(stderr.matches("warning[W001]").count(), 1, "{stderr}");
|
||||
assert_eq!(
|
||||
stderr.matches("sample/count/domain.dever:1:1: note:").count(),
|
||||
2,
|
||||
"{stderr}"
|
||||
);
|
||||
assert_eq!(
|
||||
stderr.matches("sample/count/domain.dever:2:1: note:").count(),
|
||||
1,
|
||||
"{stderr}"
|
||||
);
|
||||
assert!(!root.join("data").exists());
|
||||
assert!(!toolchain.path().join("cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_check_rejects_plain_model_get_wrapper_without_opening_database() {
|
||||
let toolchain = TemporaryDirectory::new();
|
||||
@@ -303,6 +423,68 @@ fn application_check_does_not_apply_production_reachability_to_tests() {
|
||||
assert!(!toolchain.path().join("cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_still_checks_unselected_sources_and_reports_no_match_before_compilation() {
|
||||
let toolchain = TemporaryDirectory::new();
|
||||
let compiler = compiler(toolchain.path(), "dever");
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
let root = project_dir.path();
|
||||
project(root);
|
||||
write(root, "test/sample/echo/first.dever", "first() () { assert_eq(1, 1) }");
|
||||
write(root, "test/sample/echo/second.dever", "second() () { assert_eq(1, 1) }");
|
||||
for filter in ["sam", "sample.ec", "sample.echo.fir", "sample.echo.missing"] {
|
||||
let result = cli(&compiler, root, "test", &["--filter", filter]);
|
||||
failure(&result, "no tests match filter");
|
||||
assert!(!String::from_utf8_lossy(&result.stdout).contains("runtime pack"));
|
||||
}
|
||||
let selected = cli(&compiler, root, "test", &["--filter", "sample.echo.second"]);
|
||||
failure(&selected, "runtime pack");
|
||||
let report = String::from_utf8_lossy(&selected.stdout);
|
||||
assert!(report.contains("0 passed; 1 failed; 1 not run"), "{report}");
|
||||
assert!(!report.contains("test sample/echo/first ..."), "{report}");
|
||||
write(root, "test/sample/echo/first.dever", "first() () { missing() }");
|
||||
failure(&cli(&compiler, root, "test", &["--filter", "sample.echo.second"]), "C004");
|
||||
assert!(!toolchain.path().join("cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an explicitly prepared runtime pack adjacent to CARGO_BIN_EXE_dever; owned short-lived cases only"]
|
||||
fn llvm_test_controls_preserve_indices_prefix_boundaries_and_timeout_continuation() {
|
||||
let compiler = Path::new(env!("CARGO_BIN_EXE_dever"));
|
||||
let directory = TemporaryDirectory::new();
|
||||
let root = directory.path();
|
||||
project(root);
|
||||
for (relative, source) in [
|
||||
("test/sample/echo/alpha.dever", "alpha() () { assert_eq(1, 2) }"),
|
||||
("test/sample/echo/beta.dever", "beta() () { assert_eq(7, 7) }"),
|
||||
("test/sample/echo/slow.dever", "slow() () { dever.task.sleep(30000) }"),
|
||||
("test/sample/echo/zeta.dever", "zeta() () { assert_eq(9, 9) }"),
|
||||
("test/sample/echoes/first.dever", "first() () { assert_eq(1, 2) }"),
|
||||
("test/samples/echo/first.dever", "first() () { assert_eq(1, 2) }"),
|
||||
] {
|
||||
write(root, relative, source);
|
||||
}
|
||||
let selected = cli(compiler, root, "test", &["--filter", "sample.echo.beta"]);
|
||||
success(&selected);
|
||||
let report = String::from_utf8_lossy(&selected.stdout);
|
||||
assert!(report.contains("test sample/echo/beta ... ok"), "{report}");
|
||||
assert!(report.contains("1 passed; 0 failed; 5 not run"), "{report}");
|
||||
assert!(!report.contains("sample/echo/alpha"), "{report}");
|
||||
|
||||
for (filter, counts) in [
|
||||
("sample.echo", "2 passed; 2 failed; 2 not run"),
|
||||
("sample", "2 passed; 3 failed; 1 not run"),
|
||||
] {
|
||||
let selected = cli(compiler, root, "test", &["--filter", filter, "--timeout-ms", "250"]);
|
||||
failure(&selected, counts);
|
||||
let report = String::from_utf8_lossy(&selected.stdout);
|
||||
assert!(report.contains("test process timed out after 250 ms"), "{report}");
|
||||
assert!(report.contains("test sample/echo/zeta ... ok"), "{report}");
|
||||
assert!(!report.contains("test samples/echo/first"), "{report}");
|
||||
}
|
||||
failure(&cli(compiler, root, "test", &["--filter", "sample.echo.bet"]), "no tests match filter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_build_requires_the_selected_pack_even_when_host_pack_exists() {
|
||||
let root = TemporaryDirectory::new();
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
use super::*;
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
use std::process::Output;
|
||||
|
||||
struct Project {
|
||||
temporary: TemporaryDirectory,
|
||||
layout: Layout,
|
||||
root: PathBuf,
|
||||
key: Ed25519KeyPair,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
fn new() -> Self {
|
||||
let temporary = TemporaryDirectory::new();
|
||||
let layout = Layout::new(temporary.path().join("machine"));
|
||||
layout.initialize().unwrap();
|
||||
let key = signing_key();
|
||||
fs::write(
|
||||
layout.state().join("trusted-release-key"),
|
||||
format!("{}\n", hex(key.public_key().as_ref())),
|
||||
)
|
||||
.unwrap();
|
||||
write_release(
|
||||
&layout,
|
||||
&key,
|
||||
"1.0.0",
|
||||
&temporary.path().join("dispatch"),
|
||||
true,
|
||||
true,
|
||||
);
|
||||
MachineManager::new(layout.clone())
|
||||
.install_requested("1.0.0")
|
||||
.unwrap();
|
||||
fs::copy(env!("CARGO_BIN_EXE_dever-launcher"), layout.bin().join("dever")).unwrap();
|
||||
let root = temporary.path().join("project");
|
||||
fs::create_dir_all(root.join("module")).unwrap();
|
||||
fs::create_dir(root.join("config")).unwrap();
|
||||
fs::write(root.join("config/setting.json"), "{}").unwrap();
|
||||
Self {
|
||||
temporary,
|
||||
layout,
|
||||
root,
|
||||
key,
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&self, root: &Path, extra: &[&str]) -> Output {
|
||||
Command::new(self.layout.bin().join("dever"))
|
||||
.args(["skill", "install"])
|
||||
.args(extra)
|
||||
.current_dir(root)
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn succeeds(&self) {
|
||||
let output = self.run(&self.root, &[]);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("AGENTS.md"));
|
||||
self.assert_no_staging();
|
||||
}
|
||||
|
||||
fn fails(&self, message: &str) {
|
||||
let output = self.run(&self.root, &[]);
|
||||
assert!(!output.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stderr).contains(message),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
self.assert_no_staging();
|
||||
}
|
||||
|
||||
fn assert_no_staging(&self) {
|
||||
assert!(fs::read_dir(&self.root).unwrap().all(|entry| {
|
||||
!entry
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".dever-skill-")
|
||||
}));
|
||||
}
|
||||
|
||||
fn sign_manifest(&self, manifest: &ReleaseManifest) {
|
||||
let release = self.layout.versions().join("1.0.0");
|
||||
let bytes = serde_json::to_vec(manifest).unwrap();
|
||||
fs::write(release.join("manifest.json"), &bytes).unwrap();
|
||||
fs::write(
|
||||
release.join("manifest.sig"),
|
||||
hex(self.key.sign(&bytes).as_ref()),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_skill_preserves_user_bytes_permissions_and_updates_only_its_block() {
|
||||
let fixture = Project::new();
|
||||
let agents = fixture.root.join("AGENTS.md");
|
||||
let before = "# 项目规则\r\n保留用户内容,无末尾换行";
|
||||
fs::write(&agents, before).unwrap();
|
||||
fs::set_permissions(&agents, fs::Permissions::from_mode(0o640)).unwrap();
|
||||
fixture.succeeds();
|
||||
assert_eq!(
|
||||
fs::read_to_string(&agents).unwrap(),
|
||||
format!("{before}\n\n{}", project_rules("1.0.0"))
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&agents).unwrap().permissions().mode() & 0o777,
|
||||
0o640
|
||||
);
|
||||
let installed = fs::read(&agents).unwrap();
|
||||
fixture.succeeds();
|
||||
assert_eq!(fs::read(&agents).unwrap(), installed);
|
||||
|
||||
let prefix = "# 自定义前文\r\n\r\n";
|
||||
let suffix = "\r\n# 自定义后文\r\n原样保留";
|
||||
fs::write(
|
||||
&agents,
|
||||
format!("{prefix}<!-- dever-language:start -->\n旧规则\n<!-- dever-language:end -->{suffix}"),
|
||||
)
|
||||
.unwrap();
|
||||
fixture.succeeds();
|
||||
assert_eq!(
|
||||
fs::read_to_string(&agents).unwrap(),
|
||||
format!("{prefix}{}{suffix}", project_rules("1.0.0").trim_end())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_skill_rejects_ambiguous_or_unsafe_agents_without_changes() {
|
||||
let fixture = Project::new();
|
||||
let agents = fixture.root.join("AGENTS.md");
|
||||
for content in [
|
||||
"<!-- dever-language:start -->",
|
||||
"<!-- dever-language:end -->",
|
||||
"<!-- dever-language:end --><!-- dever-language:start -->",
|
||||
"<!-- dever-language:start --><!-- dever-language:start --><!-- dever-language:end -->",
|
||||
"<!-- dever-language:start --><!-- dever-language:end --><!-- dever-language:start --><!-- dever-language:end -->",
|
||||
] {
|
||||
fs::write(&agents, content).unwrap();
|
||||
fixture.fails("exactly one complete");
|
||||
assert_eq!(fs::read_to_string(&agents).unwrap(), content);
|
||||
}
|
||||
fs::write(&agents, [0xff, 0xfe]).unwrap();
|
||||
fixture.fails("UTF-8");
|
||||
assert_eq!(fs::read(&agents).unwrap(), [0xff, 0xfe]);
|
||||
fs::write(&agents, "do not overwrite").unwrap();
|
||||
fs::set_permissions(&agents, fs::Permissions::from_mode(0o444)).unwrap();
|
||||
fixture.fails("read-only");
|
||||
fs::set_permissions(&agents, fs::Permissions::from_mode(0o644)).unwrap();
|
||||
fs::remove_file(&agents).unwrap();
|
||||
fs::create_dir(&agents).unwrap();
|
||||
fixture.fails("regular file");
|
||||
fs::remove_dir(&agents).unwrap();
|
||||
let other = fixture.temporary.path().join("other-agents");
|
||||
fs::write(&other, "untouched").unwrap();
|
||||
symlink(&other, &agents).unwrap();
|
||||
fixture.fails("regular file");
|
||||
assert!(fs::symlink_metadata(&agents).unwrap().is_symlink());
|
||||
assert_eq!(fs::read_to_string(&other).unwrap(), "untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_skill_requires_project_root_valid_version_and_signed_template() {
|
||||
let fixture = Project::new();
|
||||
let agents = fixture.root.join("AGENTS.md");
|
||||
let output = fixture.run(&fixture.root, &["unwanted-directory"]);
|
||||
assert!(!output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("accepts no arguments"));
|
||||
assert!(!fixture.root.join("unwanted-directory").exists());
|
||||
let output = fixture.run(fixture.temporary.path(), &[]);
|
||||
assert!(!output.status.success());
|
||||
assert!(!fixture.temporary.path().join("AGENTS.md").exists());
|
||||
fs::remove_dir(fixture.root.join("module")).unwrap();
|
||||
symlink(fixture.temporary.path(), fixture.root.join("module")).unwrap();
|
||||
fixture.fails("real module/");
|
||||
fs::remove_file(fixture.root.join("module")).unwrap();
|
||||
fs::create_dir(fixture.root.join("module")).unwrap();
|
||||
let settings = fixture.root.join("config/setting.json");
|
||||
for invalid in [
|
||||
"not json",
|
||||
"[]",
|
||||
r#"{"dever":{"version":"latest"}}"#,
|
||||
r#"{"dever":{"version":"9.0.0"}}"#,
|
||||
] {
|
||||
fs::write(&settings, invalid).unwrap();
|
||||
let output = fixture.run(&fixture.root, &[]);
|
||||
assert!(!output.status.success());
|
||||
assert!(!agents.exists());
|
||||
}
|
||||
fs::remove_file(&settings).unwrap();
|
||||
fixture.fails("cannot read");
|
||||
fs::write(&settings, "{}").unwrap();
|
||||
let template = fixture
|
||||
.layout
|
||||
.versions()
|
||||
.join("1.0.0/skills/dever-language/assets/template/AGENTS.md");
|
||||
fs::write(&template, "tampered instructions").unwrap();
|
||||
let output = fixture.run(&fixture.root, &[]);
|
||||
assert!(!output.status.success());
|
||||
assert!(!agents.exists());
|
||||
fixture.assert_no_staging();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_skill_refuses_unsigned_extra_or_invalid_signed_template() {
|
||||
let fixture = Project::new();
|
||||
let agents = fixture.root.join("AGENTS.md");
|
||||
fs::write(&agents, "user-owned instructions").unwrap();
|
||||
let release = fixture.layout.versions().join("1.0.0");
|
||||
let mut manifest: ReleaseManifest =
|
||||
serde_json::from_slice(&fs::read(release.join("manifest.json")).unwrap()).unwrap();
|
||||
let logical = "skills/dever-language/assets/template/AGENTS.md";
|
||||
manifest.artifacts.retain(|entry| entry.path != logical);
|
||||
fixture.sign_manifest(&manifest);
|
||||
fixture.fails("does not declare resource");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&agents).unwrap(),
|
||||
"user-owned instructions"
|
||||
);
|
||||
|
||||
let template = release.join(logical);
|
||||
for invalid in [
|
||||
"no managed block",
|
||||
"<!-- dever-language:start -->missing end",
|
||||
"outside\n<!-- dever-language:start -->rules<!-- dever-language:end -->",
|
||||
] {
|
||||
fs::write(&template, invalid).unwrap();
|
||||
manifest.artifacts.retain(|entry| entry.path != logical);
|
||||
manifest.artifacts.push(artifact(&template, logical));
|
||||
fixture.sign_manifest(&manifest);
|
||||
let output = fixture.run(&fixture.root, &[]);
|
||||
assert!(!output.status.success());
|
||||
assert_eq!(
|
||||
fs::read_to_string(&agents).unwrap(),
|
||||
"user-owned instructions"
|
||||
);
|
||||
fixture.assert_no_staging();
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ static NEXT_TEMPORARY: AtomicU64 = AtomicU64::new(0);
|
||||
#[path = "shared_toolchain_extensions.rs"]
|
||||
mod extensions;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "project_skill.rs"]
|
||||
mod project_skill;
|
||||
|
||||
struct TemporaryDirectory(PathBuf);
|
||||
|
||||
impl TemporaryDirectory {
|
||||
@@ -306,30 +310,16 @@ fn signed_versions_install_select_dispatch_update_and_rollback_atomically() {
|
||||
fs::read_to_string(skill_path(&layout).join("SKILL.md")).unwrap(),
|
||||
"skill 1.0.0\n"
|
||||
);
|
||||
let skill_entry = temporary.path().join("dever-language");
|
||||
execute_in(
|
||||
&layout,
|
||||
&[
|
||||
"skill".into(),
|
||||
"install".into(),
|
||||
skill_entry.as_os_str().into(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let loader = fs::read(skill_entry.join("SKILL.md")).unwrap();
|
||||
assert!(String::from_utf8_lossy(&loader).contains("skill path"));
|
||||
assert!(
|
||||
execute_in(
|
||||
&layout,
|
||||
&[
|
||||
"skill".into(),
|
||||
"install".into(),
|
||||
skill_entry.as_os_str().into()
|
||||
]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(fs::read(skill_entry.join("SKILL.md")).unwrap(), loader);
|
||||
fs::copy(env!("CARGO_BIN_EXE_dever-launcher"), layout.bin().join("dever")).unwrap();
|
||||
let active_project = temporary.path().join("active-project");
|
||||
fs::create_dir_all(active_project.join("module")).unwrap();
|
||||
fs::create_dir(active_project.join("config")).unwrap();
|
||||
fs::write(active_project.join("config/setting.json"), "{}").unwrap();
|
||||
install_project_skill(&layout, &active_project);
|
||||
let rules = fs::read(active_project.join("AGENTS.md")).unwrap();
|
||||
assert_eq!(rules, project_rules("1.0.0").as_bytes());
|
||||
install_project_skill(&layout, &active_project);
|
||||
assert_eq!(fs::read(active_project.join("AGENTS.md")).unwrap(), rules);
|
||||
|
||||
let new_project = temporary.path().join("not-created-yet");
|
||||
let CommandResult::Exited(code) = execute_in(
|
||||
@@ -351,6 +341,7 @@ fn signed_versions_install_select_dispatch_update_and_rollback_atomically() {
|
||||
|
||||
let project = temporary.path().join("project");
|
||||
fs::create_dir_all(project.join("config")).unwrap();
|
||||
fs::create_dir(project.join("module")).unwrap();
|
||||
fs::write(
|
||||
project.join("config/setting.json"),
|
||||
r#"{"dever":{"version":"2.0.0"}}"#,
|
||||
@@ -368,6 +359,11 @@ fn signed_versions_install_select_dispatch_update_and_rollback_atomically() {
|
||||
Path::new(&pinned_skill),
|
||||
layout.versions().join("2.0.0/skills/dever-language")
|
||||
);
|
||||
install_project_skill(&layout, &project);
|
||||
assert_eq!(
|
||||
fs::read_to_string(project.join("AGENTS.md")).unwrap(),
|
||||
project_rules("2.0.0")
|
||||
);
|
||||
let CommandResult::Exited(code) = execute_in(&layout, &arguments).unwrap() else {
|
||||
panic!("project command must dispatch")
|
||||
};
|
||||
@@ -393,7 +389,12 @@ fn signed_versions_install_select_dispatch_update_and_rollback_atomically() {
|
||||
fs::read_to_string(skill_path(&layout).join("SKILL.md")).unwrap(),
|
||||
"skill 2.0.0\n"
|
||||
);
|
||||
assert_eq!(fs::read(skill_entry.join("SKILL.md")).unwrap(), loader);
|
||||
assert_eq!(fs::read(active_project.join("AGENTS.md")).unwrap(), rules);
|
||||
install_project_skill(&layout, &active_project);
|
||||
assert_eq!(
|
||||
fs::read_to_string(active_project.join("AGENTS.md")).unwrap(),
|
||||
project_rules("2.0.0")
|
||||
);
|
||||
fs::write(layout.downloads().join("latest"), "1.0.0\n").unwrap();
|
||||
assert!(
|
||||
manager
|
||||
@@ -1342,12 +1343,16 @@ fn write_release(
|
||||
let skill = root.join("skills/dever-language/SKILL.md");
|
||||
fs::create_dir_all(skill.parent().unwrap()).unwrap();
|
||||
fs::write(&skill, format!("skill {version}\n")).unwrap();
|
||||
let rules = root.join("skills/dever-language/assets/template/AGENTS.md");
|
||||
fs::create_dir_all(rules.parent().unwrap()).unwrap();
|
||||
fs::write(&rules, project_rules(version)).unwrap();
|
||||
let manifest = ReleaseManifest::new(
|
||||
Version::parse(version).unwrap(),
|
||||
vec![
|
||||
artifact(&core, "dever-core"),
|
||||
artifact(&runtime, "runtime.pack"),
|
||||
artifact(&skill, "skills/dever-language/SKILL.md"),
|
||||
artifact(&rules, "skills/dever-language/assets/template/AGENTS.md"),
|
||||
],
|
||||
);
|
||||
let bytes = serde_json::to_vec(&manifest).unwrap();
|
||||
@@ -1359,6 +1364,29 @@ fn write_release(
|
||||
fs::write(root.join("manifest.sig"), format!("{}\n", hex(&signature))).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn project_rules(version: &str) -> String {
|
||||
include_str!("../../skills/assets/template/AGENTS.md").replace(
|
||||
"<!-- dever-language:end -->",
|
||||
&format!("<!-- fixture version {version} -->\n<!-- dever-language:end -->"),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn install_project_skill(layout: &Layout, project: &Path) {
|
||||
let output = Command::new(layout.bin().join("dever"))
|
||||
.args(["skill", "install"])
|
||||
.current_dir(project)
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn artifact(path: &Path, relative: &str) -> Artifact {
|
||||
let bytes = fs::read(path).unwrap();
|
||||
Artifact {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
use super::{Action, OsString, arguments_for};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_options_default_to_sixty_seconds_and_accept_either_order() {
|
||||
for options in [
|
||||
vec![],
|
||||
vec!["--filter", "sample.account.case_2"],
|
||||
vec!["--filter", "sample.account", "--timeout-ms", "15"],
|
||||
vec!["--timeout-ms", "15", "--filter", "sample"],
|
||||
vec!["--timeout-ms", "18446744073709551615"],
|
||||
] {
|
||||
let arguments: Vec<_> = [vec!["test", "project"], options.clone()]
|
||||
.concat()
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect();
|
||||
let (root, action) = arguments_for(&arguments).unwrap();
|
||||
assert_eq!(root.to_str(), Some("project"));
|
||||
let Action::Test(parsed) = action else {
|
||||
panic!("expected test")
|
||||
};
|
||||
let timeout = options
|
||||
.iter()
|
||||
.position(|option| *option == "--timeout-ms")
|
||||
.map(|index| options[index + 1].parse::<u64>().unwrap())
|
||||
.unwrap_or(60_000);
|
||||
assert_eq!(parsed.timeout, Duration::from_millis(timeout));
|
||||
assert_eq!(
|
||||
parsed.filter.as_deref(),
|
||||
options
|
||||
.iter()
|
||||
.position(|option| *option == "--filter")
|
||||
.map(|index| options[index + 1]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_options_reject_invalid_missing_duplicate_and_unknown_arguments() {
|
||||
let mut invalid = vec![
|
||||
vec!["--filter"],
|
||||
vec!["--timeout-ms"],
|
||||
vec!["--filter", "--timeout-ms", "20"],
|
||||
vec!["--filter", "sample", "--filter", "sample.account"],
|
||||
vec!["--timeout-ms", "20", "--timeout-ms", "30"],
|
||||
vec!["--unknown", "value"],
|
||||
vec!["extra"],
|
||||
];
|
||||
for filter in [
|
||||
"", ".sample", "sample.", "sample..case", "sample.a.b.c", "sam*", "sample/account",
|
||||
"Sample", "sample.__case", "sample.case_", "sample.2case", " sample", "用户",
|
||||
] {
|
||||
invalid.push(vec!["--filter", filter]);
|
||||
}
|
||||
for timeout in [
|
||||
"", "0", "-1", "+1", "1.5", "1ms", " 1", "18446744073709551616",
|
||||
] {
|
||||
invalid.push(vec!["--timeout-ms", timeout]);
|
||||
}
|
||||
for options in invalid {
|
||||
let arguments: Vec<_> = [vec!["test", "project"], options]
|
||||
.concat()
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect();
|
||||
assert!(arguments_for(&arguments).is_err(), "accepted {arguments:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_options_reject_non_utf8_values() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
for option in ["--filter", "--timeout-ms"] {
|
||||
let arguments = [
|
||||
OsString::from("test"),
|
||||
OsString::from("project"),
|
||||
OsString::from(option),
|
||||
OsString::from_vec(vec![0xff]),
|
||||
];
|
||||
assert!(arguments_for(&arguments).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use super::{TemporaryProject, process};
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DESCENDANTS: &str = r#"
|
||||
/bin/sh -c 'echo $$ > descendant; /bin/sleep 30 & echo $! > grandchild; wait' &
|
||||
while [ ! -s grandchild ]; do :; done
|
||||
"#;
|
||||
|
||||
// subreaper 是进程级状态;此单例测试顺序覆盖所有真实进程场景。
|
||||
#[test]
|
||||
fn case_process_captures_output_times_out_and_reaps_only_owned_descendants() {
|
||||
let original_reaper = rustix::process::child_subreaper().unwrap();
|
||||
let mut unrelated = Command::new("/bin/sleep").arg("30").spawn().unwrap();
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
for (body, descendants, expected) in [
|
||||
("printf hidden; printf hidden-error >&2; exit 0", false, None),
|
||||
(
|
||||
"printf output; printf diagnostic >&2; exit 7",
|
||||
false,
|
||||
Some("exit status: 7"),
|
||||
),
|
||||
(
|
||||
"printf before-timeout; /bin/sleep 30",
|
||||
false,
|
||||
Some("timed out after 100 ms"),
|
||||
),
|
||||
("exit 0", true, None),
|
||||
("printf owned-error >&2; exit 7", true, Some("exit status: 7")),
|
||||
("printf started; wait", true, Some("timed out after 100 ms")),
|
||||
] {
|
||||
let root = TemporaryProject::new().unwrap();
|
||||
let started = Instant::now();
|
||||
let script = if descendants {
|
||||
format!("{DESCENDANTS}\n{body}")
|
||||
} else {
|
||||
body.to_owned()
|
||||
};
|
||||
let result = process::run(
|
||||
Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(&script)
|
||||
.env_clear()
|
||||
.current_dir(root.path()),
|
||||
root.path(),
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
match expected {
|
||||
None => result.unwrap(),
|
||||
Some(message) => {
|
||||
let failure = result.unwrap_err();
|
||||
assert!(failure.message.contains(message), "{failure:?}");
|
||||
if script.contains("printf output") {
|
||||
assert_eq!(failure.stdout, b"output");
|
||||
assert_eq!(failure.stderr, b"diagnostic");
|
||||
}
|
||||
if script.contains("printf before-timeout") {
|
||||
assert_eq!(failure.stdout, b"before-timeout");
|
||||
}
|
||||
if script.contains("printf owned-error") {
|
||||
assert_eq!(failure.stderr, b"owned-error");
|
||||
}
|
||||
}
|
||||
}
|
||||
for name in ["descendant", "grandchild"] {
|
||||
let path = root.path().join(name);
|
||||
if descendants {
|
||||
let pid = fs::read_to_string(path)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!std::path::Path::new(&format!("/proc/{pid}")).exists(),
|
||||
"unreaped {name} {pid}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(rustix::process::child_subreaper().unwrap(), original_reaper);
|
||||
}
|
||||
|
||||
let root = TemporaryProject::new().unwrap();
|
||||
let failure = process::run(
|
||||
Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg("/bin/dd if=/dev/zero bs=131072 count=2 2>/dev/null; printf end >&2; exit 9")
|
||||
.env_clear(),
|
||||
root.path(),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(failure.message.contains("exit status: 9"));
|
||||
assert_eq!(failure.stdout.len(), 262_144);
|
||||
assert_eq!(failure.stderr, b"end");
|
||||
|
||||
let root = TemporaryProject::new().unwrap();
|
||||
let failure = process::run(
|
||||
&mut Command::new(root.path().join("missing-executable")),
|
||||
root.path(),
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(failure.message.contains("cannot run test process"));
|
||||
assert_eq!(rustix::process::child_subreaper().unwrap(), original_reaper);
|
||||
});
|
||||
// 即使断言失败也回收此对照进程,不能让测试自身遗留资源。
|
||||
let unrelated_running = unrelated.try_wait().unwrap().is_none();
|
||||
let _ = unrelated.kill();
|
||||
unrelated.wait().unwrap();
|
||||
result.unwrap();
|
||||
assert!(unrelated_running, "case cleanup affected an unrelated child");
|
||||
}
|
||||
@@ -86,6 +86,10 @@ path = "tests/markdown_source.rs"
|
||||
name = "cms_acceptance"
|
||||
path = "tests/cms_acceptance.rs"
|
||||
|
||||
[[test]]
|
||||
name = "skill_examples"
|
||||
path = "tests/skill_examples.rs"
|
||||
|
||||
[[test]]
|
||||
name = "native_cache"
|
||||
path = "tests/native_cache.rs"
|
||||
|
||||
@@ -2,6 +2,16 @@ mod support;
|
||||
|
||||
use support::{checked, sources};
|
||||
|
||||
fn application(files: &[(&str, &str)]) -> dever_core::hir::Program {
|
||||
let root = support::temp::TemporaryDirectory::new();
|
||||
for (relative, source) in files {
|
||||
let path = root.path().join("module").join(relative);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, source).unwrap();
|
||||
}
|
||||
checked(&dever_core::source::SourceMap::load(&root.path().join("module")).unwrap())
|
||||
}
|
||||
|
||||
fn warnings(source: &str, code: &str) -> Vec<dever_core::diagnostic::Diagnostic> {
|
||||
let sources = sources(source);
|
||||
let user_source = sources.files()[0].id();
|
||||
@@ -99,3 +109,122 @@ fn existing_unused_write_warning_is_not_duplicated_as_dead_computation() {
|
||||
.any(|warning| warning.code == "W006")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_rejects_reachable_private_copies_with_renamed_inputs() {
|
||||
let program = application(&[
|
||||
("sample/count/domain.dever", "first(value: Int) (answer: Int) pure { answer = value + 1 }\nsecond(renamed: Int) (answer: Int) pure { answer = renamed + 1 }"),
|
||||
("sample/count/app.dever", "total(value: Int) (answer: Int) { first = domain.first(value)\nsecond = domain.second(value)\nanswer = first + second }"),
|
||||
("sample/count/api.dever", "cmd total = app.total"),
|
||||
]);
|
||||
let warnings: Vec<_> = program.warnings().iter().filter(|item| item.code == "W003").collect();
|
||||
let errors = program.application_errors().unwrap();
|
||||
assert_eq!(errors.len(), 1, "{errors:?}");
|
||||
assert_eq!(errors[0].code, "C016");
|
||||
assert!(errors[0].message.contains("same implementation and contract"));
|
||||
assert!(errors[0].message.contains("domain.first"));
|
||||
assert!(errors[0].message.contains("domain.second"));
|
||||
assert_eq!(errors[0].related.len(), 1);
|
||||
assert!(warnings.iter().any(|warning| warning.primary == errors[0].primary));
|
||||
assert_ne!(errors[0].primary, errors[0].related[0].span);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_rejects_repeated_pure_calls_using_the_warning_proof() {
|
||||
let program = application(&[
|
||||
("sample/count/domain.dever", "calculate(value: Int) (answer: Int) pure { answer = value + 1 }"),
|
||||
("sample/count/app.dever", "total(value: Int) (answer: Int) { first = domain.calculate(value)\nsecond = domain.calculate(value)\nanswer = first + second }"),
|
||||
("sample/count/api.dever", "cmd total = app.total"),
|
||||
]);
|
||||
let errors = program.application_errors().unwrap();
|
||||
assert_eq!(errors.len(), 1, "{errors:?}");
|
||||
assert_eq!(errors[0].code, "C016");
|
||||
assert!(errors[0].message.contains("earlier result is still available"));
|
||||
assert_eq!(errors[0].related.len(), 1);
|
||||
assert!(program.warnings().iter().any(|warning| {
|
||||
warning.code == "W004" && warning.primary == errors[0].primary
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_does_not_reject_changed_inputs_results_or_effectful_calls() {
|
||||
for (app, domain, api) in [
|
||||
(
|
||||
"total(value: Int) (answer: Int) { first = domain.calculate(value)\nvalue = value + 1\nsecond = domain.calculate(value)\nanswer = first + second }",
|
||||
"calculate(value: Int) (answer: Int) pure { answer = value + 1 }",
|
||||
"cmd total = app.total",
|
||||
),
|
||||
(
|
||||
"total(value: Int) (answer: Int) { first = domain.calculate(value)\nfirst = 0\nsecond = domain.calculate(value)\nanswer = first + second }",
|
||||
"calculate(value: Int) (answer: Int) pure { answer = value + 1 }",
|
||||
"cmd total = app.total",
|
||||
),
|
||||
(
|
||||
"type Counter { value: Int }\ntotal(counter: Counter) (answer: Int) { first = domain.calculate(counter.value)\ncounter.value = counter.value + 1\nsecond = domain.calculate(counter.value)\nanswer = first + second }",
|
||||
"calculate(value: Int) (answer: Int) pure { answer = value + 1 }",
|
||||
"cmd total = app.total",
|
||||
),
|
||||
(
|
||||
"noisy(value: Int) (answer: Int) { dever.io.println(\"called\")\nanswer = value }\ntotal(value: Int) (answer: Int) { first = noisy(value)\nsecond = noisy(value)\nanswer = first + second }",
|
||||
"",
|
||||
"cmd total = app.total",
|
||||
),
|
||||
] {
|
||||
let program = application(&[
|
||||
("sample/count/app.dever", app),
|
||||
("sample/count/domain.dever", domain),
|
||||
("sample/count/api.dever", api),
|
||||
]);
|
||||
let errors = program.application_errors().unwrap();
|
||||
assert!(errors.is_empty(), "{app}: {errors:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_preserves_different_contracts_and_owners() {
|
||||
for domain in [
|
||||
"first(value: Int) (answer: Int) pure { answer = value }\nsecond(value: Int) (different: Int) pure { different = value }",
|
||||
"first(value: Int) (answer: Int) pure recover(\"first policy\") { answer = value }\nsecond(value: Int) (answer: Int) pure recover(\"second policy\") { answer = value }",
|
||||
"first() (answer: Int >= 1) pure { answer = 1 }\nsecond() (answer: Int >= 0) pure { answer = 1 }",
|
||||
] {
|
||||
let inputs = if domain.contains("value:") { "1" } else { "" };
|
||||
let app = format!("total() (answer: Int) {{ first = domain.first({inputs})\nsecond = domain.second({inputs})\nanswer = first + second }}");
|
||||
let program = application(&[
|
||||
("sample/count/domain.dever", domain),
|
||||
("sample/count/app.dever", &app),
|
||||
("sample/count/api.dever", "cmd total = app.total"),
|
||||
]);
|
||||
assert!(program.application_errors().unwrap().is_empty(), "{domain}");
|
||||
}
|
||||
let program = application(&[
|
||||
("first/count/domain.dever", "calculate(value: Int) (answer: Int) pure { answer = value + 1 }"),
|
||||
("first/count/app.dever", "total(value: Int) (answer: Int) { answer = domain.calculate(value) }"),
|
||||
("first/count/api.dever", "cmd total = app.total"),
|
||||
("second/count/domain.dever", "calculate(value: Int) (answer: Int) pure { answer = value + 1 }"),
|
||||
("second/count/app.dever", "total(value: Int) (answer: Int) { answer = domain.calculate(value) }"),
|
||||
("second/count/api.dever", "cmd total = app.total"),
|
||||
]);
|
||||
assert!(program.application_errors().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_keeps_effectful_private_copies_and_adapter_redundancy_as_warnings() {
|
||||
let program = application(&[
|
||||
("sample/count/domain.dever", "emit(value: Int) (answer: Int) { dever.io.println(\"called\")\nanswer = value }\nfirst(value: Int) (answer: Int) { answer = emit(value) + 1 }\nsecond(renamed: Int) (answer: Int) { answer = emit(renamed) + 1 }"),
|
||||
("sample/count/app.dever", "total(value: Int) (answer: Int) { first = domain.first(value)\nsecond = domain.second(value)\nanswer = first + second }"),
|
||||
("sample/count/api.dever", "cmd total = app.total"),
|
||||
]);
|
||||
assert!(program.warnings().iter().any(|warning| warning.code == "W003"));
|
||||
assert!(program.application_errors().unwrap().is_empty());
|
||||
|
||||
let program = application(&[
|
||||
("sample/count/port.dever", "read(value: Int) (answer: Int) fails dever.time.SleepResult"),
|
||||
("sample/count/adapter.dever", "first(value: Int) (answer: Int) { answer = value + 1 }\nsecond(value: Int) (answer: Int) { answer = value + 1 }\nport.read(value: Int) (answer: Int) { one = first(value)\ntwo = second(value)\nthree = first(value)\nanswer = one + two + three }"),
|
||||
("sample/count/app.dever", "read(value: Int) (answer: Int) { answer = port.read(value) }"),
|
||||
("sample/count/api.dever", "cmd read = app.read"),
|
||||
]);
|
||||
for code in ["W003", "W004"] {
|
||||
assert!(program.warnings().iter().any(|warning| warning.code == code));
|
||||
}
|
||||
assert!(program.application_errors().unwrap().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use dever_core::{hir::Program, source::SourceMap};
|
||||
|
||||
#[path = "support/temp.rs"]
|
||||
mod temp;
|
||||
|
||||
fn examples() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../skills/assets/examples")
|
||||
}
|
||||
|
||||
fn checked(root: &Path) -> Program {
|
||||
let sources = SourceMap::load_project(&root.join("module"), &root.join("test")).unwrap();
|
||||
let settings = dever_runtime::config::Settings::load_project(root).unwrap();
|
||||
let program = dever_core::check_with_settings(&sources, &settings).unwrap_or_else(|errors| {
|
||||
panic!(
|
||||
"{}: {}",
|
||||
root.display(),
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(&sources))
|
||||
.collect::<String>()
|
||||
)
|
||||
});
|
||||
let errors = program.application_errors().unwrap();
|
||||
assert!(
|
||||
errors.is_empty(),
|
||||
"{}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(&sources))
|
||||
.collect::<String>()
|
||||
);
|
||||
program.validate_database_settings(&settings).unwrap();
|
||||
for source in sources.files() {
|
||||
assert_eq!(
|
||||
dever_core::format::format(source).unwrap(),
|
||||
source.text(),
|
||||
"{}",
|
||||
source.path().display()
|
||||
);
|
||||
}
|
||||
program
|
||||
}
|
||||
|
||||
fn copy_tree(source: &Path, target: &Path) {
|
||||
fs::create_dir(target).unwrap();
|
||||
for entry in fs::read_dir(source).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let destination = target.join(entry.file_name());
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
copy_tree(&entry.path(), &destination);
|
||||
} else {
|
||||
fs::copy(entry.path(), destination).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relocated_examples_check_production_roots_settings_and_case_local_effects() {
|
||||
let temporary = temp::TemporaryDirectory::new();
|
||||
let projects = [
|
||||
("http-rest", 1, false),
|
||||
("jobs", 1, true),
|
||||
("external-lib", 2, false),
|
||||
("collections", 2, false),
|
||||
("command", 1, false),
|
||||
];
|
||||
assert_eq!(fs::read_dir(examples()).unwrap().count(), projects.len());
|
||||
for (name, test_count, uses_database) in projects {
|
||||
let root = temporary.path().join(name);
|
||||
copy_tree(&examples().join(name), &root);
|
||||
let program = checked(&root);
|
||||
assert_eq!(program.tests().len(), test_count);
|
||||
assert!(
|
||||
program
|
||||
.tests()
|
||||
.iter()
|
||||
.all(|case| case.uses_database() == uses_database)
|
||||
);
|
||||
}
|
||||
let template = temporary.path().join("template");
|
||||
copy_tree(&examples().parent().unwrap().join("template"), &template);
|
||||
assert_eq!(checked(&template).tests().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_ecosystems_share_one_checked_port_contract_and_fake_suite() {
|
||||
let temporary = temp::TemporaryDirectory::new();
|
||||
let root = temporary.path().join("external-lib");
|
||||
copy_tree(&examples().join("external-lib"), &root);
|
||||
let original = checked(&root).external_worker_contracts().remove(0);
|
||||
let mut schemas = std::collections::BTreeSet::new();
|
||||
for (ecosystem, entry, dependency) in [
|
||||
("pip", "python/render.py", "markdown@3.8.2"),
|
||||
("npm", "javascript/render.mjs", "marked@15.0.12"),
|
||||
("go", "go/render.go", "github.com/yuin/goldmark@v1.7.8"),
|
||||
] {
|
||||
fs::write(
|
||||
root.join("module/document/markup/adapter.dever"),
|
||||
format!("external {ecosystem} \"{entry}\" {{\n lib \"{dependency}\"\n}}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("config/setting.json"),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"lib": [format!("{ecosystem}:{dependency}")]
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let program = checked(&root);
|
||||
let contracts = program.external_worker_contracts();
|
||||
let [contract] = contracts.as_slice() else {
|
||||
panic!("one external Adapter must own the shared Port")
|
||||
};
|
||||
// 协议身份绑定生态和 Lib 声明;跨生态复用的是 Port 的类型合同。
|
||||
assert!(schemas.insert(contract.schema.clone()));
|
||||
assert_eq!(contract.port, original.port);
|
||||
assert_eq!(contract.setting, original.setting);
|
||||
assert_eq!(contract.inputs, original.inputs);
|
||||
assert_eq!(contract.outputs, original.outputs);
|
||||
assert_eq!(contract.errors, original.errors);
|
||||
assert_eq!(contract.operations, ["render"]);
|
||||
assert_eq!(contract.ecosystem, ecosystem);
|
||||
assert_eq!(contract.libs, [format!("{ecosystem}:{dependency}")]);
|
||||
assert!(root.join(&contract.entry).is_file());
|
||||
assert_eq!(program.tests().len(), 2);
|
||||
assert!(!program.tests().iter().any(|case| case.uses_database()));
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ class InstalledDaemon:
|
||||
self.__exit__(None, None, None)
|
||||
raise
|
||||
|
||||
def command(self, *arguments):
|
||||
def command(self, *arguments, cwd="/"):
|
||||
if self.child.poll() is not None:
|
||||
raise RuntimeError("owned daemon exited")
|
||||
expected = self.image.stat()
|
||||
@@ -137,7 +137,7 @@ class InstalledDaemon:
|
||||
raise RuntimeError("owned namespace identity changed")
|
||||
return execute([
|
||||
"/usr/bin/nsenter", "--target", str(self.pid), "--all", "--root",
|
||||
"--wdns=/", "--", "/usr/local/bin/dever", *arguments,
|
||||
f"--wdns={cwd}", "--", "/usr/local/bin/dever", *arguments,
|
||||
])
|
||||
|
||||
def __exit__(self, *_):
|
||||
@@ -277,10 +277,22 @@ def accept(release, assets, image, report, init, extension_helper=None):
|
||||
expected = f"/opt/dever/versions/{manifest['version']}/skills/dever-language"
|
||||
if daemon.command("skill", "path") != expected:
|
||||
raise RuntimeError("versioned skill did not match installed core")
|
||||
daemon.command("skill", "install", "/ai-skill")
|
||||
if not (image / "ai-skill/SKILL.md").is_file():
|
||||
raise RuntimeError("missing installed skill entry")
|
||||
passed("versioned_skill_resolution_and_loader_install")
|
||||
rules = image / "first/AGENTS.md"
|
||||
project_rules = b"# Project-specific rules\n\n"
|
||||
rules.write_bytes(project_rules + (
|
||||
"<!-- dever-language:start -->\n"
|
||||
"Previous Dever rules\n"
|
||||
"<!-- dever-language:end -->\n"
|
||||
).encode())
|
||||
daemon.command("skill", "install", cwd="/first")
|
||||
template = installed / "skills/dever-language/assets/template/AGENTS.md"
|
||||
expected_rules = project_rules + template.read_bytes()
|
||||
if rules.read_bytes() != expected_rules:
|
||||
raise RuntimeError("project rules differ from the signed template or user rules were lost")
|
||||
daemon.command("skill", "install", cwd="/first")
|
||||
if rules.read_bytes() != expected_rules:
|
||||
raise RuntimeError("repeated skill installation changed project rules")
|
||||
passed("versioned_skill_resolution_and_project_rules_install")
|
||||
try:
|
||||
daemon.command("build", "/first", "--target", "linux-aarch64", "--output", "/first/program.arm64")
|
||||
except RuntimeError as error:
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Focused static checks for the self-contained, versioned language skill."""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SKILL = ROOT / "skills"
|
||||
|
||||
|
||||
def check_tutorial_sources(skill):
|
||||
"""Keep tutorial excerpts tied to the complete projects checked by Rust."""
|
||||
projects = [
|
||||
path.resolve()
|
||||
for path in [skill / "assets/template", *(skill / "assets/examples").iterdir()]
|
||||
]
|
||||
for document in sorted((skill / "references").glob("*.md")):
|
||||
content = document.read_text()
|
||||
blocks = list(re.finditer(
|
||||
r"^```dever[ \t]*\n(.*?)^```[ \t]*$", content, re.MULTILINE | re.DOTALL
|
||||
))
|
||||
if len(blocks) != len(re.findall(r"^```dever\b", content, re.MULTILINE)):
|
||||
raise ValueError(f"{document.name}: unclosed or unsupported dever fence")
|
||||
for block in blocks:
|
||||
binding = re.search(
|
||||
r"源码:\[[^\]\n]+\]\(([^)\n]+)\)\n\n\Z", content[:block.start()]
|
||||
)
|
||||
if binding is None:
|
||||
raise ValueError(f"{document.name}: dever block needs a source link")
|
||||
source = (document.parent / binding.group(1)).resolve()
|
||||
owners = [root for root in projects if source.is_relative_to(root)]
|
||||
if len(owners) != 1 or source.suffix != ".dever":
|
||||
raise ValueError(f"{document.name}: source must belong to a checked skill project")
|
||||
relative = source.relative_to(owners[0])
|
||||
if relative.parts[0] not in ("module", "test") or not source.is_file():
|
||||
raise ValueError(f"{document.name}: source must be a module or test file")
|
||||
excerpt = block.group(1)
|
||||
if not excerpt.strip() or ("\n" + source.read_text()).count("\n" + excerpt) != 1:
|
||||
raise ValueError(f"{document.name}: excerpt differs from its source or is ambiguous")
|
||||
|
||||
|
||||
class SkillExamples(unittest.TestCase):
|
||||
def test_relocated_skill_links_and_release_file_limits(self):
|
||||
with tempfile.TemporaryDirectory(prefix="dever-skill-") as directory:
|
||||
installed = Path(directory) / "dever-language"
|
||||
shutil.copytree(SKILL, installed)
|
||||
files = [path for path in installed.rglob("*") if path.is_file()]
|
||||
self.assertLessEqual(len(files), 256)
|
||||
self.assertLessEqual(sum(path.stat().st_size for path in files), 8 * 1024 * 1024)
|
||||
self.assertTrue((installed / "SKILL.md").is_file())
|
||||
for path in files:
|
||||
self.assertFalse(path.is_symlink())
|
||||
self.assertLessEqual(path.stat().st_size, 1024 * 1024)
|
||||
if path.suffix != ".md":
|
||||
continue
|
||||
for target in re.findall(r"\]\(([^)]+)\)", path.read_text()):
|
||||
if "://" in target or target.startswith("#"):
|
||||
continue
|
||||
resolved = (path.parent / target.split("#", 1)[0]).resolve()
|
||||
self.assertTrue(resolved.is_relative_to(installed), (path, target))
|
||||
self.assertTrue(resolved.exists(), (path, target))
|
||||
|
||||
def test_tutorial_code_matches_relocated_checked_sources(self):
|
||||
with tempfile.TemporaryDirectory(prefix="dever-tutorial-") as directory:
|
||||
installed = Path(directory) / "dever-language"
|
||||
shutil.copytree(SKILL, installed)
|
||||
check_tutorial_sources(installed)
|
||||
|
||||
def test_tutorial_source_drift_and_unbound_code_are_rejected(self):
|
||||
with tempfile.TemporaryDirectory(prefix="dever-tutorial-drift-") as directory:
|
||||
installed = Path(directory) / "dever-language"
|
||||
shutil.copytree(SKILL, installed)
|
||||
document = installed / "references/language.md"
|
||||
original = document.read_text()
|
||||
source = installed / "assets/template/module/hello/greeting/app.dever"
|
||||
original_source = source.read_text()
|
||||
mutations = {
|
||||
"unbound": original.replace("源码:", "完整示例:", 1),
|
||||
"changed_code": original.replace('"Hello, "', '"Welcome, "', 1),
|
||||
"unclosed": original + "\n```dever\nunclosed() () {}\n",
|
||||
"outside_project": original.replace(
|
||||
"../assets/template/module/hello/greeting/app.dever", "../SKILL.md", 1
|
||||
),
|
||||
}
|
||||
for name, changed in mutations.items():
|
||||
with self.subTest(name=name):
|
||||
document.write_text(changed)
|
||||
with self.assertRaises(ValueError):
|
||||
check_tutorial_sources(installed)
|
||||
document.write_text(original)
|
||||
source.write_text(original_source.replace('"Hello, "', '"Welcome, "'))
|
||||
with self.assertRaisesRegex(ValueError, "excerpt differs"):
|
||||
check_tutorial_sources(installed)
|
||||
source.write_text(original_source + "\n" + original_source)
|
||||
with self.assertRaisesRegex(ValueError, "ambiguous"):
|
||||
check_tutorial_sources(installed)
|
||||
|
||||
def test_signature_index_matches_public_library_declarations(self):
|
||||
index = (SKILL / "references/signatures.md").read_text()
|
||||
blocks = re.findall(r"```signatures\n(.*?)```", index, re.DOTALL)
|
||||
self.assertTrue(blocks, "the shipped index must contain concrete signatures")
|
||||
seen = set()
|
||||
for signature in "\n".join(blocks).splitlines():
|
||||
if not signature:
|
||||
continue
|
||||
qualified = signature.split("(", 1)[0]
|
||||
package, function = qualified.rsplit(".", 1)
|
||||
library = ROOT / "library" / (package.replace(".", "/") + ".dever")
|
||||
self.assertNotIn(qualified, seen)
|
||||
seen.add(qualified)
|
||||
declaration = re.search(
|
||||
rf"^public ({re.escape(function)}\s*\([^{{]*?)\s*\{{",
|
||||
library.read_text(),
|
||||
re.MULTILINE,
|
||||
)
|
||||
self.assertIsNotNone(declaration, qualified)
|
||||
expected = package + "." + declaration.group(1)
|
||||
self.assertEqual(re.sub(r"\s", "", signature), re.sub(r"\s", "", expected), qualified)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user