Skip to content

Commit 58e895a

Browse files
committed
restore TS example pipelines + prefer-TS dual-language detection
1 parent 806c065 commit 58e895a

17 files changed

Lines changed: 373 additions & 39 deletions

File tree

crates/hm-dsl-engine/src/detect.rs

Lines changed: 68 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,39 +5,53 @@ use anyhow::{Context, bail};
55
use crate::DslLanguage;
66

77
/// Detect the DSL language used in a project by scanning `.hm/` for file
8-
/// extensions.
9-
///
10-
/// A repo carrying **both** a `.py` and a `.ts` pipeline is rejected rather
11-
/// than silently tie-broken: `hm run` (local) and the backend discovery path
12-
/// (`hm pipelines` / `hm render`) both route through this one resolver, so a
13-
/// tie-break here would let the local build run one language while cloud
14-
/// discovery built the other. Failing fast keeps the two in lockstep. Keep a
15-
/// single pipeline language per `.hm/` directory.
8+
/// extensions. Prefers **TypeScript** when both are present (the `hm run`
9+
/// default).
1610
///
1711
/// # Errors
1812
///
1913
/// - The `.hm/` directory does not exist.
2014
/// - No `.py` or `.ts` files are found inside `.hm/`.
21-
/// - Both `.py` and `.ts` files are present (ambiguous language).
2215
pub fn detect_language(repo_root: &Path) -> anyhow::Result<DslLanguage> {
2316
let harmont_dir = repo_root.join(".hm");
2417
if !harmont_dir.is_dir() {
2518
bail!("no .hm/ directory found in {}", repo_root.display());
2619
}
2720
let langs = scan_extensions(repo_root)?;
28-
match (langs.has_py, langs.has_ts) {
29-
(true, true) => bail!(
30-
"ambiguous pipeline language in {dir}: found both Python (.py) and \
31-
TypeScript (.ts) pipeline files\n \
32-
→ keep exactly one pipeline language per .hm/ directory; remove the \
33-
extra .py or .ts files\n \
34-
(otherwise `hm run` and cloud discovery could resolve different \
35-
languages for the same repo)",
36-
dir = harmont_dir.display()
37-
),
38-
(false, true) => Ok(DslLanguage::TypeScript),
39-
(true, false) => Ok(DslLanguage::Python),
40-
(false, false) => bail!("no .py or .ts files found in {}", harmont_dir.display()),
21+
if langs.has_ts {
22+
// When both languages are present, prefer TypeScript.
23+
Ok(DslLanguage::TypeScript)
24+
} else if langs.has_py {
25+
Ok(DslLanguage::Python)
26+
} else {
27+
bail!("no .py or .ts files found in {}", harmont_dir.display())
28+
}
29+
}
30+
31+
/// Like [`detect_language`] but prefers **Python** when both are present.
32+
///
33+
/// Used by the machine-facing `hm pipelines` / `hm render` commands that the
34+
/// backend shells out to: the Python path is the fully-supported one (the
35+
/// discovery envelope is Python-only today), so a repo carrying both a `.py`
36+
/// and a redundant `.ts` resolves to Python rather than the unsupported TS
37+
/// registry. `hm run` keeps the TypeScript-preferring [`detect_language`].
38+
///
39+
/// # Errors
40+
///
41+
/// - The `.hm/` directory does not exist.
42+
/// - No `.py` or `.ts` files are found inside `.hm/`.
43+
pub fn detect_language_python_first(repo_root: &Path) -> anyhow::Result<DslLanguage> {
44+
let harmont_dir = repo_root.join(".hm");
45+
if !harmont_dir.is_dir() {
46+
bail!("no .hm/ directory found in {}", repo_root.display());
47+
}
48+
let langs = scan_extensions(repo_root)?;
49+
if langs.has_py {
50+
Ok(DslLanguage::Python)
51+
} else if langs.has_ts {
52+
Ok(DslLanguage::TypeScript)
53+
} else {
54+
bail!("no .py or .ts files found in {}", harmont_dir.display())
4155
}
4256
}
4357

@@ -46,7 +60,7 @@ pub fn detect_language(repo_root: &Path) -> anyhow::Result<DslLanguage> {
4660
/// The backend fans pipeline discovery out across every repo in an
4761
/// installation, most of which declare no pipelines at all. Those repos should
4862
/// yield an empty registry, not an error — callers use this to short-circuit to
49-
/// an empty envelope instead of calling [`detect_language`].
63+
/// an empty envelope instead of calling [`detect_language_python_first`].
5064
#[must_use]
5165
pub fn has_pipeline_files(repo_root: &Path) -> bool {
5266
matches!(scan_extensions(repo_root), Ok(langs) if langs.has_py || langs.has_ts)
@@ -120,18 +134,10 @@ mod tests {
120134
}
121135

122136
#[test]
123-
fn mixed_languages_is_ambiguous_error() {
124-
// A repo declaring both .py and .ts must fail loudly rather than
125-
// silently tie-break — otherwise local `hm run` and cloud discovery
126-
// could resolve different languages for the same repo.
137+
fn mixed_languages_prefers_typescript() {
127138
let tmp = setup(&["ci.py", "deploy.ts"]);
128-
let err = detect_language(tmp.path()).unwrap_err();
129-
let msg = err.to_string();
130-
assert!(
131-
msg.contains("ambiguous pipeline language"),
132-
"unexpected error: {msg}"
133-
);
134-
assert!(msg.contains(".py") && msg.contains(".ts"), "msg: {msg}");
139+
let lang = detect_language(tmp.path()).unwrap();
140+
assert_eq!(lang, DslLanguage::TypeScript);
135141
}
136142

137143
#[test]
@@ -155,6 +161,34 @@ mod tests {
155161
);
156162
}
157163

164+
#[test]
165+
fn python_first_prefers_python_when_mixed() {
166+
let tmp = setup(&["ci.py", "deploy.ts"]);
167+
assert_eq!(
168+
detect_language_python_first(tmp.path()).unwrap(),
169+
DslLanguage::Python
170+
);
171+
}
172+
173+
#[test]
174+
fn python_first_falls_back_to_typescript_when_only_ts() {
175+
let tmp = setup(&["ci.ts"]);
176+
assert_eq!(
177+
detect_language_python_first(tmp.path()).unwrap(),
178+
DslLanguage::TypeScript
179+
);
180+
}
181+
182+
#[test]
183+
fn python_first_no_harmont_dir_is_error() {
184+
let tmp = TempDir::new().unwrap();
185+
let err = detect_language_python_first(tmp.path()).unwrap_err();
186+
assert!(
187+
err.to_string().contains("no .hm/ directory"),
188+
"unexpected error: {err}"
189+
);
190+
}
191+
158192
#[test]
159193
fn has_pipeline_files_true_for_py_and_ts() {
160194
assert!(has_pipeline_files(setup(&["ci.py"]).path()));

crates/hm/src/cli/pipelines.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ const EMPTY_ENVELOPE: &str = r#"{"schema_version":"1","pipelines":[]}"#;
2222
/// declares no pipelines and yields the empty envelope rather than an error —
2323
/// the backend fans discovery out across every repo in an installation, most of
2424
/// which carry no pipelines. Both Python and TypeScript pipelines emit the same
25-
/// discovery envelope; a repo declaring both languages is rejected by
26-
/// [`detect::detect_language`] so local `hm run` and cloud discovery can never
27-
/// resolve different languages.
25+
/// discovery envelope; a repo declaring both languages resolves to Python via
26+
/// [`detect::detect_language_python_first`] (the fully-supported backend path),
27+
/// matching `hm render`.
2828
///
2929
/// # Errors
3030
///
@@ -41,7 +41,8 @@ pub async fn run(args: PipelinesArgs) -> Result<()> {
4141
return Ok(());
4242
}
4343

44-
let lang = detect::detect_language(&repo_root).context("detecting pipeline language")?;
44+
let lang =
45+
detect::detect_language_python_first(&repo_root).context("detecting pipeline language")?;
4546
let engine = engine_for(lang).context("initializing DSL engine")?;
4647
let json = engine
4748
.registry_json(&repo_root)

crates/hm/src/cli/render.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ pub async fn run(args: RenderArgs) -> Result<()> {
3131
None => std::env::current_dir().context("cannot determine current directory")?,
3232
};
3333

34-
let lang = detect::detect_language(&repo_root).context("detecting pipeline language")?;
34+
let lang =
35+
detect::detect_language_python_first(&repo_root).context("detecting pipeline language")?;
3536
let engine = engine_for(lang).context("initializing DSL engine")?;
3637
let json = engine
3738
.render_pipeline_json(&repo_root, &args.slug)

examples/bun/.hm/pipeline.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { pipeline, push, type PipelineDefinition } from "@harmont/hm";
2+
import { js } from "@harmont/hm/toolchains";
3+
4+
const project = js.project({ runtime: "bun" });
5+
6+
const pipelines: PipelineDefinition[] = [
7+
{
8+
slug: "ci",
9+
triggers: [push({ branch: "main" })],
10+
pipeline: pipeline([project.run("build"), project.run("test"), project.run("lint")], {
11+
env: { CI: "true" },
12+
defaultImage: "ubuntu:24.04",
13+
}),
14+
},
15+
];
16+
17+
export default pipelines;

examples/c/.hm/pipeline.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { pipeline, push, type PipelineDefinition } from "@harmont/hm";
2+
import { cmake } from "@harmont/hm/toolchains";
3+
4+
const project = cmake({ path: ".", defines: { CMAKE_BUILD_TYPE: "Release" } });
5+
6+
const pipelines: PipelineDefinition[] = [
7+
{
8+
slug: "ci",
9+
triggers: [push({ branch: "main" })],
10+
pipeline: pipeline([project.test(), project.fmt()], {
11+
env: { CI: "true" },
12+
defaultImage: "ubuntu:24.04",
13+
}),
14+
},
15+
];
16+
17+
export default pipelines;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { pipeline, push, pullRequest, type PipelineDefinition } from "@harmont/hm";
2+
import { cmake } from "@harmont/hm/toolchains";
3+
4+
const project = cmake({
5+
path: ".",
6+
compiler: "clang-18",
7+
defines: {
8+
CMAKE_BUILD_TYPE: "Release",
9+
CMAKE_CXX_STANDARD: "20",
10+
BUILD_TESTING: "ON",
11+
},
12+
});
13+
14+
const pipelines: PipelineDefinition[] = [
15+
{
16+
slug: "ci",
17+
triggers: [push({ branch: "main" }), pullRequest()],
18+
pipeline: pipeline([project.test(), project.lint(), project.fmt()], {
19+
env: { CI: "true" },
20+
defaultImage: "ubuntu:24.04",
21+
}),
22+
},
23+
];
24+
25+
export default pipelines;

examples/cpp/.hm/pipeline.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { pipeline, push, type PipelineDefinition } from "@harmont/hm";
2+
import { cmake } from "@harmont/hm/toolchains";
3+
4+
const project = cmake({ path: ".", defines: { CMAKE_BUILD_TYPE: "Release", CMAKE_CXX_STANDARD: "17" } });
5+
6+
const pipelines: PipelineDefinition[] = [
7+
{
8+
slug: "ci",
9+
triggers: [push({ branch: "main" })],
10+
pipeline: pipeline([project.test(), project.lint(), project.fmt()], {
11+
env: { CI: "true" },
12+
defaultImage: "ubuntu:24.04",
13+
}),
14+
},
15+
];
16+
17+
export default pipelines;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { pipeline, push, pullRequest, type PipelineDefinition } from "@harmont/hm";
2+
import { elixir } from "@harmont/hm/toolchains";
3+
4+
const project = elixir({
5+
path: ".",
6+
elixirVersion: "1.18.3",
7+
otpVersion: "27.3.3",
8+
});
9+
10+
const pipelines: PipelineDefinition[] = [
11+
{
12+
slug: "ci",
13+
triggers: [push({ branch: "main" }), pullRequest()],
14+
pipeline: pipeline(
15+
[
16+
project.compile(),
17+
project.test({ cover: true }),
18+
project.format(),
19+
project.credo(),
20+
project.dialyzer(),
21+
project.sobelow(),
22+
project.depsAudit(),
23+
project.hexAudit(),
24+
],
25+
{
26+
env: {
27+
CI: "true",
28+
MIX_ENV: "test",
29+
},
30+
defaultImage: "ubuntu:24.04",
31+
},
32+
),
33+
},
34+
{
35+
slug: "deploy",
36+
triggers: [push({ branch: "main" })],
37+
pipeline: pipeline(
38+
[project.compile(), project.mix("assets.deploy"), project.release()],
39+
{ env: { MIX_ENV: "prod" }, defaultImage: "ubuntu:24.04" },
40+
),
41+
},
42+
];
43+
44+
export default pipelines;

examples/elixir/.hm/pipeline.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { pipeline, push, type PipelineDefinition } from "@harmont/hm";
2+
import { elixir } from "@harmont/hm/toolchains";
3+
4+
const project = elixir({ path: "." });
5+
6+
const pipelines: PipelineDefinition[] = [
7+
{
8+
slug: "ci",
9+
triggers: [push({ branch: "main" })],
10+
pipeline: pipeline(
11+
[
12+
project.compile(),
13+
project.test(),
14+
project.format(),
15+
project.credo(),
16+
project.dialyzer(),
17+
project.depsAudit(),
18+
project.hexAudit(),
19+
],
20+
{ env: { CI: "true", MIX_ENV: "test" }, defaultImage: "ubuntu:24.04" },
21+
),
22+
},
23+
];
24+
25+
export default pipelines;

examples/go/.hm/pipeline.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { pipeline, push, type PipelineDefinition } from "@harmont/hm";
2+
import { go } from "@harmont/hm/toolchains";
3+
4+
const project = go({ path: "." });
5+
6+
const pipelines: PipelineDefinition[] = [
7+
{
8+
slug: "ci",
9+
triggers: [push({ branch: "main" })],
10+
pipeline: pipeline(
11+
[project.build(), project.test(), project.vet(), project.fmt()],
12+
{ env: { CI: "true" }, defaultImage: "ubuntu:24.04" },
13+
),
14+
},
15+
];
16+
17+
export default pipelines;

0 commit comments

Comments
 (0)