story-kit: start 65_story_standardised_script_test_entry_point_for_all_projects

This commit is contained in:
Dave
2026-02-23 12:59:55 +00:00
parent 216ca9ea2f
commit cbd0233e5e
8 changed files with 205 additions and 94 deletions

View File

@@ -983,38 +983,32 @@ fn check_uncommitted_changes(path: &Path) -> Result<(), String> {
Ok(())
}
/// Run `cargo clippy` and `cargo nextest run` (falling back to `cargo test`) in
/// the given directory. Returns `(gates_passed, combined_output)`.
fn run_acceptance_gates(path: &Path) -> Result<(bool, String), String> {
let mut all_output = String::new();
let mut all_passed = true;
// ── cargo clippy ──────────────────────────────────────────────
let clippy = Command::new("cargo")
.args(["clippy", "--all-targets", "--all-features"])
.current_dir(path)
.output()
.map_err(|e| format!("Failed to run cargo clippy: {e}"))?;
all_output.push_str("=== cargo clippy ===\n");
let clippy_stdout = String::from_utf8_lossy(&clippy.stdout);
let clippy_stderr = String::from_utf8_lossy(&clippy.stderr);
if !clippy_stdout.is_empty() {
all_output.push_str(&clippy_stdout);
}
if !clippy_stderr.is_empty() {
all_output.push_str(&clippy_stderr);
}
all_output.push('\n');
if !clippy.status.success() {
all_passed = false;
/// Run the project's test suite.
///
/// Uses `script/test` if present, treating it as the canonical single test entry point.
/// Falls back to `cargo nextest run` / `cargo test` when `script/test` is absent.
/// Returns `(tests_passed, output)`.
fn run_project_tests(path: &Path) -> Result<(bool, String), String> {
let script_test = path.join("script").join("test");
if script_test.exists() {
let mut output = String::from("=== script/test ===\n");
let result = Command::new(&script_test)
.current_dir(path)
.output()
.map_err(|e| format!("Failed to run script/test: {e}"))?;
let out = format!(
"{}{}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
output.push_str(&out);
output.push('\n');
return Ok((result.status.success(), output));
}
// ── cargo nextest run (fallback: cargo test) ──────────────────
all_output.push_str("=== tests ===\n");
let (test_success, test_out) = match Command::new("cargo")
// Fallback: cargo nextest run / cargo test
let mut output = String::from("=== tests ===\n");
let (success, test_out) = match Command::new("cargo")
.args(["nextest", "run"])
.current_dir(path)
.output()
@@ -1042,10 +1036,43 @@ fn run_acceptance_gates(path: &Path) -> Result<(bool, String), String> {
(o.status.success(), combined)
}
};
output.push_str(&test_out);
output.push('\n');
Ok((success, output))
}
all_output.push_str(&test_out);
/// Run `cargo clippy` and the project test suite (via `script/test` if present,
/// otherwise `cargo nextest run` / `cargo test`) in the given directory.
/// Returns `(gates_passed, combined_output)`.
fn run_acceptance_gates(path: &Path) -> Result<(bool, String), String> {
let mut all_output = String::new();
let mut all_passed = true;
// ── cargo clippy ──────────────────────────────────────────────
let clippy = Command::new("cargo")
.args(["clippy", "--all-targets", "--all-features"])
.current_dir(path)
.output()
.map_err(|e| format!("Failed to run cargo clippy: {e}"))?;
all_output.push_str("=== cargo clippy ===\n");
let clippy_stdout = String::from_utf8_lossy(&clippy.stdout);
let clippy_stderr = String::from_utf8_lossy(&clippy.stderr);
if !clippy_stdout.is_empty() {
all_output.push_str(&clippy_stdout);
}
if !clippy_stderr.is_empty() {
all_output.push_str(&clippy_stderr);
}
all_output.push('\n');
if !clippy.status.success() {
all_passed = false;
}
// ── tests (script/test if available, else cargo nextest/test) ─
let (test_success, test_out) = run_project_tests(path)?;
all_output.push_str(&test_out);
if !test_success {
all_passed = false;
}
@@ -1151,45 +1178,16 @@ fn run_merge_quality_gates(project_root: &Path) -> Result<(bool, String), String
all_passed = false;
}
// ── cargo nextest run (fallback: cargo test) ─────────────────
all_output.push_str("=== tests ===\n");
let (test_success, test_out) = match Command::new("cargo")
.args(["nextest", "run"])
.current_dir(project_root)
.output()
{
Ok(o) => {
let combined = format!(
"{}{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
);
(o.status.success(), combined)
}
Err(_) => {
let o = Command::new("cargo")
.args(["test"])
.current_dir(project_root)
.output()
.map_err(|e| format!("Failed to run cargo test: {e}"))?;
let combined = format!(
"{}{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
);
(o.status.success(), combined)
}
};
// ── tests (script/test if available, else cargo nextest/test)
let (test_success, test_out) = run_project_tests(project_root)?;
all_output.push_str(&test_out);
all_output.push('\n');
if !test_success {
all_passed = false;
}
// ── pnpm (if frontend/ directory exists) ─────────────────────
// ── pnpm build (if frontend/ directory exists) ────────────────
// pnpm test is handled by script/test when present; only run it here as
// a standalone fallback when there is no script/test.
let frontend_dir = project_root.join("frontend");
if frontend_dir.exists() {
all_output.push_str("=== pnpm build ===\n");
@@ -1211,23 +1209,28 @@ fn run_merge_quality_gates(project_root: &Path) -> Result<(bool, String), String
all_passed = false;
}
all_output.push_str("=== pnpm test ===\n");
let pnpm_test = Command::new("pnpm")
.args(["test", "--run"])
.current_dir(&frontend_dir)
.output()
.map_err(|e| format!("Failed to run pnpm test: {e}"))?;
// Only run pnpm test separately when script/test is absent (it would
// already cover frontend tests in that case).
let script_test = project_root.join("script").join("test");
if !script_test.exists() {
all_output.push_str("=== pnpm test ===\n");
let pnpm_test = Command::new("pnpm")
.args(["test", "--run"])
.current_dir(&frontend_dir)
.output()
.map_err(|e| format!("Failed to run pnpm test: {e}"))?;
let test_out = format!(
"{}{}",
String::from_utf8_lossy(&pnpm_test.stdout),
String::from_utf8_lossy(&pnpm_test.stderr)
);
all_output.push_str(&test_out);
all_output.push('\n');
let pnpm_test_out = format!(
"{}{}",
String::from_utf8_lossy(&pnpm_test.stdout),
String::from_utf8_lossy(&pnpm_test.stderr)
);
all_output.push_str(&pnpm_test_out);
all_output.push('\n');
if !pnpm_test.status.success() {
all_passed = false;
if !pnpm_test.status.success() {
all_passed = false;
}
}
}
@@ -1875,4 +1878,50 @@ mod tests {
assert!(archived.exists(), "archived file should exist");
}
}
// ── run_project_tests tests ───────────────────────────────────
#[cfg(unix)]
#[test]
fn run_project_tests_uses_script_test_when_present_and_passes() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let path = tmp.path();
let script_dir = path.join("script");
fs::create_dir_all(&script_dir).unwrap();
let script_test = script_dir.join("test");
fs::write(&script_test, "#!/usr/bin/env bash\necho 'all tests passed'\nexit 0\n").unwrap();
let mut perms = fs::metadata(&script_test).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&script_test, perms).unwrap();
let (passed, output) = run_project_tests(path).unwrap();
assert!(passed, "script/test exiting 0 should pass");
assert!(output.contains("script/test"), "output should mention script/test");
}
#[cfg(unix)]
#[test]
fn run_project_tests_reports_failure_when_script_test_exits_nonzero() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let path = tmp.path();
let script_dir = path.join("script");
fs::create_dir_all(&script_dir).unwrap();
let script_test = script_dir.join("test");
fs::write(&script_test, "#!/usr/bin/env bash\nexit 1\n").unwrap();
let mut perms = fs::metadata(&script_test).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&script_test, perms).unwrap();
let (passed, output) = run_project_tests(path).unwrap();
assert!(!passed, "script/test exiting 1 should fail");
assert!(output.contains("script/test"), "output should mention script/test");
}
}

