story-kit: done 225_story_surface_merge_conflicts_and_failures_in_the_web_ui

This commit is contained in:
Dave
2026-02-27 16:41:20 +00:00
parent 1168917071
commit 7574e3b4bc
3 changed files with 299 additions and 17 deletions

View File

@@ -100,7 +100,7 @@ const STORY_KIT_CLAUDE_SETTINGS: &str = r#"{
}
"#;
const DEFAULT_PROJECT_TOML: &str = r#"[[agent]]
const DEFAULT_PROJECT_AGENTS_TOML: &str = r#"[[agent]]
name = "coder-1"
stage = "coder"
role = "Full-stack engineer. Implements features across all components."
@@ -131,6 +131,96 @@ prompt = "You are the mergemaster agent for story {{story_id}}. Call merge_agent
system_prompt = "You are the mergemaster agent. Trigger merge_agent_work via MCP and report results. Never manually move story files. Call report_merge_failure when merges fail."
"#;
/// Detect the tech stack from the project root and return TOML `[[component]]` entries.
///
/// Inspects well-known marker files at the project root to identify which
/// tech stacks are present, then emits one `[[component]]` entry per detected
/// stack with sensible default `setup` commands. If no markers are found, a
/// single fallback `app` component with an empty `setup` list is returned so
/// that the pipeline never breaks on an unknown stack.
pub fn detect_components_toml(root: &Path) -> String {
let mut sections = Vec::new();
if root.join("Cargo.toml").exists() {
sections.push(
"[[component]]\nname = \"server\"\npath = \".\"\nsetup = [\"cargo check\"]\n"
.to_string(),
);
}
if root.join("package.json").exists() {
let setup_cmd = if root.join("pnpm-lock.yaml").exists() {
"pnpm install"
} else {
"npm install"
};
sections.push(format!(
"[[component]]\nname = \"frontend\"\npath = \".\"\nsetup = [\"{setup_cmd}\"]\n"
));
}
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
sections.push(
"[[component]]\nname = \"python\"\npath = \".\"\nsetup = [\"pip install -r requirements.txt\"]\n"
.to_string(),
);
}
if root.join("go.mod").exists() {
sections.push(
"[[component]]\nname = \"go\"\npath = \".\"\nsetup = [\"go build ./...\"]\n"
.to_string(),
);
}
if root.join("Gemfile").exists() {
sections.push(
"[[component]]\nname = \"ruby\"\npath = \".\"\nsetup = [\"bundle install\"]\n"
.to_string(),
);
}
if sections.is_empty() {
// No tech stack markers detected — emit two example components so that
// the scaffold is immediately usable and agents can see the expected
// format. The ONBOARDING_PROMPT instructs the chat agent to inspect
// the project and replace these placeholders with real definitions.
sections.push(
"# EXAMPLE: Replace with your actual backend component.\n\
# Common patterns: \"cargo check\" (Rust), \"go build ./...\" (Go),\n\
# \"python -m pytest\" (Python), \"mvn verify\" (Java)\n\
[[component]]\n\
name = \"backend\"\n\
path = \".\"\n\
setup = [\"cargo check\"]\n\
teardown = []\n"
.to_string(),
);
sections.push(
"# EXAMPLE: Replace with your actual frontend component.\n\
# Common patterns: \"pnpm install\" (pnpm), \"npm install\" (npm),\n\
# \"yarn\" (Yarn), \"bun install\" (Bun)\n\
[[component]]\n\
name = \"frontend\"\n\
path = \".\"\n\
setup = [\"pnpm install\"]\n\
teardown = []\n"
.to_string(),
);
}
sections.join("\n")
}
/// Generate a complete `project.toml` for a new project at `root`.
///
/// Detects the tech stack via [`detect_components_toml`] and prepends the
/// resulting `[[component]]` entries before the default `[[agent]]` sections.
fn generate_project_toml(root: &Path) -> String {
let components = detect_components_toml(root);
format!("{components}\n{DEFAULT_PROJECT_AGENTS_TOML}")
}
/// Resolve a path argument supplied on the CLI against the given working
/// directory. Relative paths (including `.`) are joined with `cwd` and
/// then canonicalized when possible. Absolute paths are returned
@@ -297,7 +387,8 @@ fn scaffold_story_kit(root: &Path) -> Result<(), String> {
.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(&story_kit_root.join("project.toml"), DEFAULT_PROJECT_TOML)?;
let project_toml_content = generate_project_toml(root);
write_file_if_missing(&story_kit_root.join("project.toml"), &project_toml_content)?;
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)?;
@@ -1254,4 +1345,194 @@ mod tests {
// Path doesn't exist yet — canonicalize fails, fallback is cwd/newproject
assert_eq!(result, cwd.join("newproject"));
}
// --- detect_components_toml ---
#[test]
fn detect_no_markers_returns_fallback_components() {
let dir = tempdir().unwrap();
let toml = detect_components_toml(dir.path());
// At least one [[component]] entry should always be present
assert!(
toml.contains("[[component]]"),
"should always emit at least one component"
);
// The fallback should include example backend and frontend entries
assert!(
toml.contains("name = \"backend\"") || toml.contains("name = \"frontend\""),
"fallback should include example component entries"
);
}
#[test]
fn detect_cargo_toml_generates_rust_component() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"\n").unwrap();
let toml = detect_components_toml(dir.path());
assert!(toml.contains("name = \"server\""));
assert!(toml.contains("setup = [\"cargo check\"]"));
}
#[test]
fn detect_package_json_with_pnpm_lock_generates_pnpm_component() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("package.json"), "{}").unwrap();
fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap();
let toml = detect_components_toml(dir.path());
assert!(toml.contains("name = \"frontend\""));
assert!(toml.contains("setup = [\"pnpm install\"]"));
}
#[test]
fn detect_package_json_without_pnpm_lock_generates_npm_component() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("package.json"), "{}").unwrap();
let toml = detect_components_toml(dir.path());
assert!(toml.contains("name = \"frontend\""));
assert!(toml.contains("setup = [\"npm install\"]"));
}
#[test]
fn detect_pyproject_toml_generates_python_component() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("pyproject.toml"), "[project]\nname = \"test\"\n").unwrap();
let toml = detect_components_toml(dir.path());
assert!(toml.contains("name = \"python\""));
assert!(toml.contains("pip install"));
}
#[test]
fn detect_requirements_txt_generates_python_component() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap();
let toml = detect_components_toml(dir.path());
assert!(toml.contains("name = \"python\""));
assert!(toml.contains("pip install"));
}
#[test]
fn detect_go_mod_generates_go_component() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap();
let toml = detect_components_toml(dir.path());
assert!(toml.contains("name = \"go\""));
assert!(toml.contains("setup = [\"go build ./...\"]"));
}
#[test]
fn detect_gemfile_generates_ruby_component() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Gemfile"), "source \"https://rubygems.org\"\n").unwrap();
let toml = detect_components_toml(dir.path());
assert!(toml.contains("name = \"ruby\""));
assert!(toml.contains("setup = [\"bundle install\"]"));
}
#[test]
fn detect_multiple_markers_generates_multiple_components() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"server\"\n").unwrap();
fs::write(dir.path().join("package.json"), "{}").unwrap();
let toml = detect_components_toml(dir.path());
assert!(toml.contains("name = \"server\""));
assert!(toml.contains("name = \"frontend\""));
// Both component entries should be present
let component_count = toml.matches("[[component]]").count();
assert_eq!(component_count, 2);
}
#[test]
fn detect_no_fallback_when_markers_found() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
let toml = detect_components_toml(dir.path());
// The fallback "app" component should NOT appear when a real stack is detected
assert!(!toml.contains("name = \"app\""));
}
// --- generate_project_toml ---
#[test]
fn generate_project_toml_includes_both_components_and_agents() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
let toml = generate_project_toml(dir.path());
// Component section
assert!(toml.contains("[[component]]"));
assert!(toml.contains("name = \"server\""));
// Agent sections
assert!(toml.contains("[[agent]]"));
assert!(toml.contains("stage = \"coder\""));
assert!(toml.contains("stage = \"qa\""));
assert!(toml.contains("stage = \"mergemaster\""));
}
#[test]
fn scaffold_project_toml_contains_detected_components() {
let dir = tempdir().unwrap();
// Place a Cargo.toml in the project root before scaffolding
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"myapp\"\n").unwrap();
scaffold_story_kit(dir.path()).unwrap();
let content =
fs::read_to_string(dir.path().join(".story_kit/project.toml")).unwrap();
assert!(
content.contains("[[component]]"),
"project.toml should contain a component entry"
);
assert!(
content.contains("name = \"server\""),
"Rust project should have a 'server' component"
);
assert!(
content.contains("cargo check"),
"Rust component should have cargo check setup"
);
}
#[test]
fn scaffold_project_toml_fallback_when_no_stack_detected() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
let content =
fs::read_to_string(dir.path().join(".story_kit/project.toml")).unwrap();
assert!(
content.contains("[[component]]"),
"project.toml should always have at least one component"
);
// Fallback emits example components so the scaffold is immediately usable
assert!(
content.contains("name = \"backend\"") || content.contains("name = \"frontend\""),
"fallback should include example component entries"
);
}
#[test]
fn scaffold_does_not_overwrite_existing_project_toml_with_components() {
let dir = tempdir().unwrap();
let sk_dir = dir.path().join(".story_kit");
fs::create_dir_all(&sk_dir).unwrap();
let existing = "[[component]]\nname = \"custom\"\npath = \".\"\nsetup = [\"make build\"]\n";
fs::write(sk_dir.join("project.toml"), existing).unwrap();
scaffold_story_kit(dir.path()).unwrap();
let content = fs::read_to_string(sk_dir.join("project.toml")).unwrap();
assert_eq!(
content, existing,
"scaffold should not overwrite existing project.toml"
);
}
}