From 9d85ddf35b9d2a6687e38955d90c2056c86af091 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Tue, 2 Jun 2026 22:26:00 +0000 Subject: [PATCH] fix(hm): pipelines/render prefer Python + empty registry for pipeline-less repos Two fixes for backend pipeline discovery, surfaced running the hm-based render sandbox in prod: 1. A repo with no .harmont/ (or no .py/.ts files) now yields the empty discovery envelope {"pipelines":[]} instead of erroring. The backend fans discovery across every repo in an installation, most of which declare no pipelines; those must be a no-op, not a render_failed retry loop. New detect::has_pipeline_files + EMPTY_ENVELOPE short-circuit. 2. hm pipelines / hm render now prefer Python when a repo carries BOTH .py and .ts (new detect::detect_language_python_first). The discovery envelope is Python-only today, so a repo with a redundant .ts (e.g. harmont-cli's own ci.ts) resolved to the unsupported TS registry and bailed. hm run keeps the TypeScript-preferring detect_language, so the local-run default is unchanged. --- crates/hm-dsl-engine/src/detect.rs | 109 +++++++++++++++++++++++++---- crates/hm/src/cli/pipelines.rs | 23 +++++- crates/hm/src/cli/render.rs | 6 +- crates/hm/tests/cmd_pipelines.rs | 23 ++++++ 4 files changed, 144 insertions(+), 17 deletions(-) diff --git a/crates/hm-dsl-engine/src/detect.rs b/crates/hm-dsl-engine/src/detect.rs index ccef272f..2d49fa2e 100644 --- a/crates/hm-dsl-engine/src/detect.rs +++ b/crates/hm-dsl-engine/src/detect.rs @@ -5,43 +5,83 @@ use anyhow::{Context, bail}; use crate::DslLanguage; /// Detect the DSL language used in a project by scanning `.harmont/` for file -/// extensions. +/// extensions. Prefers **TypeScript** when both are present (the `hm run` +/// default). /// /// # Errors /// /// - The `.harmont/` directory does not exist. /// - No `.py` or `.ts` files are found inside `.harmont/`. -/// - Both `.py` and `.ts` files are present (mixed languages). pub fn detect_language(repo_root: &Path) -> anyhow::Result { let harmont_dir = repo_root.join(".harmont"); + if !harmont_dir.is_dir() { + bail!("no .harmont/ directory found in {}", repo_root.display()); + } + match scan_extensions(repo_root)? { + // When both languages are present, prefer TypeScript. + (_, true) => Ok(DslLanguage::TypeScript), + (true, false) => Ok(DslLanguage::Python), + (false, false) => bail!("no .py or .ts files found in {}", harmont_dir.display()), + } +} +/// Like [`detect_language`] but prefers **Python** when both are present. +/// +/// Used by the machine-facing `hm pipelines` / `hm render` commands that the +/// backend shells out to: the Python path is the fully-supported one (the +/// discovery envelope is Python-only today), so a repo carrying both a `.py` +/// and a redundant `.ts` resolves to Python rather than the unsupported TS +/// registry. `hm run` keeps the TypeScript-preferring [`detect_language`]. +/// +/// # Errors +/// +/// - The `.harmont/` directory does not exist. +/// - No `.py` or `.ts` files are found inside `.harmont/`. +pub fn detect_language_python_first(repo_root: &Path) -> anyhow::Result { + let harmont_dir = repo_root.join(".harmont"); if !harmont_dir.is_dir() { bail!("no .harmont/ directory found in {}", repo_root.display()); } + match scan_extensions(repo_root)? { + (true, _) => Ok(DslLanguage::Python), + (false, true) => Ok(DslLanguage::TypeScript), + (false, false) => bail!("no .py or .ts files found in {}", harmont_dir.display()), + } +} + +/// True when `.harmont/` exists and holds at least one `.py` or `.ts` file. +/// +/// The backend fans pipeline discovery out across every repo in an +/// installation, most of which declare no pipelines at all. Those repos should +/// yield an empty registry, not an error — callers use this to short-circuit to +/// an empty envelope instead of calling [`detect_language_python_first`]. +#[must_use] +pub fn has_pipeline_files(repo_root: &Path) -> bool { + matches!(scan_extensions(repo_root), Ok((py, ts)) if py || ts) +} + +/// Scan `.harmont/` and report `(has_py, has_ts)`. A missing `.harmont/` +/// directory yields `(false, false)`; an unreadable one is an error. +fn scan_extensions(repo_root: &Path) -> anyhow::Result<(bool, bool)> { + let harmont_dir = repo_root.join(".harmont"); + if !harmont_dir.is_dir() { + return Ok((false, false)); + } let entries = std::fs::read_dir(&harmont_dir) .with_context(|| format!("failed to read {}", harmont_dir.display()))?; let mut has_py = false; let mut has_ts = false; - for entry in entries { let entry = entry?; - let path = entry.path(); - - match path.extension().and_then(|e| e.to_str()) { + match entry.path().extension().and_then(|e| e.to_str()) { Some("py") => has_py = true, Some("ts") => has_ts = true, _ => {} } } - - match (has_py, has_ts) { - // When both languages are present, prefer TypeScript. - (_, true) => Ok(DslLanguage::TypeScript), - (true, false) => Ok(DslLanguage::Python), - (false, false) => bail!("no .py or .ts files found in {}", harmont_dir.display()), - } + Ok((has_py, has_ts)) } #[cfg(test)] @@ -107,4 +147,47 @@ mod tests { "unexpected error: {msg}" ); } + + #[test] + fn python_first_prefers_python_when_mixed() { + let tmp = setup(&["ci.py", "deploy.ts"]); + assert_eq!( + detect_language_python_first(tmp.path()).unwrap(), + DslLanguage::Python + ); + } + + #[test] + fn python_first_falls_back_to_typescript_when_only_ts() { + let tmp = setup(&["ci.ts"]); + assert_eq!( + detect_language_python_first(tmp.path()).unwrap(), + DslLanguage::TypeScript + ); + } + + #[test] + fn python_first_no_harmont_dir_is_error() { + let tmp = TempDir::new().unwrap(); + let err = detect_language_python_first(tmp.path()).unwrap_err(); + assert!( + err.to_string().contains("no .harmont/ directory"), + "unexpected error: {err}" + ); + } + + #[test] + fn has_pipeline_files_true_for_py_and_ts() { + assert!(has_pipeline_files(setup(&["ci.py"]).path())); + assert!(has_pipeline_files(setup(&["ci.ts"]).path())); + assert!(has_pipeline_files(setup(&["ci.py", "deploy.ts"]).path())); + } + + #[test] + fn has_pipeline_files_false_for_missing_or_empty_harmont() { + // No .harmont/ directory at all. + assert!(!has_pipeline_files(TempDir::new().unwrap().path())); + // .harmont/ exists but declares no .py/.ts files. + assert!(!has_pipeline_files(setup(&["README.md"]).path())); + } } diff --git a/crates/hm/src/cli/pipelines.rs b/crates/hm/src/cli/pipelines.rs index acc2c971..02f25d7f 100644 --- a/crates/hm/src/cli/pipelines.rs +++ b/crates/hm/src/cli/pipelines.rs @@ -11,19 +11,36 @@ pub struct PipelinesArgs { pub dir: Option, } +/// Empty discovery envelope, emitted when a repo declares no pipelines. Mirrors +/// the shape of `harmont.dump_registry_json()` so backend discovery parses it +/// the same way (it reads only the `pipelines` array). +const EMPTY_ENVELOPE: &str = r#"{"schema_version":"1","pipelines":[]}"#; + /// Print the discovery envelope JSON (all pipelines) to stdout. /// +/// A repo with no `.harmont/` directory (or one with no `.py`/`.ts` files) +/// declares no pipelines and yields the empty envelope rather than an error — +/// the backend fans discovery out across every repo in an installation, most of +/// which carry no pipelines. When both Python and TypeScript are present, Python +/// wins (the registry dump is Python-only today). +/// /// # Errors /// -/// Returns an error if the language can't be detected, the engine can't start, -/// or the DSL runtime fails to evaluate the pipelines. +/// Returns an error if the engine can't start or the DSL runtime fails to +/// evaluate the pipelines. pub async fn run(args: PipelinesArgs) -> Result<()> { let repo_root = match args.dir { Some(d) => d, None => std::env::current_dir().context("cannot determine current directory")?, }; - let lang = detect::detect_language(&repo_root).context("detecting pipeline language")?; + if !detect::has_pipeline_files(&repo_root) { + print!("{EMPTY_ENVELOPE}"); + return Ok(()); + } + + let lang = + detect::detect_language_python_first(&repo_root).context("detecting pipeline language")?; let engine = engine_for(lang).context("initializing DSL engine")?; let json = engine .registry_json(&repo_root) diff --git a/crates/hm/src/cli/render.rs b/crates/hm/src/cli/render.rs index 4a7d6202..d295cca0 100644 --- a/crates/hm/src/cli/render.rs +++ b/crates/hm/src/cli/render.rs @@ -17,6 +17,9 @@ pub struct RenderArgs { /// Render one pipeline's v0 IR JSON to stdout without executing it. /// +/// When both Python and TypeScript are present, Python wins (the supported +/// backend path), matching `hm pipelines`. +/// /// # Errors /// /// Returns an error if the language can't be detected, the engine can't start, @@ -28,7 +31,8 @@ pub async fn run(args: RenderArgs) -> Result<()> { None => std::env::current_dir().context("cannot determine current directory")?, }; - let lang = detect::detect_language(&repo_root).context("detecting pipeline language")?; + let lang = + detect::detect_language_python_first(&repo_root).context("detecting pipeline language")?; let engine = engine_for(lang).context("initializing DSL engine")?; let json = engine .render_pipeline_json(&repo_root, &args.slug) diff --git a/crates/hm/tests/cmd_pipelines.rs b/crates/hm/tests/cmd_pipelines.rs index 5e282575..c579a5ee 100644 --- a/crates/hm/tests/cmd_pipelines.rs +++ b/crates/hm/tests/cmd_pipelines.rs @@ -45,3 +45,26 @@ def ci() -> hm.Step: assert_eq!(v["pipelines"][0]["slug"], "ci"); assert_eq!(v["pipelines"][0]["triggers"][0]["event"], "push"); } + +#[test] +fn pipelines_emits_empty_envelope_when_no_harmont_dir() { + // A repo that declares no pipelines must yield an empty envelope, not an + // error (the backend fans discovery across every repo in an installation, + // most of which carry no `.harmont/`). No python3 needed — this short- + // circuits before the DSL engine, so the test always runs. + let dir = tempfile::tempdir().unwrap(); + + let out = Command::cargo_bin("hm") + .unwrap() + .arg("pipelines") + .arg("--dir") + .arg(dir.path()) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(v["pipelines"], serde_json::json!([])); +}