KeiSeiKit-1.0/_assembler/tests/substrate_role.rs
Parfii-bot e4b64418fc feat(convergence/u2): capability renames + back-compat aliases
Pre-unlock wave U2. Task 3 from CONVERGENCE-PLAN — rename misleading
capability names, keep old names as deprecated aliases.

Renames:
- tools::read-only → tools::deny-tools (mechanism is tool-name denial,
  not "read-only" metaphor)
- tools::cargo-only-bash → tools::bash-allowlist (mechanism is Bash
  pattern allow-list; cargo-only is one config value)

Back-compat via registry.resolve_alias():
- Old dir _capabilities/tools/{read-only,cargo-only-bash}/ retained with
  capability.toml-only stub: `alias = "<new-name>"` + `deprecated` field
- registry.rs loads alias stubs, redirects lookup before dispatch
- warn_deprecated_once() emits single-shot stderr per alias per process
  via OnceLock<Mutex<HashSet>>
- Zero breaking change to existing manifests / task.toml referencing
  old names

Rust impl files renamed in place:
- gates/tools_read_only.rs → gates/tools_deny_tools.rs (struct
  DenyTools)
- gates/tools_cargo_only_bash.rs → gates/tools_bash_allowlist.rs
  (struct BashAllowlist)
- gates/mod.rs + registry.rs + gate_smoke.rs updated

Roles updated (3): read-only.toml, explorer.toml, edit-local.toml —
reference new names directly.

Tests: kei-agent-runtime 41/41 (was 40, +1 deprecated_aliases_resolve
_to_new_names), _assembler 40/40 unchanged (substrate role expansion
follows new paths).

Docs updated: AGENT-ROLES.md, AGENT-SUBSTRATE-SCHEMA.md, 4 _manifests
referencing the old names (comment-only annotations).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 03:43:40 +08:00

141 lines
5.7 KiB
Rust

//! Integration tests for the v0.16 substrate-role field (phase 5).
//!
//! Confirms that when a manifest declares `substrate_role`, the assembler:
//! 1. Reads `_roles/<role>.toml` from the kit root
//! 2. Concatenates each capability's `_capabilities/<cat>/<slug>/text.md`
//! 3. Emits the fragments as a new `# AGENT SUBSTRATE` section between
//! `# ROLE` and the first behavioural block, preserving the existing
//! generation for manifests that do NOT declare the field.
mod common;
use common::{assemble_bin, read_generated};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
/// Kit root (parent of `_assembler/`). Used by migrated manifests that
/// reference real `_roles/` + `_capabilities/` content.
fn kit_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf()
}
/// Mirror `_manifests/`, `_blocks/`, `_roles/`, `_capabilities/` from
/// the live kit into a temp dir so the test is hermetic.
fn seed_full_kit() -> (TempDir, PathBuf) {
let tmp = TempDir::new().expect("mktempdir");
let root = tmp.path().to_path_buf();
let src = kit_root();
for sub in ["_manifests", "_blocks", "_roles"] {
mirror_flat(&src.join(sub), &root.join(sub));
}
mirror_caps(&src.join("_capabilities"), &root.join("_capabilities"));
(tmp, root)
}
fn mirror_flat(from: &Path, to: &Path) {
fs::create_dir_all(to).expect("mkdir dst");
for entry in fs::read_dir(from).expect("read src").flatten() {
let p = entry.path();
if p.is_file() {
fs::copy(&p, to.join(p.file_name().unwrap())).expect("copy");
}
}
}
fn mirror_caps(from: &Path, to: &Path) {
fs::create_dir_all(to).expect("mkdir caps root");
for cat in fs::read_dir(from).expect("read caps").flatten() {
let cat_path = cat.path();
if !cat_path.is_dir() { continue; }
let cat_dst = to.join(cat_path.file_name().unwrap());
fs::create_dir_all(&cat_dst).expect("mkdir cat");
for slug in fs::read_dir(&cat_path).expect("read cat").flatten() {
let slug_path = slug.path();
if !slug_path.is_dir() { continue; }
let slug_dst = cat_dst.join(slug_path.file_name().unwrap());
fs::create_dir_all(&slug_dst).expect("mkdir slug");
for file in fs::read_dir(&slug_path).expect("read slug").flatten() {
let fp = file.path();
if fp.is_file() {
fs::copy(&fp, slug_dst.join(fp.file_name().unwrap())).expect("copy cap");
}
}
}
}
}
fn assemble(root: &Path, manifest: &str) -> (bool, String, String) {
let path = root.join("_manifests").join(format!("{manifest}.toml"));
let out = Command::new(assemble_bin())
.env("AGENT_ROOT", root)
.env("HOME", root)
.arg(path)
.output()
.expect("spawn");
(
out.status.success(),
String::from_utf8_lossy(&out.stdout).to_string(),
String::from_utf8_lossy(&out.stderr).to_string(),
)
}
#[test]
fn migrated_code_implementer_embeds_substrate_section() {
let (_tmp, root) = seed_full_kit();
let (ok, _stdout, stderr) = assemble(&root, "kei-code-implementer");
assert!(ok, "assemble failed: {stderr}");
let md = read_generated(&root, "kei-code-implementer");
assert!(md.contains("# AGENT SUBSTRATE — role `edit-local`"),
"substrate section header missing in generated md");
assert!(md.contains("You MUST NOT invoke `git`"),
"policy::no-git-ops text.md fragment missing");
assert!(md.contains("under 200 lines of code"),
"quality::constructor-pattern text.md fragment missing");
// Existing block content still present.
assert!(md.contains("# BASELINE"), "baseline block dropped during substrate injection");
assert!(md.contains("# DOMAIN SCOPE"), "domain scope section dropped");
}
#[test]
fn migrated_read_only_agents_embed_read_only_substrate() {
let (_tmp, root) = seed_full_kit();
for name in ["kei-critic", "kei-architect", "kei-security-auditor", "kei-validator"] {
let (ok, _stdout, stderr) = assemble(&root, name);
assert!(ok, "assemble {name} failed: {stderr}");
let md = read_generated(&root, name);
assert!(md.contains("# AGENT SUBSTRATE — role `read-only`"),
"{name}: substrate section header missing");
assert!(md.contains("You MUST NOT use the `Edit` or `Write` tools"),
"{name}: tools::deny-tools text.md fragment missing");
}
}
#[test]
fn non_migrated_agent_has_no_substrate_section() {
let (_tmp, root) = seed_full_kit();
let (ok, _stdout, stderr) = assemble(&root, "kei-researcher");
assert!(ok, "assemble failed: {stderr}");
let md = read_generated(&root, "kei-researcher");
assert!(!md.contains("# AGENT SUBSTRATE"),
"non-migrated agent must not emit substrate section");
}
#[test]
fn substrate_section_precedes_first_block() {
// Invariant: substrate fragments are injected AFTER `# ROLE` and
// BEFORE the first `_blocks/*.md` block (baseline).
let (_tmp, root) = seed_full_kit();
let (ok, _stdout, stderr) = assemble(&root, "kei-code-implementer");
assert!(ok, "assemble failed: {stderr}");
let md = read_generated(&root, "kei-code-implementer");
let role_pos = md.find("# ROLE").expect("# ROLE missing");
let substrate_pos = md.find("# AGENT SUBSTRATE").expect("# AGENT SUBSTRATE missing");
let baseline_pos = md.find("# BASELINE").expect("# BASELINE missing");
assert!(role_pos < substrate_pos, "substrate must come AFTER # ROLE");
assert!(substrate_pos < baseline_pos, "substrate must come BEFORE first block");
}