huskies: merge 589_story_wizard_auto_detects_project_components_and_configures_scripts_accordingly

This commit is contained in:
dave
2026-04-16 00:18:42 +00:00
parent 61502f51d9
commit df2f20a5e5
4 changed files with 643 additions and 8 deletions
+478
View File
@@ -223,6 +223,139 @@ fn detect_node_test_cmd(pkg_dir: &Path) -> String {
}
}
/// Detect the appropriate Node.js build command for a directory containing `package.json`.
fn detect_node_build_cmd(pkg_dir: &Path) -> String {
if pkg_dir.join("pnpm-lock.yaml").exists() {
"pnpm run build".to_string()
} else {
"npm run build".to_string()
}
}
/// Detect the appropriate Node.js lint command for a directory containing `package.json`.
///
/// Reads the `package.json` content to identify eslint. Falls back to
/// `npm run lint` or `pnpm run lint` based on which lock file is present.
fn detect_node_lint_cmd(pkg_dir: &Path) -> String {
let has_pnpm = pkg_dir.join("pnpm-lock.yaml").exists();
let content = std::fs::read_to_string(pkg_dir.join("package.json")).unwrap_or_default();
if content.contains("\"eslint\"") {
let pm = if has_pnpm { "pnpm" } else { "npx" };
return format!("{pm} eslint .");
}
if has_pnpm {
"pnpm run lint".to_string()
} else {
"npm run lint".to_string()
}
}
/// Generate `script/build` content for a new project at `root`.
///
/// Inspects well-known marker files to identify which tech stacks are present
/// and emits the appropriate build commands. Multi-stack projects get combined
/// commands run sequentially. Falls back to a generic stub when no markers
/// are found so the scaffold is always valid.
///
/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`),
/// the build command is detected from the presence of `pnpm-lock.yaml`.
pub fn detect_script_build(root: &Path) -> String {
let mut commands: Vec<String> = Vec::new();
if root.join("Cargo.toml").exists() {
commands.push("cargo build --release".to_string());
}
if root.join("package.json").exists() {
commands.push(detect_node_build_cmd(root));
}
// Detect frontend in known subdirectories (e.g. frontend/, client/)
for subdir in &["frontend", "client"] {
let sub_path = root.join(subdir);
if sub_path.join("package.json").exists() {
let cmd = detect_node_build_cmd(&sub_path);
commands.push(format!("(cd {} && {})", subdir, cmd));
}
}
if root.join("pyproject.toml").exists() {
commands.push("python -m build".to_string());
}
if root.join("go.mod").exists() {
commands.push("go build ./...".to_string());
}
if commands.is_empty() {
return "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's build commands here.\necho \"No build configured\"\n".to_string();
}
let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string();
for cmd in commands {
script.push_str(&cmd);
script.push('\n');
}
script
}
/// Generate `script/lint` content for a new project at `root`.
///
/// Inspects well-known marker files to identify which linters are present
/// and emits the appropriate lint commands. Multi-stack projects get combined
/// commands run sequentially. Falls back to a generic stub when no markers
/// are found so the scaffold is always valid.
///
/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`),
/// the lint command is detected from the `package.json` (eslint, npm, pnpm).
pub fn detect_script_lint(root: &Path) -> String {
let mut commands: Vec<String> = Vec::new();
if root.join("Cargo.toml").exists() {
commands.push("cargo fmt --all --check".to_string());
commands.push("cargo clippy -- -D warnings".to_string());
}
if root.join("package.json").exists() {
commands.push(detect_node_lint_cmd(root));
}
// Detect frontend in known subdirectories (e.g. frontend/, client/)
for subdir in &["frontend", "client"] {
let sub_path = root.join(subdir);
if sub_path.join("package.json").exists() {
let cmd = detect_node_lint_cmd(&sub_path);
commands.push(format!("(cd {} && {})", subdir, cmd));
}
}
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
let mut content = std::fs::read_to_string(root.join("pyproject.toml")).unwrap_or_default();
content
.push_str(&std::fs::read_to_string(root.join("requirements.txt")).unwrap_or_default());
if content.contains("ruff") {
commands.push("ruff check .".to_string());
} else {
commands.push("flake8 .".to_string());
}
}
if root.join("go.mod").exists() {
commands.push("go vet ./...".to_string());
}
if commands.is_empty() {
return "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's lint commands here.\necho \"No linters configured\"\n".to_string();
}
let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string();
for cmd in commands {
script.push_str(&cmd);
script.push('\n');
}
script
}
/// Generate `script/test` content for a new project at `root`.
///
/// Inspects well-known marker files to identify which tech stacks are present
@@ -449,6 +582,10 @@ pub(crate) fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> {
write_file_if_missing(&tech_root.join("STACK.md"), STORY_KIT_STACK)?;
let script_test_content = detect_script_test(root);
write_script_if_missing(&script_root.join("test"), &script_test_content)?;
let script_build_content = detect_script_build(root);
write_script_if_missing(&script_root.join("build"), &script_build_content)?;
let script_lint_content = detect_script_lint(root);
write_script_if_missing(&script_root.join("lint"), &script_lint_content)?;
write_file_if_missing(&root.join("CLAUDE.md"), STORY_KIT_CLAUDE_MD)?;
// Write per-transport bot.toml example files so users can see all options.
@@ -1387,6 +1524,347 @@ mod tests {
);
}
// --- detect_script_build ---
#[test]
fn detect_script_build_no_markers_returns_stub() {
let dir = tempdir().unwrap();
let script = detect_script_build(dir.path());
assert!(
script.contains("No build configured"),
"fallback should contain the generic stub message"
);
assert!(script.starts_with("#!/usr/bin/env bash"));
}
#[test]
fn detect_script_build_cargo_toml_adds_cargo_build_release() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
let script = detect_script_build(dir.path());
assert!(
script.contains("cargo build --release"),
"Rust project should run cargo build --release"
);
assert!(!script.contains("No build configured"));
}
#[test]
fn detect_script_build_package_json_npm_adds_npm_run_build() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("package.json"), "{}").unwrap();
let script = detect_script_build(dir.path());
assert!(
script.contains("npm run build"),
"Node project without pnpm-lock should run npm run build"
);
}
#[test]
fn detect_script_build_package_json_pnpm_adds_pnpm_run_build() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("package.json"), "{}").unwrap();
fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap();
let script = detect_script_build(dir.path());
assert!(
script.contains("pnpm run build"),
"Node project with pnpm-lock should run pnpm run build"
);
assert!(
!script.lines().any(|l| l.trim() == "npm run build"),
"should not use npm when pnpm-lock.yaml is present"
);
}
#[test]
fn detect_script_build_go_mod_adds_go_build() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap();
let script = detect_script_build(dir.path());
assert!(
script.contains("go build ./..."),
"Go project should run go build ./..."
);
}
#[test]
fn detect_script_build_pyproject_toml_adds_python_build() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("pyproject.toml"),
"[project]\nname = \"x\"\n",
)
.unwrap();
let script = detect_script_build(dir.path());
assert!(
script.contains("python -m build"),
"Python project should run python -m build"
);
}
#[test]
fn detect_script_build_frontend_subdir_detected() {
let dir = tempdir().unwrap();
let frontend = dir.path().join("frontend");
fs::create_dir_all(&frontend).unwrap();
fs::write(frontend.join("package.json"), "{}").unwrap();
let script = detect_script_build(dir.path());
assert!(
script.contains("cd frontend"),
"frontend subdir should be detected for build"
);
assert!(script.contains("npm run build"));
}
#[test]
fn detect_script_build_rust_plus_frontend_subdir_both_included() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"server\"\n",
)
.unwrap();
let frontend = dir.path().join("frontend");
fs::create_dir_all(&frontend).unwrap();
fs::write(frontend.join("package.json"), "{}").unwrap();
let script = detect_script_build(dir.path());
assert!(script.contains("cargo build --release"));
assert!(script.contains("cd frontend"));
assert!(script.contains("npm run build"));
}
// --- detect_script_lint ---
#[test]
fn detect_script_lint_no_markers_returns_stub() {
let dir = tempdir().unwrap();
let script = detect_script_lint(dir.path());
assert!(
script.contains("No linters configured"),
"fallback should contain the generic stub message"
);
assert!(script.starts_with("#!/usr/bin/env bash"));
}
#[test]
fn detect_script_lint_cargo_toml_adds_fmt_and_clippy() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
let script = detect_script_lint(dir.path());
assert!(
script.contains("cargo fmt --all --check"),
"Rust project should check formatting"
);
assert!(
script.contains("cargo clippy -- -D warnings"),
"Rust project should run clippy"
);
assert!(!script.contains("No linters configured"));
}
#[test]
fn detect_script_lint_package_json_without_eslint_uses_npm_run_lint() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("package.json"), "{}").unwrap();
let script = detect_script_lint(dir.path());
assert!(
script.contains("npm run lint"),
"Node project without eslint dep should fall back to npm run lint"
);
}
#[test]
fn detect_script_lint_package_json_with_eslint_uses_npx_eslint() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("package.json"),
r#"{"devDependencies":{"eslint":"^8.0.0"}}"#,
)
.unwrap();
let script = detect_script_lint(dir.path());
assert!(
script.contains("npx eslint ."),
"Node project with eslint should use npx eslint ."
);
}
#[test]
fn detect_script_lint_pnpm_with_eslint_uses_pnpm_eslint() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("package.json"),
r#"{"devDependencies":{"eslint":"^8.0.0"}}"#,
)
.unwrap();
fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap();
let script = detect_script_lint(dir.path());
assert!(
script.contains("pnpm eslint ."),
"pnpm project with eslint should use pnpm eslint ."
);
}
#[test]
fn detect_script_lint_python_requirements_uses_flake8() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap();
let script = detect_script_lint(dir.path());
assert!(
script.contains("flake8 ."),
"Python project without ruff should use flake8"
);
}
#[test]
fn detect_script_lint_python_with_ruff_uses_ruff() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("pyproject.toml"),
"[project]\nname = \"x\"\n\n[tool.ruff]\n",
)
.unwrap();
let script = detect_script_lint(dir.path());
assert!(
script.contains("ruff check ."),
"Python project with ruff configured should use ruff"
);
assert!(
!script.contains("flake8"),
"should not use flake8 when ruff is configured"
);
}
#[test]
fn detect_script_lint_go_mod_adds_go_vet() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap();
let script = detect_script_lint(dir.path());
assert!(
script.contains("go vet ./..."),
"Go project should run go vet ./..."
);
}
#[test]
fn detect_script_lint_frontend_subdir_detected() {
let dir = tempdir().unwrap();
let frontend = dir.path().join("frontend");
fs::create_dir_all(&frontend).unwrap();
fs::write(frontend.join("package.json"), "{}").unwrap();
let script = detect_script_lint(dir.path());
assert!(
script.contains("cd frontend"),
"frontend subdir should be detected for lint"
);
}
#[test]
fn detect_script_lint_rust_plus_frontend_subdir_both_included() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"server\"\n",
)
.unwrap();
let frontend = dir.path().join("frontend");
fs::create_dir_all(&frontend).unwrap();
fs::write(frontend.join("package.json"), "{}").unwrap();
let script = detect_script_lint(dir.path());
assert!(script.contains("cargo fmt --all --check"));
assert!(script.contains("cargo clippy -- -D warnings"));
assert!(script.contains("cd frontend"));
}
#[test]
fn scaffold_story_kit_creates_script_build_and_lint() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
assert!(
dir.path().join("script/build").exists(),
"script/build should be created by scaffold"
);
assert!(
dir.path().join("script/lint").exists(),
"script/lint should be created by scaffold"
);
}
#[cfg(unix)]
#[test]
fn scaffold_story_kit_creates_executable_script_build_and_lint() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
for name in &["build", "lint"] {
let path = dir.path().join("script").join(name);
assert!(path.exists(), "script/{name} should be created");
let perms = fs::metadata(&path).unwrap().permissions();
assert!(
perms.mode() & 0o111 != 0,
"script/{name} should be executable"
);
}
}
#[test]
fn scaffold_script_build_contains_detected_commands_for_rust() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"myapp\"\n",
)
.unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join("script/build")).unwrap();
assert!(
content.contains("cargo build --release"),
"Rust project scaffold should set cargo build --release in script/build"
);
}
#[test]
fn scaffold_script_lint_contains_detected_commands_for_rust() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"myapp\"\n",
)
.unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join("script/lint")).unwrap();
assert!(
content.contains("cargo fmt --all --check"),
"Rust project scaffold should include fmt check in script/lint"
);
assert!(
content.contains("cargo clippy -- -D warnings"),
"Rust project scaffold should include clippy in script/lint"
);
}
// --- generate_project_toml ---
#[test]
+11 -3
View File
@@ -16,9 +16,13 @@ pub enum WizardStep {
Stack,
/// Step 4: create script/test
TestScript,
/// Step 5: create script/release
/// Step 5: create script/build
BuildScript,
/// Step 6: create script/lint
LintScript,
/// Step 7: create script/release
ReleaseScript,
/// Step 6: create script/test_coverage
/// Step 8: create script/test_coverage
TestCoverage,
}
@@ -29,6 +33,8 @@ impl WizardStep {
WizardStep::Context,
WizardStep::Stack,
WizardStep::TestScript,
WizardStep::BuildScript,
WizardStep::LintScript,
WizardStep::ReleaseScript,
WizardStep::TestCoverage,
];
@@ -40,6 +46,8 @@ impl WizardStep {
WizardStep::Context => "Generate project context (00_CONTEXT.md)",
WizardStep::Stack => "Generate tech stack spec (STACK.md)",
WizardStep::TestScript => "Create test script (script/test)",
WizardStep::BuildScript => "Create build script (script/build)",
WizardStep::LintScript => "Create lint script (script/lint)",
WizardStep::ReleaseScript => "Create release script (script/release)",
WizardStep::TestCoverage => "Create test coverage script (script/test_coverage)",
}
@@ -262,7 +270,7 @@ mod tests {
#[test]
fn default_state_has_all_steps_pending() {
let state = WizardState::default();
assert_eq!(state.steps.len(), 6);
assert_eq!(state.steps.len(), 8);
for step in &state.steps {
assert_eq!(step.status, StepStatus::Pending);
}