View File

@@ -379,6 +379,8 @@ To support both Remote and Local models, the system implements a `ModelProvider`
* Shell commands that modify state (non-readonly) should ideally require a UI confirmation (configurable).
* File writes must be confirmed or revertible."#;
const STORY_KIT_SCRIPT_TEST: &str = "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's test commands here.\n# Story Kit agents invoke this script as the canonical test runner.\n# Exit 0 on success, non-zero on failure.\necho \"No tests configured\"\n";
/// Walk from `start` up through parent directories, returning the first
/// directory that contains a `.story_kit/` subdirectory, or `None`.
pub fn find_story_kit_root(start: &Path) -> Option<PathBuf> {
@@ -468,6 +470,24 @@ fn write_file_if_missing(path: &Path, content: &str) -> Result<(), String> {
Ok(())
}
/// Write `content` to `path` if missing, then ensure the file is executable.
fn write_script_if_missing(path: &Path, content: &str) -> Result<(), String> {
write_file_if_missing(path, content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)
.map_err(|e| format!("Failed to read permissions for {}: {}", path.display(), e))?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)
.map_err(|e| format!("Failed to set permissions on {}: {}", path.display(), e))?;
}
Ok(())
}
fn scaffold_story_kit(root: &Path) -> Result<(), String> {
let story_kit_root = root.join(".story_kit");
let specs_root = story_kit_root.join("specs");
@@ -475,17 +495,21 @@ fn scaffold_story_kit(root: &Path) -> Result<(), String> {
let functional_root = specs_root.join("functional");
let stories_root = story_kit_root.join("stories");
let archive_root = stories_root.join("archive");
let script_root = root.join("script");
fs::create_dir_all(&tech_root).map_err(|e| format!("Failed to create specs/tech: {}", e))?;
fs::create_dir_all(&functional_root)
.map_err(|e| format!("Failed to create specs/functional: {}", e))?;
fs::create_dir_all(&archive_root)
.map_err(|e| format!("Failed to create stories/archive: {}", e))?;
fs::create_dir_all(&script_root)
.map_err(|e| format!("Failed to create script/ directory: {}", e))?;
write_file_if_missing(&story_kit_root.join("README.md"), STORY_KIT_README)?;
write_file_if_missing(&specs_root.join("README.md"), STORY_KIT_SPECS_README)?;
write_file_if_missing(&specs_root.join("00_CONTEXT.md"), STORY_KIT_CONTEXT)?;
write_file_if_missing(&tech_root.join("STACK.md"), STORY_KIT_STACK)?;
write_script_if_missing(&script_root.join("test"), STORY_KIT_SCRIPT_TEST)?;
Ok(())
}
@@ -1027,6 +1051,24 @@ mod tests {
assert!(dir.path().join(".story_kit/specs/00_CONTEXT.md").exists());
assert!(dir.path().join(".story_kit/specs/tech/STACK.md").exists());
assert!(dir.path().join(".story_kit/stories/archive").is_dir());
assert!(dir.path().join("script/test").exists());
}
#[cfg(unix)]
#[test]
fn scaffold_story_kit_creates_executable_script_test() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
let script_test = dir.path().join("script/test");
assert!(script_test.exists(), "script/test should be created");
let perms = fs::metadata(&script_test).unwrap().permissions();
assert!(
perms.mode() & 0o111 != 0,
"script/test should be executable"
);
}
#[test]