huskies: merge 589_story_wizard_auto_detects_project_components_and_configures_scripts_accordingly
This commit is contained in:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user