story-kit: done 225_story_surface_merge_conflicts_and_failures_in_the_web_ui
This commit is contained in:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user