use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
fn sinter(repo: &Path, args: &[&str]) -> (bool, String) {
let out = Command::new(env!("CARGO_BIN_EXE_sinter"))
.args(args)
.current_dir(repo)
.env("HOME", repo)
.env("USERPROFILE", repo)
.output()
.expect("run sinter");
(
out.status.success(),
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
),
)
}
fn controller_fixture(repo: &Path) {
std::fs::create_dir_all(repo.join("player/traversal")).unwrap();
std::fs::write(
repo.join("player/character.ts"),
r#"// Main player character controller: movement, traversal, input routing.
export class PlayerCharacterV2 {
// Advances one frame.
update(): void {
climbFactory();
}
}
// Builds a character record (constructor-style noise seed).
export function character(): number {
return 1;
}
"#,
)
.unwrap();
std::fs::write(
repo.join("player/traversal/climb.ts"),
r#"import { character } from "../character";
// Climbing movement component.
export class ClimbComponentV2 {
begin(): void {
character();
}
}
export function climbFactory(): number {
return character();
}
"#,
)
.unwrap();
std::fs::write(
repo.join("player/traversal/ledge.ts"),
r#"// Ledge grab component.
export class LedgeComponentV2 {
grab(): void {}
}
"#,
)
.unwrap();
std::fs::write(
repo.join("tests/character.test.ts"),
"export function character(): number {\n return 2;\n}\n",
)
.ok();
std::fs::create_dir_all(repo.join("tests")).unwrap();
std::fs::write(
repo.join("tests/character.test.ts"),
"export function testCharacter(): number {\n return 2;\n}\n",
)
.unwrap();
}
#[test]
fn ask_ranks_documented_controller_first() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
controller_fixture(repo);
let (ok, out) = sinter(repo, &["build"]);
assert!(ok, "{out}");
let started = Instant::now();
let (ok, out) = sinter(repo, &["ask", "where is the character controller?"]);
let elapsed = started.elapsed();
assert!(ok, "{out}");
let first_hit = out
.lines()
.find(|l| l.starts_with("1. "))
.expect("ranked output");
assert!(first_hit.contains("PlayerCharacterV2"), "{out}");
assert!(first_hit.contains("class"), "{out}");
assert!(out.contains("Main player character controller"), "{out}");
assert!(out.contains("2/2 terms"), "{out}");
let ctor_rank = out
.lines()
.position(|l| l.contains("function character") || l.contains(" character "));
let class_rank = out.lines().position(|l| l.contains("PlayerCharacterV2"));
if let (Some(c), Some(k)) = (ctor_rank, class_rank) {
assert!(k < c, "constructor outranked controller:\n{out}");
}
assert!(
elapsed < Duration::from_millis(1500),
"ask took {elapsed:?} end to end"
);
let (_, again) = sinter(repo, &["ask", "where is the character controller?"]);
assert_eq!(out, again, "ask output not deterministic");
}
#[test]
fn ask_zero_hits_and_stopword_only() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
controller_fixture(repo);
let (ok, out) = sinter(repo, &["build"]);
assert!(ok, "{out}");
let (ok, out) = sinter(repo, &["ask", "zzqx flurble"]);
assert!(!ok, "no-result ask must exit nonzero: {out}");
assert!(out.contains("no match"), "{out}");
let (ok, out) = sinter(repo, &["ask", "where is the?"]);
assert!(!ok, "stopword-only question should fail with a hint");
assert!(out.contains("no searchable terms"), "{out}");
}
#[test]
fn ask_json_carries_span_and_score() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
controller_fixture(repo);
sinter(repo, &["build"]);
let (ok, out) = sinter(repo, &["ask", "character controller", "--json"]);
assert!(ok, "{out}");
let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid json");
let first = &parsed.as_array().expect("array")[0];
assert_eq!(first["name"], "PlayerCharacterV2");
assert!(first["span"]["end"].as_u64().unwrap() > 0);
assert!(first["score"].as_i64().unwrap() > 0);
assert!(first["matched"].as_array().unwrap().len() == 2);
}
#[test]
fn ask_dampens_vendored_paths() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
controller_fixture(repo);
std::fs::create_dir_all(repo.join("vendor/thirdlib")).unwrap();
std::fs::write(
repo.join("vendor/thirdlib/character.ts"),
r#"// Vendored character controller helper for character controller demos.
export class CharacterControllerShim {
control(): void {}
}
"#,
)
.unwrap();
let (ok, out) = sinter(repo, &["build"]);
assert!(ok, "{out}");
let (ok, out) = sinter(repo, &["ask", "where is the character controller?"]);
assert!(ok, "{out}");
let first = out.lines().find(|l| l.starts_with("1. ")).unwrap();
assert!(
first.contains("PlayerCharacterV2"),
"vendored shim outranked project code:\n{out}"
);
}
#[test]
fn ask_drops_weak_verbs_when_real_terms_remain() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
std::fs::create_dir_all(repo.join("src")).unwrap();
std::fs::write(
repo.join("src/cache.ts"),
r#"// Incremental cache invalidation for derived state.
export class CacheInvalidator {
invalidate(): void {}
}
"#,
)
.unwrap();
std::fs::write(
repo.join("src/workspace.ts"),
r#"// Workspace working-set utilities for workers at work.
export class WorkspaceWorker {
work(): void {}
}
"#,
)
.unwrap();
let (ok, out) = sinter(repo, &["build"]);
assert!(ok, "{out}");
let (ok, out) = sinter(repo, &["ask", "how does the cache invalidation work?"]);
assert!(ok, "{out}");
assert!(out.contains("2 terms"), "{out}");
let first = out.lines().find(|l| l.starts_with("1. ")).unwrap();
assert!(first.contains("CacheInvalidator"), "{out}");
let (ok, out) = sinter(repo, &["ask", "work"]);
assert!(ok, "{out}");
assert!(
out.contains("WorkspaceWorker") || out.contains("work"),
"{out}"
);
}
#[test]
fn ask_trigram_credit_is_per_term() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
controller_fixture(repo);
std::fs::write(
repo.join("player/gym.ts"),
r#"// Gym control action registry.
export class GymControlAction {
act(): void {}
}
"#,
)
.unwrap();
let (ok, out) = sinter(repo, &["build"]);
assert!(ok, "{out}");
let (ok, out) = sinter(
repo,
&["ask", "where is the character controller?", "--limit", "10"],
);
assert!(ok, "{out}");
let first = out.lines().find(|l| l.starts_with("1. ")).unwrap();
assert!(first.contains("PlayerCharacterV2"), "{out}");
for line in out.lines() {
let ranked = line.get(..3).is_some_and(|p| p.ends_with(". "));
if ranked && line.contains("GymControlAction") {
assert!(
line.contains("1/2 terms"),
"trigram closeness credited across terms:\n{out}"
);
}
}
}
#[test]
fn ask_finds_primaries_across_golden_fixtures() {
let fixtures = [
("rust-basic", "compute", "function compute"),
("go-basic", "Greet", "function Greet"),
("python-basic", "greet", "greet"),
("typescript-basic", "greet", "greet"),
("bash-basic", "greet", "function greet"),
];
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../harness/golden/fixtures");
for (fixture, symbol, expect) in fixtures {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
copy_tree(&root.join(fixture), repo);
let (ok, out) = sinter(repo, &["build"]);
assert!(ok, "{fixture}: {out}");
let (ok, out) = sinter(repo, &["ask", symbol]);
assert!(ok, "{fixture}: {out}");
let first = out
.lines()
.find(|l| l.starts_with("1. "))
.unwrap_or_else(|| panic!("{fixture}: no hits\n{out}"));
assert!(first.contains(expect), "{fixture}: top hit wrong\n{out}");
}
}
fn copy_tree(from: &Path, to: &Path) {
for entry in std::fs::read_dir(from).unwrap() {
let entry = entry.unwrap();
let dest = to.join(entry.file_name());
if entry.path().is_dir() {
std::fs::create_dir_all(&dest).unwrap();
copy_tree(&entry.path(), &dest);
} else if entry.file_name() != "expected.json" {
std::fs::copy(entry.path(), dest).unwrap();
}
}
}
#[test]
fn show_card_and_ambiguity() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
controller_fixture(repo);
sinter(repo, &["build"]);
let (ok, out) = sinter(repo, &["show", "PlayerCharacterV2"]);
assert!(ok, "{out}");
assert!(out.contains("class PlayerCharacterV2"), "{out}");
assert!(out.contains("contains (1)"), "{out}");
assert!(out.contains("unresolved refs in this file:"), "{out}");
assert!(out.contains("Next: sinter affected"), "{out}");
let (ok, out) = sinter(repo, &["show", "player/traversal/climb.ts"]);
assert!(ok, "{out}");
assert!(out.contains("file player/traversal/climb.ts"), "{out}");
assert!(out.contains("contains"), "{out}");
let (ok, out) = sinter(repo, &["show", "begin"]);
assert!(ok, "{out}"); let (ok, out) = sinter(repo, &["show", "nonexistent_thing_xyz"]);
assert!(!ok, "{out}");
}
#[test]
fn ask_family_boost_surfaces_parent() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
std::fs::create_dir_all(repo.join("src")).unwrap();
std::fs::write(
repo.join("src/pawn.ts"),
r#"// The player character pawn: locomotion and traversal.
export class PlayerPawn {
// Refreshes the character controller control mode.
refreshControlMode(): void {}
// Reads the character controller mode.
getControlMode(): void {}
// Applies character controller input routing.
routeControllerInput(): void {}
}
"#,
)
.unwrap();
let (ok, out) = sinter(repo, &["build"]);
assert!(ok, "{out}");
let (ok, out) = sinter(repo, &["ask", "where is the character controller?"]);
assert!(ok, "{out}");
let first = out.lines().find(|l| l.starts_with("1. ")).unwrap();
assert!(
first.contains("class PlayerPawn"),
"parent class not surfaced above its matching members:\n{out}"
);
assert!(first.contains("family"), "family channel missing:\n{out}");
}
#[test]
fn ask_family_boost_crosses_header_impl() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
std::fs::create_dir_all(repo.join("pawn")).unwrap();
std::fs::write(
repo.join("pawn/pawn.h"),
r#"// The player character pawn.
class GAME_API APawn2
{
public:
void RefreshControlMode();
void RouteControllerInput();
};
"#,
)
.unwrap();
std::fs::write(
repo.join("pawn/pawn.cpp"),
r#"#include "pawn/pawn.h"
// Refreshes the character controller mode.
void APawn2::RefreshControlMode() {}
// Routes character controller input.
void APawn2::RouteControllerInput() {}
"#,
)
.unwrap();
let (ok, out) = sinter(repo, &["build"]);
assert!(ok, "{out}");
let (ok, out) = sinter(repo, &["ask", "where is the character controller?"]);
assert!(ok, "{out}");
let first = out.lines().find(|l| l.starts_with("1. ")).unwrap();
assert!(
first.contains("class APawn2"),
"class not surfaced above impl-side members:\n{out}"
);
}
#[test]
fn install_writes_skill_card() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("skills/sinter");
let out = Command::new(env!("CARGO_BIN_EXE_sinter"))
.args(["install", "--dir"])
.arg(&target)
.output()
.unwrap();
assert!(out.status.success());
let card = std::fs::read_to_string(target.join("SKILL.md")).unwrap();
assert!(card.contains("name: sinter"), "frontmatter missing");
assert!(card.contains("sinter ask"), "routing missing");
assert!(
!card.to_lowercase().contains("retry"),
"no orchestration in prose"
);
}
#[test]
fn doctor_diagnoses_and_clears() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
std::fs::write(repo.join("a.rs"), "pub fn f() {}\n").unwrap();
let (ok, out) = sinter(repo, &["doctor"]);
assert!(!ok, "{out}");
assert!(out.contains("run `sinter build`"), "{out}");
sinter(repo, &["build"]);
let (_, out) = sinter(repo, &["doctor"]);
assert!(out.contains("graph fresh"), "{out}");
assert!(out.contains("graph schema"), "{out}");
std::fs::write(repo.join("a.rs"), "pub fn f() -> u32 { 1 }\n").unwrap();
let (ok, out) = sinter(repo, &["doctor"]);
assert!(!ok, "{out}");
assert!(out.contains("graph stale: 1 changed"), "{out}");
}
#[test]
fn install_mcp_merges_project_config() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
std::fs::write(repo.join("a.rs"), "pub fn f() {}\n").unwrap();
std::fs::write(
repo.join(".mcp.json"),
r#"{"mcpServers": {"other": {"command": "other-tool"}}}"#,
)
.unwrap();
let skills = dir.path().join("skills");
let out = Command::new(env!("CARGO_BIN_EXE_sinter"))
.args(["install", "--dir"])
.arg(&skills)
.args(["--mcp", "--repo"])
.arg(repo)
.output()
.unwrap();
assert!(out.status.success());
let cfg: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(repo.join(".mcp.json")).unwrap()).unwrap();
assert!(
cfg["mcpServers"]["other"].is_object(),
"clobbered existing server"
);
let command = cfg["mcpServers"]["sinter"]["command"]
.as_str()
.expect("sinter MCP command");
assert!(Path::new(command).is_absolute(), "{command}");
let cursor: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(repo.join(".cursor/mcp.json")).unwrap())
.unwrap();
assert_eq!(cursor["mcpServers"]["sinter"]["command"], command);
let codex: toml::Value =
toml::from_str(&std::fs::read_to_string(repo.join(".codex/config.toml")).unwrap()).unwrap();
assert_eq!(
codex["mcp_servers"]["sinter"]["command"].as_str(),
Some(command)
);
assert_eq!(
codex["mcp_servers"]["sinter"]["required"].as_bool(),
Some(true)
);
let args = cfg["mcpServers"]["sinter"]["args"]
.as_array()
.expect("sinter MCP args")
.iter()
.map(|arg| arg.as_str().expect("string MCP arg"));
let mut launched = Command::new(command)
.args(args)
.env("PATH", "")
.current_dir(repo)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("launch generated MCP command without PATH");
writeln!(
launched.stdin.as_mut().expect("piped stdin"),
r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{}}}}"#
)
.unwrap();
drop(launched.stdin.take());
let handshake = launched.wait_with_output().unwrap();
assert!(handshake.status.success());
assert!(
String::from_utf8_lossy(&handshake.stdout).contains("\"result\""),
"MCP initialize did not return a result"
);
sinter(repo, &["build"]);
let (_, out) = sinter(repo, &["doctor"]);
assert!(out.contains("MCP server registered"), "{out}");
}
#[test]
fn install_for_cursor_and_agents() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
std::fs::write(
repo.join("AGENTS.md"),
"# Existing house rules\n\nKeep these.\n",
)
.unwrap();
let skills = dir.path().join("skills");
let run = || {
Command::new(env!("CARGO_BIN_EXE_sinter"))
.args(["install", "all", "--dir"])
.arg(&skills)
.args(["--repo"])
.arg(repo)
.env("HOME", dir.path())
.env("USERPROFILE", dir.path())
.output()
.unwrap()
};
assert!(run().status.success());
assert!(run().status.success());
let agents = std::fs::read_to_string(repo.join("AGENTS.md")).unwrap();
assert!(
agents.contains("Existing house rules"),
"clobbered host content"
);
assert_eq!(
agents.matches("BEGIN sinter").count(),
1,
"block duplicated"
);
assert!(agents.contains("sinter ask"), "routing missing");
let rule = std::fs::read_to_string(repo.join(".cursor/rules/sinter.mdc")).unwrap();
assert!(rule.contains("alwaysApply"), "cursor frontmatter missing");
assert!(rule.contains("sinter ask"), "routing missing");
let skill = std::fs::read_to_string(skills.join("SKILL.md")).unwrap();
let body_line = "never treat it as stale-proof";
assert!(skill.contains(body_line) && rule.contains(body_line));
assert!(!agents.contains(body_line), "AGENTS.md got the full card");
for verb in [
"sinter ask",
"sinter affected",
"sinter path",
"sinter impact",
] {
assert!(
agents.contains(verb) && skill.contains(verb),
"{verb} missing"
);
}
}
#[test]
fn hooks_install_preserves_existing_hooks() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
std::fs::write(repo.join("a.rs"), "pub fn f() {}\n").unwrap();
Command::new("git")
.args(["init", "-q"])
.current_dir(repo)
.output()
.unwrap();
let hook = repo.join(".git/hooks/post-commit");
std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
std::fs::write(&hook, "#!/bin/sh\necho user-hook\n").unwrap();
let (ok, out) = sinter(repo, &["hooks", "install"]);
assert!(ok, "{out}");
let (ok, out) = sinter(repo, &["hooks", "install"]); assert!(ok, "{out}");
assert!(out.contains("already installed"), "{out}");
let content = std::fs::read_to_string(&hook).unwrap();
assert!(
content.contains("echo user-hook"),
"clobbered user hook:\n{content}"
);
assert_eq!(
content.matches("sinter build").count(),
1,
"duplicated:\n{content}"
);
}
#[test]
fn version_subcommand_matches_flag() {
let run = |args: &[&str]| {
let out = Command::new(env!("CARGO_BIN_EXE_sinter"))
.args(args)
.output()
.unwrap();
assert!(out.status.success());
String::from_utf8_lossy(&out.stdout).into_owned()
};
let sub = run(&["version"]);
let flag = run(&["--version"]);
assert!(sub.contains(env!("CARGO_PKG_VERSION")), "{sub}");
assert!(flag.contains(env!("CARGO_PKG_VERSION")), "{flag}");
assert!(sub.contains("graph schema v"), "{sub}");
assert!(sub.contains("rust"), "{sub}");
}
#[test]
fn init_onboards_repo() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let repo = dir.path();
std::fs::write(repo.join("a.rs"), "pub fn f() {}\n").unwrap();
Command::new("git")
.args(["init", "-q"])
.current_dir(repo)
.output()
.unwrap();
let init = |args: &[&str]| {
let out = Command::new(env!("CARGO_BIN_EXE_sinter"))
.args(args)
.current_dir(repo)
.env("HOME", home.path())
.env("USERPROFILE", home.path())
.output()
.expect("run sinter");
(
out.status.success(),
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
),
)
};
let (ok, out) = init(&["init"]);
assert!(ok, "{out}");
assert!(
home.path().join(".claude/skills/sinter/SKILL.md").exists(),
"{out}"
);
assert!(out.contains("== build =="), "{out}");
assert!(repo.join(".sinter/graph.redb").exists(), "{out}");
assert!(
std::fs::read_to_string(repo.join(".git/hooks/post-commit"))
.unwrap()
.contains("sinter build"),
"{out}"
);
assert!(
std::fs::read_to_string(repo.join("AGENTS.md"))
.unwrap()
.contains("BEGIN sinter"),
"{out}"
);
let mcp: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(repo.join(".mcp.json")).unwrap()).unwrap();
assert!(
Path::new(
mcp["mcpServers"]["sinter"]["command"]
.as_str()
.expect("sinter MCP command")
)
.is_absolute()
);
assert!(out.contains("== doctor =="), "{out}");
let (_, again) = init(&["init"]);
assert!(again.contains("0 changed"), "{again}");
let agents = std::fs::read_to_string(repo.join("AGENTS.md")).unwrap();
assert_eq!(agents.matches("BEGIN sinter").count(), 1, "{agents}");
}
#[cfg(unix)]
#[test]
fn init_runs_indexers_only_with_consent() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let bin = tempfile::tempdir().unwrap();
let repo = dir.path();
std::fs::write(repo.join("a.rs"), "pub fn f() {}\n").unwrap();
let marker = bin.path().join("executed");
std::fs::write(
bin.path().join("rust-analyzer"),
format!("#!/bin/sh\ntouch {}\n", marker.display()),
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
bin.path().join("rust-analyzer"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
let path_env = format!(
"{}:{}",
bin.path().display(),
std::env::var("PATH").unwrap_or_default()
);
let init = |args: &[&str]| {
Command::new(env!("CARGO_BIN_EXE_sinter"))
.args(args)
.current_dir(repo)
.env("HOME", home.path())
.env("USERPROFILE", home.path())
.env("PATH", &path_env)
.stdin(std::process::Stdio::null())
.output()
.expect("run sinter")
};
let out = init(&["init"]);
assert!(out.status.success());
assert!(
!marker.exists(),
"non-interactive init executed an indexer without consent"
);
let text = String::from_utf8_lossy(&out.stdout).into_owned();
assert!(text.contains("skipped: non-interactive"), "{text}");
init(&["init", "--scip"]);
assert!(marker.exists(), "--scip must run the indexer");
}
const HOOK_FILE: &str = if cfg!(windows) {
"sinter-first.ps1"
} else {
"sinter-first.sh"
};
const OTHER_HOOK_FILE: &str = if cfg!(windows) {
"sinter-first.sh"
} else {
"sinter-first.ps1"
};
#[test]
fn install_enforce_is_idempotent_and_preserving() {
let home = tempfile::tempdir().unwrap();
std::fs::create_dir_all(home.path().join(".claude")).unwrap();
std::fs::write(
home.path().join(".claude/settings.json"),
r#"{"model":"opus","hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"other-tool hook"}]}]}}"#,
)
.unwrap();
let run = || {
let out = Command::new(env!("CARGO_BIN_EXE_sinter"))
.args(["install", "enforce", "--global"])
.env("HOME", home.path())
.env("USERPROFILE", home.path())
.output()
.expect("run sinter");
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
};
run();
run();
assert!(home.path().join(".claude/hooks").join(HOOK_FILE).exists());
assert!(
!home
.path()
.join(".claude/hooks")
.join(OTHER_HOOK_FILE)
.exists(),
"only the platform's hook variant is installed"
);
let settings: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(home.path().join(".claude/settings.json")).unwrap(),
)
.expect("settings stay valid JSON");
assert_eq!(settings["model"], "opus", "unrelated settings preserved");
let text = settings.to_string();
assert!(text.contains("other-tool hook"), "existing hooks preserved");
assert!(text.contains(HOOK_FILE), "{text}");
for marker in [" prompt\"", " grep\"", " greptool\""] {
assert_eq!(
text.matches(marker).count(),
1,
"{marker} must appear exactly once after two installs"
);
}
assert!(
!text.contains("permissionDecision"),
"enforcement must never carry a permission decision"
);
let doctor = Command::new(env!("CARGO_BIN_EXE_sinter"))
.args(["doctor"])
.current_dir(home.path())
.env("HOME", home.path())
.env("USERPROFILE", home.path())
.output()
.expect("run doctor");
let out = String::from_utf8_lossy(&doctor.stdout);
assert!(
out.contains("enforcement hooks installed and current"),
"doctor must accept the {HOOK_FILE} enforcement install: {out}"
);
}
#[test]
fn install_enforce_defaults_to_repo_scope() {
let repo = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_sinter"))
.args(["install", "enforce"])
.current_dir(repo.path())
.env("HOME", home.path())
.env("USERPROFILE", home.path())
.output()
.expect("run sinter");
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(repo.path().join(".claude/hooks").join(HOOK_FILE).exists());
let settings = std::fs::read_to_string(repo.path().join(".claude/settings.json")).unwrap();
let relative_cmd = if cfg!(windows) {
"& '.claude/hooks/sinter-first.ps1' prompt".to_string()
} else {
"bash .claude/hooks/sinter-first.sh prompt".to_string()
};
assert!(
settings.contains(&relative_cmd),
"repo scope must use a relative command: {settings}"
);
assert!(
!home.path().join(".claude/hooks").join(HOOK_FILE).exists(),
"repo scope must not touch the global home"
);
}
#[test]
fn uninit_reverses_init_and_preserves_user_content() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let repo = dir.path();
std::fs::write(repo.join("a.rs"), "pub fn f() {}\n").unwrap();
std::fs::write(repo.join("AGENTS.md"), "# My rules\n\nKeep me.\n").unwrap();
std::fs::write(
repo.join(".mcp.json"),
r#"{"mcpServers":{"other":{"command":"other"}}}"#,
)
.unwrap();
Command::new("git")
.args(["init", "-q"])
.current_dir(repo)
.output()
.unwrap();
let run = |args: &[&str]| {
let out = Command::new(env!("CARGO_BIN_EXE_sinter"))
.args(args)
.current_dir(repo)
.env("HOME", home.path())
.env("USERPROFILE", home.path())
.stdin(std::process::Stdio::null())
.output()
.expect("run sinter");
assert!(
out.status.success(),
"sinter {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
};
run(&["init"]);
assert!(repo.join(".sinter/graph.redb").exists());
assert!(repo.join(".claude/hooks").join(HOOK_FILE).exists());
run(&["uninit"]);
for gone in [
".sinter",
".claude/hooks/sinter-first.sh",
".claude/hooks/sinter-first.ps1",
".claude/settings.json",
".codex/config.toml",
".git/hooks/post-commit",
] {
assert!(!repo.join(gone).exists(), "{gone} should be removed");
}
let agents = std::fs::read_to_string(repo.join("AGENTS.md")).unwrap();
assert!(agents.contains("Keep me."), "{agents}");
assert!(!agents.contains("sinter"), "{agents}");
let mcp = std::fs::read_to_string(repo.join(".mcp.json")).unwrap();
assert!(mcp.contains("other"), "{mcp}");
assert!(!mcp.contains("sinter"), "{mcp}");
}
#[test]
fn ask_verbose_question_drops_scaffolding_and_flags_weak_match() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
controller_fixture(repo);
std::fs::write(
repo.join("player/describe.ts"),
"// Describe helper.\nexport function describeThing(): number {\n return 1;\n}\n",
)
.unwrap();
let (ok, out) = sinter(repo, &["build"]);
assert!(ok, "{out}");
let (ok, out) = sinter(
repo,
&[
"ask",
"What documentation describes dashboard usability, operator experience, or comparisons to k9s?",
],
);
assert!(!ok, "{out}");
let header = out.lines().next().unwrap_or_default();
for filler in ["documentation", "describes", "comparisons"] {
assert!(!header.contains(filler), "{filler} survived: {out}");
}
if out.contains("1. ") {
assert!(out.contains("weak match"), "{out}");
}
}
#[test]
fn ask_multi_topic_groups_hits_per_clause() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
controller_fixture(repo);
let (ok, out) = sinter(repo, &["build"]);
assert!(ok, "{out}");
let question = "where is the character controller, or the ledge grab?";
let (ok, out) = sinter(repo, &["ask", question]);
assert!(ok, "{out}");
assert!(out.contains("Best matches (2 topics):"), "{out}");
let controller_at = out.find("## character controller").expect(&out);
let ledge_at = out.find("## ledge grab").expect(&out);
assert!(
controller_at < ledge_at,
"clause order not preserved:\n{out}"
);
let controller_section = &out[controller_at..ledge_at];
let ledge_section = &out[ledge_at..];
assert!(controller_section.contains("PlayerCharacterV2"), "{out}");
assert!(ledge_section.contains("LedgeComponentV2"), "{out}");
assert!(controller_section.contains("2/2 terms"), "{out}");
let (_, again) = sinter(repo, &["ask", question]);
assert_eq!(out, again, "multi-clause output not deterministic");
let (ok, single) = sinter(repo, &["ask", "where is the character controller?"]);
assert!(ok, "{single}");
assert!(!single.contains("## "), "{single}");
assert!(single.contains("Best matches (2 terms"), "{single}");
let (ok, js) = sinter(repo, &["ask", question, "--json"]);
assert!(ok, "{js}");
let parsed: serde_json::Value = serde_json::from_str(&js).expect("valid json");
let hits = parsed.as_array().expect("array");
assert!(!hits.is_empty(), "{js}");
assert!(hits.iter().all(|h| h["topic"].is_string()), "{js}");
assert!(
hits.iter()
.any(|h| h["topic"] == "character controller" && h["name"] == "PlayerCharacterV2"),
"{js}"
);
assert!(hits.iter().any(|h| h["topic"] == "ledge grab"), "{js}");
}
#[test]
fn ask_codex_shaped_question_splits_sanely() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
controller_fixture(repo);
let (ok, out) = sinter(repo, &["build"]);
assert!(ok, "{out}");
let (ok, out) = sinter(
repo,
&[
"ask",
"What product decisions or documentation describe dashboard usability, \
operator experience, terminal layout, keyboard controls, or comparisons to k9s?",
],
);
assert!(!ok, "{out}");
assert!(out.contains("topics):"), "did not split: {out}");
assert!(out.contains("## dashboard usability"), "{out}");
assert!(out.contains("## keyboard controls"), "{out}");
for filler in ["## documentation", "describe", "comparisons"] {
assert!(!out.contains(filler), "{filler} leaked into topics: {out}");
}
assert!(out.contains("no match"), "{out}");
}
#[cfg(unix)]
#[test]
fn strict_hook_denies_first_search_then_nudges() {
let script = concat!(env!("CARGO_MANIFEST_DIR"), "/skill/sinter-first.sh");
let repo = tempfile::tempdir().unwrap();
std::fs::create_dir_all(repo.path().join(".sinter")).unwrap();
std::fs::write(repo.path().join(".sinter/graph.redb"), "").unwrap();
let tmp = tempfile::tempdir().unwrap();
let run = |mode: &str, stdin: &str| -> String {
use std::io::Write;
let mut child = Command::new("bash")
.args([script, mode])
.current_dir(repo.path())
.env("TMPDIR", tmp.path())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.expect("spawn hook");
child
.stdin
.take()
.unwrap()
.write_all(stdin.as_bytes())
.unwrap();
let out = child.wait_with_output().expect("run hook");
assert!(out.status.success());
String::from_utf8(out.stdout).unwrap()
};
let input = |sid: Option<&str>| match sid {
Some(sid) => format!(r#"{{"session_id":"{sid}","tool_input":{{"command":"rg foo"}}}}"#),
None => r#"{"tool_input":{"command":"rg foo"}}"#.to_string(),
};
let first = run("grep-strict", &input(Some("sess-x")));
let json: serde_json::Value = serde_json::from_str(&first).expect("deny must be valid JSON");
assert_eq!(
json["hookSpecificOutput"]["permissionDecision"], "deny",
"{first}"
);
assert!(!first.contains("\"allow\""), "{first}");
assert!(
first.contains("sinter ask")
&& first.contains("affected")
&& first.contains("path")
&& first.contains("impact"),
"deny reason must name the sinter commands: {first}"
);
let second = run("grep-strict", &input(Some("sess-x")));
assert!(second.contains("additionalContext"), "{second}");
assert!(!second.contains("deny"), "{second}");
let other = run("grep-strict", &input(Some("sess-y")));
assert!(other.contains("\"permissionDecision\":\"deny\""), "{other}");
let anon = run("grep-strict", &input(None));
assert!(anon.contains("additionalContext"), "{anon}");
assert!(!anon.contains("deny"), "{anon}");
let git = run(
"grep-strict",
r#"{"session_id":"sess-git","tool_input":{"command":"git log --oneline"}}"#,
);
assert!(git.contains("additionalContext"), "{git}");
assert!(!git.contains("deny"), "{git}");
let gt_first = run("greptool-strict", r#"{"session_id":"sess-gt"}"#);
assert!(
gt_first.contains("\"permissionDecision\":\"deny\""),
"{gt_first}"
);
let gt_second = run("greptool-strict", r#"{"session_id":"sess-gt"}"#);
assert!(gt_second.contains("additionalContext"), "{gt_second}");
assert!(!gt_second.contains("deny"), "{gt_second}");
}
#[test]
fn hook_scripts_never_emit_allow() {
for script in ["/skill/sinter-first.sh", "/skill/sinter-first.ps1"] {
let body =
std::fs::read_to_string(format!("{}{script}", env!("CARGO_MANIFEST_DIR"))).unwrap();
assert!(
!body.contains("permissionDecision\":\"allow"),
"{script} must never emit an allow decision"
);
}
}
#[test]
fn install_enforce_strict_switches_slots() {
let home = tempfile::tempdir().unwrap();
let run = |args: &[&str], must_succeed: bool| {
let out = Command::new(env!("CARGO_BIN_EXE_sinter"))
.args(args)
.env("HOME", home.path())
.env("USERPROFILE", home.path())
.current_dir(home.path())
.output()
.expect("run sinter");
if must_succeed {
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
String::from_utf8_lossy(&out.stdout).into_owned()
};
let settings = || std::fs::read_to_string(home.path().join(".claude/settings.json")).unwrap();
run(&["install", "enforce", "--global", "--strict"], true);
let text = settings();
for marker in [" grep-strict\"", " greptool-strict\""] {
assert_eq!(text.matches(marker).count(), 1, "{marker}: {text}");
}
assert!(!text.contains(" grep\""), "non-strict entry left: {text}");
let doctor = run(&["doctor"], false);
assert!(
doctor.contains("enforcement hooks installed and current"),
"{doctor}"
);
run(&["install", "enforce", "--global"], true);
let text = settings();
assert!(!text.contains("-strict"), "strict entry left: {text}");
for marker in [" grep\"", " greptool\""] {
assert_eq!(text.matches(marker).count(), 1, "{marker}: {text}");
}
let doctor = run(&["doctor"], false);
assert!(
doctor.contains("enforcement hooks installed and current"),
"{doctor}"
);
}